Compare commits
2 Commits
49bbd2b871
...
ff2e46a0d2
| Author | SHA1 | Date | |
|---|---|---|---|
| ff2e46a0d2 | |||
| 690d818fe9 |
@@ -49,14 +49,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
|
||||
/minigame create My_Minigame "My Minigame"
|
||||
/minigame create MyAssetPack My_Minigame "My Minigame"
|
||||
/minigame enable 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
|
||||
|
||||
-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 `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.
|
||||
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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|---|---|
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 5 | Capture Zone A | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 1` | `ModifyPlayerScore ADD 1` |
|
||||
| 6 | Capture Zone B | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 2` | `ModifyPlayerScore ADD 1` |
|
||||
| 7 | Capture Zone C | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 3` | `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` | `ModifyScore PLAYER TRIGGERING_PLAYER 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` |
|
||||
| 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)* |
|
||||
@@ -115,7 +115,7 @@ Conditions:
|
||||
CurrentRound MinigameId=King_Of_The_Hill Compare=EQUAL Round=1
|
||||
|
||||
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.
|
||||
|
||||
+51
-3
@@ -49,6 +49,55 @@ Runtime state includes:
|
||||
|
||||
`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
|
||||
|
||||
`MinigameEvent` implements Hytale's `IEvent<String>`.
|
||||
@@ -94,10 +143,9 @@ Current effect type IDs:
|
||||
- `ResetMinigame`
|
||||
- `SetMinigamePhase`
|
||||
- `ResetScores`
|
||||
- `ModifyPlayerScore`
|
||||
- `ModifyTeamScore`
|
||||
- `ModifyScore`
|
||||
- `ModifyCounter`
|
||||
- `ModifyPlayerLives`
|
||||
- `ModifyLives`
|
||||
- `SetPlayerStatus`
|
||||
- `JoinMinigameQueue`
|
||||
- `LeaveMinigameQueue`
|
||||
|
||||
+48
-37
@@ -8,62 +8,67 @@ All commands are subcommands of `/minigame`.
|
||||
|
||||
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
|
||||
/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>`
|
||||
|
||||
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
|
||||
/minigame edit Beach_Race MaxPlayers 16
|
||||
/minigame edit Beach_Race RoundLengthSeconds 600
|
||||
/minigame edit Beach_Race name Beach Race Deluxe
|
||||
/minigame edit Beach_Race enabled true
|
||||
```
|
||||
|
||||
### `/minigame enable <id>`
|
||||
### `/minigame enable <id>` / `/minigame disable <id>`
|
||||
|
||||
Sets `Enabled = true`.
|
||||
|
||||
### `/minigame disable <id>`
|
||||
|
||||
Sets `Enabled = false`. This prevents new starts but does not necessarily stop an already running runtime.
|
||||
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 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>`
|
||||
|
||||
Deletes the minigame definition.
|
||||
|
||||
### `/minigame export <id>`
|
||||
|
||||
Exports the current definition JSON.
|
||||
|
||||
### `/minigame import <file>`
|
||||
|
||||
Imports a definition from a JSON file path relative to the data directory.
|
||||
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.
|
||||
|
||||
## Runtime Management
|
||||
|
||||
### `/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>`
|
||||
|
||||
@@ -73,28 +78,34 @@ Resets all active runtimes for the minigame 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>`
|
||||
|
||||
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
|
||||
|
||||
### `/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]`
|
||||
|
||||
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.
|
||||
|
||||
`/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.
|
||||
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.
|
||||
|
||||
@@ -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_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_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_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
|
||||
|
||||
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 |
|
||||
|---------|--------|-------------|
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
@@ -79,17 +79,18 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
|
||||
| `ResetMinigame` | `MinigameId` | Resets the selected minigame runtime. |
|
||||
| `SetMinigamePhase` | `MinigameId`, `Phase` | Sets the runtime phase directly. |
|
||||
| `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. |
|
||||
| `ModifyTeamScore` | `MinigameId`, `TeamId`, `Operation`, `Amount` | Sets, adds, or subtracts a team'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. |
|
||||
| `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`. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
@@ -127,7 +128,11 @@ Use an explicit `PlayerId` only when the trigger is not naturally tied to the pl
|
||||
|
||||
Team spawn volumes are normal Hytale trigger volumes tagged with `MinigameId`, `MapId`, optional `VariantId`, `SpawnRole=TeamSpawn`, and `TeamId=<team id>`.
|
||||
|
||||
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 team spawns (step 3), set `ClearCheckpoints=true` when you want players sent back to their start line or team spawn rather than wherever they last checkpointed. A typical round transition chains `SetRound` → `ResetScores` (optional) → `SwapTeams` (for alternating-sides maps) → `RespawnAllPlayers`.
|
||||
|
||||
`SwapTeams` rotates team membership for maps where sides alternate each round (attack/defend). It clears checkpoints by default so the follow-up respawn resolves the new team's spawn; this relies on each side having team spawn volumes tagged as above. With its `Respawn` flag left on, it moves players to their new side immediately and no separate `RespawnAllPlayers` is needed.
|
||||
|
||||
## Item Spawners
|
||||
|
||||
@@ -135,6 +140,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`.
|
||||
|
||||
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
|
||||
|
||||
`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.
|
||||
2. Add `PlayerInMinigame` as a condition.
|
||||
3. Add `ModifyPlayerScore` as an effect.
|
||||
4. Set `MinigameId = Hill_Points`, `Operation = ADD`, and `Amount = 1`.
|
||||
3. Add `ModifyScore` as an effect.
|
||||
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
|
||||
|
||||
@@ -84,15 +84,18 @@ Effect:
|
||||
|
||||
## 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:
|
||||
|
||||
- `MinigameId`
|
||||
- `TeamId`
|
||||
- `TargetType = TEAM`
|
||||
- `TargetSource = TRIGGERING_TEAM` or `TAG_TEAM_ID`
|
||||
- `Operation`
|
||||
- `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`.
|
||||
|
||||
## 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 |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `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. |
|
||||
|
||||
## Players
|
||||
@@ -27,14 +27,14 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
||||
| `MinPlayers` | Integer | No | `1` | Minimum player count. |
|
||||
| `MaxPlayers` | Integer | No | `16` | Maximum player count. |
|
||||
| `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. |
|
||||
| `AllowSpectators` | Boolean | No | `true` | Whether spectators are allowed. |
|
||||
| `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 `/minigame spectate` may join the game as a spectator. |
|
||||
|
||||
## Teams
|
||||
|
||||
| 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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `OvertimeEnabled` | Boolean | No | `false` | When the final round ends, sets the phase to `OVERTIME` and dispatches `on_overtime_start` before `on_game_rounds_complete`. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -72,16 +74,16 @@ Supported values:
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `AllowPvp` | Boolean | No | `false` | Whether players can damage each other. |
|
||||
| `AllowBlockBreaking` | Boolean | No | `false` | Whether blocks can be broken. Readable via definition; enforcement relies on `RequiredGameMode` (`Adventure` blocks both break and place by default). |
|
||||
| `AllowBlockPlacing` | Boolean | No | `false` | Whether blocks can be placed. Same enforcement note as above. |
|
||||
| `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` | Enforced: block breaking by players in this game is cancelled when `false`. |
|
||||
| `AllowBlockPlacing` | Boolean | No | `false` | Enforced: block placing by players in this game is cancelled when `false`. |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
|
||||
## 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`.
|
||||
|
||||
+3
-1
@@ -42,7 +42,7 @@ Team spawn volumes are normal trigger volumes used as teleport destinations by `
|
||||
| `MapId` | Recommended | Map ID | Filters spawns to the current runtime's map |
|
||||
| `VariantId` | No | Variant ID | Filters spawns to the current runtime's variant when present |
|
||||
| `SpawnRole` | Yes | `TeamSpawn` | Marks the volume as a team spawn candidate |
|
||||
| `TeamId` | Yes | Team ID, such as `red` or `blue` | Selects spawns for the player's assigned team |
|
||||
| `TeamId` | Yes | Team ID, such as `red` or `blue` | Selects spawns for the player's assigned team; also used by `ModifyScore`/`ModifyLives` when `TargetSource=TAG_TEAM_ID` |
|
||||
|
||||
Example:
|
||||
|
||||
@@ -55,6 +55,8 @@ TeamId=red
|
||||
|
||||
`RespawnPlayer` chooses a random matching team spawn when no explicit `DestinationId` and no player checkpoint are available.
|
||||
|
||||
`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 let runtime state changes fire normal Hytale trigger-volume logic by toggling a tag on matching volumes.
|
||||
|
||||
+3
-3
@@ -58,10 +58,10 @@ Use a normal trigger volume around the scoring area.
|
||||
Attach:
|
||||
|
||||
- Condition: `PlayerInMinigame`
|
||||
- Effect: `ModifyPlayerScore`
|
||||
- Effect: `ModifyScore`
|
||||
- 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
|
||||
|
||||
@@ -90,7 +90,7 @@ Tag each team spawn volume with:
|
||||
|
||||
This means checkpoint-based games can still override spawns per player, while team arena games can rely on tagged team spawn 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. Team-spawn fallback is resolved from tagged spawn volumes.
|
||||
|
||||
### Counters
|
||||
|
||||
|
||||
@@ -24,9 +24,13 @@ import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||
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.MinigameKillScoreSystem;
|
||||
import net.kewwbec.minigames.system.MinigamePregameSystem;
|
||||
import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems;
|
||||
import net.kewwbec.minigames.ui.MinigameHudService;
|
||||
import net.kewwbec.minigames.ui.MinigameHudSystem;
|
||||
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
|
||||
@@ -54,15 +58,16 @@ import net.kewwbec.minigames.volume.effect.EndMinigameEffect;
|
||||
import net.kewwbec.minigames.volume.effect.LeaveMinigameQueueEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyCounterEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyObjectiveProgressEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerLivesEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerScoreEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyTeamScoreEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyLivesEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyScoreEffect;
|
||||
import net.kewwbec.minigames.mount.LockMountComponent;
|
||||
import net.kewwbec.minigames.mount.LockMountPacketHandler;
|
||||
import net.kewwbec.minigames.volume.effect.DismountPlayerEffect;
|
||||
import net.kewwbec.minigames.volume.effect.MountPlayerEffect;
|
||||
import net.kewwbec.minigames.volume.effect.PlaceBlocksEffect;
|
||||
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.ResetScoresEffect;
|
||||
import net.kewwbec.minigames.volume.effect.RestoreInventoryEffect;
|
||||
@@ -95,7 +100,6 @@ import net.kewwbec.minigames.command.TriggerVolumeCloneGroupCommand;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
|
||||
@@ -106,6 +110,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
private MinigameQueueUIService queueUIService;
|
||||
private PacketFilter duplicatePacketFilter;
|
||||
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<>();
|
||||
|
||||
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
||||
super(init);
|
||||
@@ -132,9 +138,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
.build()
|
||||
);
|
||||
|
||||
var dataRoot = Path.of("minigame-core");
|
||||
var runtimeService = new DefaultMinigameRuntimeService();
|
||||
var minigameService = new DefaultMinigameService(dataRoot, runtimeService);
|
||||
var minigameService = new DefaultMinigameService(runtimeService);
|
||||
var adapters = new HytaleServerAdapters();
|
||||
var signals = new MinigameVolumeSignalService();
|
||||
runtimeService.setSignalService(signals);
|
||||
@@ -143,7 +148,6 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
runtimeService.setInventoryAdapter(adapters);
|
||||
var menuService = new MinigameMenuService();
|
||||
menuService.setPlayerAdapter(adapters);
|
||||
runtimeService.setMenuService(menuService);
|
||||
getEntityStoreRegistry().registerSystem(new MinigameMenuOpenSystem(menuService));
|
||||
var hudService = new MinigameHudService();
|
||||
hudService.setPlayerAdapter(adapters);
|
||||
@@ -151,11 +155,23 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService));
|
||||
var queueService = new MinigameQueueService();
|
||||
runtimeService.setQueueService(queueService);
|
||||
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(runtimeService));
|
||||
this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), queueService, signals);
|
||||
this.services = new MinigameServices(minigameService, runtimeService, adapters,
|
||||
new MinigameMapDiscoveryService(), queueService, signals, menuService);
|
||||
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(services));
|
||||
this.queueUIService = new MinigameQueueUIService(services);
|
||||
this.queueUIService.setPlayerAdapter(adapters);
|
||||
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
|
||||
|
||||
// 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();
|
||||
|
||||
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
|
||||
@@ -188,8 +204,10 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
return instance != null ? instance.queueUIService : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
||||
return instance.lockMountComponentType;
|
||||
MinigameCorePlugin plugin = instance;
|
||||
return plugin != null ? plugin.lockMountComponentType : null;
|
||||
}
|
||||
|
||||
private void injectTriggerVolumeCloneCommand() {
|
||||
@@ -216,7 +234,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
hasBeenRegisteredField.set(tvCommand, true);
|
||||
LOGGER.atInfo().log("Injected /triggervolume clone and clonegroup commands");
|
||||
} 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 and clonegroup are UNAVAILABLE. "
|
||||
+ "The server build likely changed CommandManager internals; core-minigames needs an update.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +249,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ResetMinigameEffect.TYPE_ID, ResetMinigameEffect.class, ResetMinigameEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID, SetMinigamePhaseEffect.class, SetMinigamePhaseEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ResetScoresEffect.TYPE_ID, ResetScoresEffect.class, ResetScoresEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyPlayerScoreEffect.TYPE_ID, ModifyPlayerScoreEffect.class, ModifyPlayerScoreEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyTeamScoreEffect.TYPE_ID, ModifyTeamScoreEffect.class, ModifyTeamScoreEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyScoreEffect.TYPE_ID, ModifyScoreEffect.class, ModifyScoreEffect.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, SetPlayerLoadoutEffect.TYPE_ID, SetPlayerLoadoutEffect.class, SetPlayerLoadoutEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID, JoinMinigameQueueEffect.class, JoinMinigameQueueEffect.CODEC);
|
||||
@@ -246,6 +267,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerTriggerVolumeEffectType(triggerVolumes, StopTimerEffect.TYPE_ID, StopTimerEffect.class, StopTimerEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetSpawnPointEffect.TYPE_ID, SetSpawnPointEffect.class, SetSpawnPointEffect.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, AssignTeamEffect.TYPE_ID, AssignTeamEffect.class, AssignTeamEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SaveInventoryEffect.TYPE_ID, SaveInventoryEffect.class, SaveInventoryEffect.CODEC);
|
||||
@@ -266,6 +289,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
@Nonnull Class<T> clazz,
|
||||
@Nonnull BuilderCodec<T> codec
|
||||
) {
|
||||
ownEffectTypeIds.add(typeId);
|
||||
if (TriggerEffect.CODEC.getCodecFor(typeId) != null) {
|
||||
return;
|
||||
}
|
||||
@@ -297,6 +321,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
@Nonnull Class<T> clazz,
|
||||
@Nonnull BuilderCodec<T> codec
|
||||
) {
|
||||
ownConditionTypeIds.add(typeId);
|
||||
if (TriggerCondition.CODEC.getCodecFor(typeId) == null) {
|
||||
TriggerCondition.CODEC.register(typeId, clazz, codec);
|
||||
}
|
||||
@@ -319,10 +344,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, ResetMinigameEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ResetScoresEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyPlayerScoreEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyTeamScoreEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyScoreEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyCounterEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyLivesEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerStatusEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID);
|
||||
@@ -330,6 +354,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, VoteForMapEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SpawnItemEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(SpawnItemEffect.TYPE_ID, "ItemId", "Item");
|
||||
registerMinigameIdAssetField(triggerVolumes, SpawnNpcEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(SpawnNpcEffect.TYPE_ID, "RoleId", "NpcRole");
|
||||
registerMinigameIdAssetField(triggerVolumes, MountPlayerEffect.TYPE_ID);
|
||||
@@ -343,6 +368,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, StopTimerEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetSpawnPointEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, RespawnPlayerEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, RespawnAllPlayersEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SwapTeamsEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SendMinigameMessageEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, AssignTeamEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SaveInventoryEffect.TYPE_ID);
|
||||
@@ -352,6 +379,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(PlaceBlocksEffect.TYPE_ID, "BlockId", "BlockType");
|
||||
registerMinigameIdAssetField(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StartPlayerTimerEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StopPlayerTimerEffect.TYPE_ID);
|
||||
@@ -374,44 +402,12 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
}
|
||||
|
||||
private int registeredTriggerEffectCount() {
|
||||
int count = 0;
|
||||
for (String typeId : TriggerEffect.CODEC.getRegisteredIds()) {
|
||||
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;
|
||||
var registered = TriggerEffect.CODEC.getRegisteredIds();
|
||||
return (int) ownEffectTypeIds.stream().filter(registered::contains).count();
|
||||
}
|
||||
|
||||
private int registeredTriggerConditionCount() {
|
||||
int count = 0;
|
||||
for (String typeId : TriggerCondition.CODEC.getRegisteredIds()) {
|
||||
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)
|
||||
|| typeId.equals(CurrentRoundCondition.TYPE_ID) || typeId.equals(PlayerLivesCondition.TYPE_ID)
|
||||
|| typeId.equals(TeamCondition.TYPE_ID) || typeId.equals(TimerElapsedCondition.TYPE_ID)
|
||||
|| typeId.equals(ObjectiveStatusCondition.TYPE_ID) || typeId.equals(WinConditionMetCondition.TYPE_ID)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
var registered = TriggerCondition.CODEC.getRegisteredIds();
|
||||
return (int) ownConditionTypeIds.stream().filter(registered::contains).count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class HytaleServerAdapters implements
|
||||
@@ -79,7 +78,7 @@ public final class HytaleServerAdapters implements
|
||||
@Override
|
||||
public void setGameMode(String playerId, GameMode gameMode) {
|
||||
if (gameMode == null) return;
|
||||
withPlayerRef(playerId, ref -> {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
Player.setGameMode(ref, gameMode, ref.getStore());
|
||||
return null;
|
||||
});
|
||||
@@ -88,7 +87,7 @@ public final class HytaleServerAdapters implements
|
||||
@Override
|
||||
public void giveItem(String playerId, String itemId, int amount) {
|
||||
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
||||
withPlayerRef(playerId, ref -> {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
Item item = Item.getAssetMap().getAsset(itemId);
|
||||
if (item != null) {
|
||||
ItemStack stack = new ItemStack(item.getId(), amount);
|
||||
@@ -145,7 +144,7 @@ public final class HytaleServerAdapters implements
|
||||
|
||||
@Override
|
||||
public void restoreInventory(String playerId, Map<String, Object> inventory) {
|
||||
withPlayerRef(playerId, ref -> {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
var store = ref.getStore();
|
||||
restoreInventory(store, ref, "armor", inventory, InventoryComponent.Armor.getComponentType());
|
||||
restoreInventory(store, ref, "hotbar", inventory, InventoryComponent.Hotbar.getComponentType());
|
||||
@@ -175,7 +174,7 @@ public final class HytaleServerAdapters implements
|
||||
|
||||
@Override
|
||||
public void clearInventory(String playerId) {
|
||||
withPlayerRef(playerId, ref -> {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
InventoryUtils.clear(ref, ref.getStore());
|
||||
return null;
|
||||
});
|
||||
@@ -242,6 +241,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) {
|
||||
return findPlayer(playerId).flatMap(player -> {
|
||||
var ref = player.getReference();
|
||||
@@ -252,7 +275,9 @@ public final class HytaleServerAdapters implements
|
||||
if (world.isInThread()) {
|
||||
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 +285,7 @@ public final class HytaleServerAdapters implements
|
||||
if (world.isInThread()) {
|
||||
action.run();
|
||||
} else {
|
||||
CompletableFuture.runAsync(action, world).join();
|
||||
world.execute(action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,12 +318,13 @@ public final class HytaleServerAdapters implements
|
||||
if (parts.length == 3 || parts.length == 6) {
|
||||
return coordinates(currentWorld, parts, 0);
|
||||
}
|
||||
var universe = Universe.get();
|
||||
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);
|
||||
}
|
||||
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 null;
|
||||
|
||||
@@ -2,7 +2,6 @@ package net.kewwbec.minigames.command;
|
||||
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import net.kewwbec.minigames.command.sub.*;
|
||||
import net.kewwbec.minigames.command.sub.volume.MinigameVolumeCommand;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
public final class MinigameCommand extends AbstractCommandCollection {
|
||||
@@ -19,8 +18,6 @@ public final class MinigameCommand extends AbstractCommandCollection {
|
||||
addSubCommand(new MinigameStopCommand(services));
|
||||
addSubCommand(new MinigameResetCommand(services));
|
||||
addSubCommand(new MinigameValidateCommand(services));
|
||||
addSubCommand(new MinigameExportCommand(services));
|
||||
addSubCommand(new MinigameImportCommand(services));
|
||||
addSubCommand(new MinigameListCommand(services));
|
||||
addSubCommand(new MinigameInfoCommand(services));
|
||||
addSubCommand(new MinigameMapsCommand(services));
|
||||
@@ -28,6 +25,5 @@ public final class MinigameCommand extends AbstractCommandCollection {
|
||||
addSubCommand(new MinigameJoinCommand(services));
|
||||
addSubCommand(new MinigameLeaveCommand(services));
|
||||
addSubCommand(new MinigameSpectateCommand(services));
|
||||
addSubCommand(new MinigameVolumeCommand(services));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,68 @@
|
||||
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.asset.AssetModule;
|
||||
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.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.service.DefaultMinigameService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public final class MinigameCreateCommand extends CommandBase {
|
||||
private final MinigameServices services;
|
||||
private final RequiredArg<String> packArg;
|
||||
private final RequiredArg<String> idArg;
|
||||
private final RequiredArg<String> nameArg;
|
||||
|
||||
public MinigameCreateCommand(MinigameServices services) {
|
||||
super("create", "minigames.command.spec.create.description");
|
||||
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.nameArg = withRequiredArg("name", "minigames.command.spec.create.arg.name", ArgTypes.GREEDY_STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
String pack = ctx.get(packArg);
|
||||
String id = ctx.get(idArg);
|
||||
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()) {
|
||||
ctx.sendMessage(Message.translation("minigames.command.error.already_exists").param("id", id));
|
||||
return;
|
||||
}
|
||||
|
||||
MinigameDefinition def = services.minigames().create(id, name);
|
||||
ctx.sendMessage(Message.translation("minigames.command.create.success")
|
||||
.param("id", def.getId())
|
||||
.param("name", def.getName()));
|
||||
try {
|
||||
MinigameDefinition def = services.minigames().create(pack, id, name);
|
||||
ctx.sendMessage(Message.translation("minigames.command.create.success")
|
||||
.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;
|
||||
}
|
||||
|
||||
if (!services.runtime().runtimes(id).isEmpty()) {
|
||||
services.runtime().end(id, "minigames.runtime.reason.deleted");
|
||||
}
|
||||
|
||||
services.minigames().delete(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 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 {
|
||||
private final MinigameServices services;
|
||||
private final RequiredArg<String> idArg;
|
||||
private final RequiredArg<String> propertyArg;
|
||||
private final RequiredArg<String> valueArg;
|
||||
|
||||
public MinigameEditCommand(MinigameServices services) {
|
||||
super("edit", "minigames.command.spec.edit.description");
|
||||
this.services = services;
|
||||
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
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
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));
|
||||
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();
|
||||
boolean running = !services.runtime().runtimes(id).isEmpty();
|
||||
boolean running = !services.runtime().runtimesForMinigame(id).isEmpty();
|
||||
|
||||
ctx.sendMessage(Message.translation("minigames.command.info.header")
|
||||
.param("id", d.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.basecommands.CommandBase;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@@ -27,7 +28,8 @@ public final class MinigameJoinCommand extends CommandBase {
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
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));
|
||||
return;
|
||||
}
|
||||
@@ -38,7 +40,12 @@ public final class MinigameJoinCommand extends CommandBase {
|
||||
return;
|
||||
}
|
||||
|
||||
services.queue().join(id, target.getUuid().toString());
|
||||
ctx.sendMessage(Message.raw("[Minigames] Joined queue for " + id + "."));
|
||||
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), target.getUuid().toString());
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ public final class MinigameLeaveCommand extends CommandBase {
|
||||
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());
|
||||
|
||||
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(
|
||||
Message.translation("minigames.command.list.entry")
|
||||
.param("id", def.getId())
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package net.kewwbec.minigames.command.sub;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||
@@ -7,9 +11,16 @@ 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.basecommands.CommandBase;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
|
||||
public final class MinigameSpectateCommand extends CommandBase {
|
||||
private final MinigameServices services;
|
||||
@@ -27,12 +38,18 @@ public final class MinigameSpectateCommand extends CommandBase {
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
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));
|
||||
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));
|
||||
return;
|
||||
}
|
||||
@@ -43,6 +60,59 @@ public final class MinigameSpectateCommand extends CommandBase {
|
||||
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();
|
||||
PlayerMinigameState state = runtime.players().computeIfAbsent(playerId,
|
||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||
state.status(PlayerStatus.SPECTATOR);
|
||||
runtime.spectators().add(playerId);
|
||||
|
||||
teleportToArena(target, runtime);
|
||||
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
||||
}
|
||||
|
||||
/** Best effort: drop the spectator at the arena's MainArena volume if one is visible from their world. */
|
||||
private void teleportToArena(PlayerRef target, MinigameRuntime runtime) {
|
||||
var ref = target.getReference();
|
||||
if (ref == null || !ref.isValid()) {
|
||||
return;
|
||||
}
|
||||
Store<EntityStore> store = ref.getStore();
|
||||
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
VolumeEntry arena = findMainArena(manager, runtime);
|
||||
if (arena == null) {
|
||||
return;
|
||||
}
|
||||
var pos = arena.getPosition();
|
||||
String destination = pos.x() + "," + pos.y() + "," + pos.z();
|
||||
services.adapters().teleport(target.getUuid().toString(), destination);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
var tags = volume.getRawTags();
|
||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
continue;
|
||||
}
|
||||
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
|
||||
continue;
|
||||
}
|
||||
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
|
||||
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
|
||||
continue;
|
||||
}
|
||||
return volume;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.service.MinigameStarter;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Optional;
|
||||
@@ -41,8 +42,12 @@ public final class MinigameStartCommand extends CommandBase {
|
||||
|
||||
PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null;
|
||||
Store<EntityStore> store = player != null && player.getReference() != null ? player.getReference().getStore() : null;
|
||||
var discovered = services.maps().discover(store, id);
|
||||
services.queue().startQueued(def.get(), discovered.candidates(), services.runtime());
|
||||
ctx.sendMessage(Message.translation("minigames.command.start.success").param("id", id));
|
||||
// Admin-started games skip the min-player pregame check.
|
||||
var runtime = MinigameStarter.startQueued(services, def.get(), store, true);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public final class MinigameStopCommand extends CommandBase {
|
||||
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));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ public final class MinigameVoteCommand extends CommandBase {
|
||||
return;
|
||||
}
|
||||
|
||||
services.queue().vote(id, player.getUuid().toString(), mapId);
|
||||
ctx.sendMessage(Message.raw("[Minigames] Voted for " + mapId + " in " + id + "."));
|
||||
boolean voted = services.queue().vote(id, player.getUuid().toString(), mapId);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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.Validators;
|
||||
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.MapSelectionMode;
|
||||
import net.kewwbec.minigames.model.Enums.GameType;
|
||||
import net.kewwbec.minigames.model.Enums.TeamMode;
|
||||
import net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
||||
import net.kewwbec.minigames.model.Enums.WinCondition;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@@ -158,6 +158,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
(def, parent) -> def.overtimeEnabled = parent.overtimeEnabled
|
||||
)
|
||||
.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(
|
||||
new KeyedCodec<>("SuddenDeathEnabled", Codec.BOOLEAN),
|
||||
(def, v) -> def.suddenDeathEnabled = v,
|
||||
@@ -165,6 +172,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
(def, parent) -> def.suddenDeathEnabled = parent.suddenDeathEnabled
|
||||
)
|
||||
.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(
|
||||
new KeyedCodec<>("WinCondition", new EnumCodec<>(WinCondition.class)),
|
||||
(def, v) -> def.winCondition = v,
|
||||
@@ -354,7 +368,9 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
protected int countdownSeconds = 10;
|
||||
protected int defaultPlayerLives = 0;
|
||||
protected boolean overtimeEnabled = false;
|
||||
protected int overtimeSeconds = 60;
|
||||
protected boolean suddenDeathEnabled = false;
|
||||
protected int suddenDeathSeconds = 60;
|
||||
protected WinCondition winCondition = WinCondition.HIGHEST_SCORE;
|
||||
protected MapSelectionMode mapSelectionMode = MapSelectionMode.VOTE;
|
||||
protected FriendlyFireKillScoreMode friendlyFireKillScoreMode = FriendlyFireKillScoreMode.NO_POINTS;
|
||||
@@ -423,7 +439,9 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
public int getCountdownSeconds() { return countdownSeconds; }
|
||||
public int getDefaultPlayerLives() { return defaultPlayerLives; }
|
||||
public boolean isOvertimeEnabled() { return overtimeEnabled; }
|
||||
public int getOvertimeSeconds() { return overtimeSeconds; }
|
||||
public boolean isSuddenDeathEnabled() { return suddenDeathEnabled; }
|
||||
public int getSuddenDeathSeconds() { return suddenDeathSeconds; }
|
||||
public WinCondition getWinCondition() { return winCondition != null ? winCondition : WinCondition.HIGHEST_SCORE; }
|
||||
public MapSelectionMode getMapSelectionMode() { return mapSelectionMode != null ? mapSelectionMode : MapSelectionMode.VOTE; }
|
||||
public FriendlyFireKillScoreMode getFriendlyFireKillScoreMode() { return friendlyFireKillScoreMode != null ? friendlyFireKillScoreMode : FriendlyFireKillScoreMode.NO_POINTS; }
|
||||
|
||||
@@ -35,7 +35,9 @@ public final class LockMountPacketHandler implements SubPacketHandler {
|
||||
|
||||
Store<EntityStore> store = ref.getStore();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 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.logger.HytaleLogger;
|
||||
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.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.PlayerMinigameState;
|
||||
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.ui.MinigameHudService;
|
||||
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -26,14 +25,24 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
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;
|
||||
|
||||
public final class DefaultMinigameRuntimeService implements MinigameRuntimeService {
|
||||
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
|
||||
private MinigameVolumeSignalService signalService;
|
||||
@Nullable
|
||||
@@ -45,14 +54,8 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
@Nullable
|
||||
private HytaleAdapters.InventoryAdapter inventoryAdapter;
|
||||
@Nullable
|
||||
private MinigameMenuService menuService;
|
||||
@Nullable
|
||||
private MinigameHudService hudService;
|
||||
|
||||
public void setMenuService(@Nullable MinigameMenuService menuService) {
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
public void setHudService(@Nullable MinigameHudService hudService) {
|
||||
this.hudService = hudService;
|
||||
}
|
||||
@@ -79,14 +82,28 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
|
||||
@Override
|
||||
public MinigameRuntime start(MinigameDefinition definition) {
|
||||
return start(definition, MinigameMapCandidate.none(definition.getId()));
|
||||
return start(definition, MinigameMapCandidate.none(definition.getId()), null, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
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 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);
|
||||
|
||||
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("$countdownNext", (long) countdownSecs);
|
||||
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.WAITING_FOR_PLAYERS);
|
||||
}
|
||||
setPhase(runtime, MinigamePhase.WAITING_FOR_PLAYERS);
|
||||
dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime)));
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tickPregame(long nowMs) {
|
||||
for (MinigameRuntime runtime : List.copyOf(runtimes.values())) {
|
||||
if (runtime.phase() != MinigamePhase.WAITING_FOR_PLAYERS) continue;
|
||||
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
||||
public void setupTeams(MinigameRuntime runtime) {
|
||||
if (runtime.definition().getTeamMode() != TeamMode.TEAMS) {
|
||||
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)
|
||||
int present = countPresentPlayers(runtime);
|
||||
if (present < runtime.definition().getMinPlayers()) {
|
||||
cancelPregame(runtime, "player_count_too_low");
|
||||
continue;
|
||||
private static List<TeamState> ensureTeams(MinigameRuntime runtime) {
|
||||
List<TeamState> teams = new ArrayList<>(runtime.teams().values());
|
||||
if (teams.isEmpty()) {
|
||||
int count = Math.max(0, runtime.definition().getTeamCount());
|
||||
if (count == 0) {
|
||||
int perTeam = runtime.definition().getPlayersPerTeam();
|
||||
if (perTeam > 0) {
|
||||
count = (int) Math.ceil((double) runtime.players().size() / perTeam);
|
||||
}
|
||||
}
|
||||
for (int i = 1; i <= count; i++) {
|
||||
String id = "team" + i;
|
||||
teams.add(runtime.teams().computeIfAbsent(id, key -> new TeamState(key, key, null)));
|
||||
}
|
||||
}
|
||||
teams.sort(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");
|
||||
@@ -126,7 +201,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
if (remainingSecs < nextTick) {
|
||||
runtime.variables().put("$countdownNext", remainingSecs);
|
||||
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)));
|
||||
if (playerAdapter != null && remainingSecs > 0) {
|
||||
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) {
|
||||
runtime.phase(MinigamePhase.STARTING);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.STARTING);
|
||||
}
|
||||
setPhase(runtime, MinigamePhase.STARTING);
|
||||
dispatch(new MinigameEvent("on_game_starting", runtime.minigameId(), eventPayload(runtime)));
|
||||
}
|
||||
|
||||
@@ -165,124 +237,195 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
if (queueService != null) {
|
||||
for (String playerId : runtime.players().keySet()) {
|
||||
if (playerAdapter == null || playerAdapter.isOnline(playerId)) {
|
||||
queueService.join(runtime.minigameId(), playerId);
|
||||
queueService.join(runtime.definition(), playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
var payload = new HashMap<>(eventPayload(runtime));
|
||||
payload.put("reason", reason);
|
||||
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);
|
||||
runtimes.remove(runtime.runtimeId());
|
||||
}
|
||||
|
||||
private int countPresentPlayers(MinigameRuntime runtime) {
|
||||
if (playerAdapter == null) return runtime.players().size();
|
||||
int count = 0;
|
||||
for (String playerId : runtime.players().keySet()) {
|
||||
if (playerAdapter.isOnline(playerId)) count++;
|
||||
for (var entry : runtime.players().entrySet()) {
|
||||
if (entry.getValue().status() == PlayerStatus.LEFT) continue;
|
||||
if (playerAdapter == null || playerAdapter.isOnline(entry.getKey())) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void end(String minigameId, String reason) {
|
||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
||||
public void endRuntime(String runtimeId, String reason) {
|
||||
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) {
|
||||
hudService.removeAllForRuntime(runtime.runtimeId());
|
||||
}
|
||||
runtime.phase(MinigamePhase.ENDING);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
|
||||
signalService.cancelAll(runtime.runtimeId());
|
||||
}
|
||||
// ENDING signals fire while the players map is still intact so volumes have an actor.
|
||||
setPhase(runtime, MinigamePhase.ENDING);
|
||||
cleanupTemporaryEntities(runtime);
|
||||
if (runtime.definition().isRestorePlayerInventory()) {
|
||||
restoreAllInventories(runtime);
|
||||
}
|
||||
giveRewards(runtime);
|
||||
sendEndScores(runtime);
|
||||
var payload = new HashMap<String, Object>(eventPayload(runtime));
|
||||
payload.put("reason", reason);
|
||||
dispatch(new MinigameEvent("on_game_end", runtime.minigameId(), payload));
|
||||
clearRuntimeState(runtime);
|
||||
runtimes.remove(runtime.runtimeId());
|
||||
|
||||
if (runtime.definition().isResetOnEnd()) {
|
||||
if (signalService != null) {
|
||||
runtime.phase(MinigamePhase.RESETTING);
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
|
||||
}
|
||||
// One-shot only: the runtime is about to be removed, so tick loops registered
|
||||
// for RESETTING would never be cancelled (they used to leak forever).
|
||||
setPhase(runtime, MinigamePhase.RESETTING, false);
|
||||
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
|
||||
public void reset(String minigameId) {
|
||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
||||
runtime.phase(MinigamePhase.RESETTING);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
|
||||
signalService.cancelAll(runtime.runtimeId());
|
||||
}
|
||||
clearRuntimeState(runtime);
|
||||
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
||||
for (MinigameRuntime runtime : runtimesForMinigame(minigameId)) {
|
||||
doReset(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
|
||||
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
||||
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()) {
|
||||
var playerState = runtime.players().get(playerId);
|
||||
if (playerState != null && playerState.savedInventory().isEmpty()) {
|
||||
var snapshot = inventoryAdapter.saveInventory(playerId);
|
||||
playerState.savedInventory().putAll(snapshot);
|
||||
inventoryAdapter.clearInventory(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerAdapter != null) {
|
||||
var playerState = runtime.players().get(playerId);
|
||||
LoadoutConfig loadout = resolveLoadout(runtime, playerState);
|
||||
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());
|
||||
}
|
||||
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) {
|
||||
hudService.requestHudShow(playerId, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<MinigameRuntime> runtime(String id) {
|
||||
MinigameRuntime byRuntimeId = runtimes.get(id);
|
||||
if (byRuntimeId != null) {
|
||||
return Optional.of(byRuntimeId);
|
||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||
var player = runtime.players().get(playerId);
|
||||
if (player == null || player.status() == PlayerStatus.LEFT) {
|
||||
return;
|
||||
}
|
||||
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 (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");
|
||||
}
|
||||
return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(id)).findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<MinigameRuntime> runtimes(String minigameId) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -313,20 +456,32 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
@Override
|
||||
public void shutdownAll(String reason) {
|
||||
for (String runtimeId : runtimes.keySet().toArray(String[]::new)) {
|
||||
end(runtimeId, reason);
|
||||
endRuntime(runtimeId, reason);
|
||||
}
|
||||
runtimes.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
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)) {
|
||||
Object timerName = payload != null ? payload.get("timer_name") : null;
|
||||
if ("$round".equals(timerName)) {
|
||||
if (TIMER_ROUND.equals(timerName)) {
|
||||
handleRoundTimerExpiry(runtime);
|
||||
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));
|
||||
if (payload != null) full.putAll(payload);
|
||||
@@ -347,30 +502,17 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
dispatch(new MinigameEvent("on_round_end", runtime.minigameId(), Map.copyOf(endPayload)));
|
||||
|
||||
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));
|
||||
donePayload.put("final_round", current);
|
||||
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
|
||||
|
||||
if (!runtime.definition().isOvertimeEnabled() && !runtime.definition().isSuddenDeathEnabled()) {
|
||||
end(runtime.minigameId(), "rounds_complete");
|
||||
// Sequential extra phases: overtime first (if enabled), then sudden death.
|
||||
if (runtime.definition().isOvertimeEnabled()) {
|
||||
enterOvertime(runtime);
|
||||
} else if (runtime.definition().isSuddenDeathEnabled()) {
|
||||
enterSuddenDeath(runtime);
|
||||
} else {
|
||||
endRuntime(runtime.runtimeId(), "rounds_complete");
|
||||
}
|
||||
} else {
|
||||
int next = current + 1;
|
||||
@@ -381,7 +523,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
signalService.onRoundStart(runtime, next);
|
||||
int roundLen = runtime.definition().getRoundLengthSeconds();
|
||||
if (roundLen > 0) {
|
||||
signalService.startTimer(runtime, "$round", roundLen * 1000L);
|
||||
signalService.startTimer(runtime, TIMER_ROUND, roundLen * 1000L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,12 +533,22 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<MinigameRuntime> matching(String id) {
|
||||
MinigameRuntime runtime = runtimes.get(id);
|
||||
if (runtime != null) {
|
||||
return java.util.List.of(runtime);
|
||||
private void enterOvertime(MinigameRuntime runtime) {
|
||||
setPhase(runtime, MinigamePhase.OVERTIME);
|
||||
dispatch(new MinigameEvent("on_overtime_start", runtime.minigameId(), eventPayload(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) {
|
||||
@@ -410,8 +562,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
return;
|
||||
}
|
||||
|
||||
var entityIds = java.util.List.copyOf(runtime.temporaryEntities());
|
||||
LOGGER.atInfo().log("Cleaning up %s temporary entities for runtime=%s minigame=%s", entityIds.size(), runtime.runtimeId(), runtime.minigameId());
|
||||
var entityIds = List.copyOf(runtime.temporaryEntities());
|
||||
for (String entityId : entityIds) {
|
||||
entityAdapter.despawn(entityId);
|
||||
}
|
||||
@@ -447,14 +598,13 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
||||
|
||||
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 : recipients) {
|
||||
for (String recipient : runtime.players().keySet()) {
|
||||
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
|
||||
int rank = 1;
|
||||
for (String playerId : ranked) {
|
||||
var state = runtime.players().get(playerId);
|
||||
PlayerMinigameState state = runtime.players().get(playerId);
|
||||
if (state == null) continue;
|
||||
String name = playerAdapter.getDisplayName(playerId);
|
||||
String scoreText = lowestTime
|
||||
@@ -485,7 +635,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
RewardConfig[] rewards = runtime.definition().getRewards();
|
||||
if (rewards == null || rewards.length == 0) return;
|
||||
|
||||
List<String> ranked = buildRankedPlayers(runtime);
|
||||
List<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||
for (RewardConfig reward : rewards) {
|
||||
if (reward == null || reward.getTarget() == null) continue;
|
||||
Set<String> eligible = resolveRewardTargets(reward.getTarget(), ranked);
|
||||
@@ -499,41 +649,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) {
|
||||
String lower = target.trim().toLowerCase();
|
||||
if ("all".equals(lower)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
|
||||
@@ -19,6 +20,9 @@ public final class MinigameRuntime {
|
||||
private final String minigameId;
|
||||
private final String mapId;
|
||||
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 int roundNumber = 1;
|
||||
private final Instant createdAt = Instant.now();
|
||||
@@ -50,6 +54,15 @@ public final class MinigameRuntime {
|
||||
public String minigameId() { return minigameId; }
|
||||
public String mapId() { return mapId; }
|
||||
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 void phase(MinigamePhase phase) { this.phase = phase; }
|
||||
public int roundNumber() { return roundNumber; }
|
||||
|
||||
@@ -3,22 +3,68 @@ package net.kewwbec.minigames.runtime;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
|
||||
public interface MinigameRuntimeService {
|
||||
MinigameRuntime start(MinigameDefinition definition);
|
||||
|
||||
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);
|
||||
Optional<MinigameRuntime> runtime(String id);
|
||||
Collection<MinigameRuntime> runtimes(String minigameId);
|
||||
|
||||
/** 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> runtimeForPlayer(String playerId);
|
||||
|
||||
Collection<MinigameRuntime> runtimes();
|
||||
|
||||
void shutdownAll(String reason);
|
||||
|
||||
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.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.MinigameRuntimeService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
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 LocalJsonMinigameDefinitionStore store;
|
||||
private final MinigameDefinitionStore store;
|
||||
|
||||
public DefaultMinigameService(Path dataRoot, MinigameRuntimeService runtimeService) {
|
||||
public DefaultMinigameService(MinigameRuntimeService 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
|
||||
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);
|
||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition));
|
||||
try {
|
||||
store.save(definition);
|
||||
store.save(packName, definition);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to save minigame definition: " + id, e);
|
||||
}
|
||||
@@ -38,7 +45,6 @@ public final class DefaultMinigameService implements MinigameService {
|
||||
|
||||
@Override
|
||||
public void update(MinigameDefinition definition) {
|
||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition));
|
||||
try {
|
||||
store.save(definition);
|
||||
} catch (IOException e) {
|
||||
@@ -48,7 +54,7 @@ public final class DefaultMinigameService implements MinigameService {
|
||||
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
runtimeService.end(id, "minigames.runtime.reason.definition_deleted");
|
||||
runtimeService.endAll(id, "minigames.runtime.reason.definition_deleted");
|
||||
try {
|
||||
store.delete(id);
|
||||
} catch (IOException e) {
|
||||
@@ -60,12 +66,7 @@ public final class DefaultMinigameService implements MinigameService {
|
||||
public void setEnabled(String id, boolean enabled) {
|
||||
definition(id).ifPresent(def -> {
|
||||
def.setEnabled(enabled);
|
||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(def));
|
||||
try {
|
||||
store.save(def);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to save minigame definition: " + id, e);
|
||||
}
|
||||
update(def);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ public final class DefaultMinigameService implements MinigameService {
|
||||
|
||||
@Override
|
||||
public void end(String id, String reason) {
|
||||
runtimeService.end(id, reason);
|
||||
runtimeService.endAll(id, reason);
|
||||
}
|
||||
|
||||
@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.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.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.manager.TriggerVolumeManager;
|
||||
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.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.Enums;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.model.MinigameMapDiscoveryResult;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
@@ -176,6 +179,54 @@ public final class MinigameMapDiscoveryService {
|
||||
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
|
||||
private static TriggerVolumeManager manager(@Nullable Store<EntityStore> store) {
|
||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||
|
||||
@@ -6,6 +6,7 @@ import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
@@ -15,15 +16,39 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
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 {
|
||||
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();
|
||||
|
||||
public void join(@Nonnull String minigameId, @Nonnull String playerId) {
|
||||
session(minigameId).players.add(playerId);
|
||||
@Nonnull
|
||||
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) {
|
||||
@@ -31,36 +56,125 @@ public final class MinigameQueueService {
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
session.players.remove(playerId);
|
||||
session.votes.remove(playerId);
|
||||
if (session.players.isEmpty() && session.votes.isEmpty()) {
|
||||
sessions.remove(minigameId);
|
||||
synchronized (session) {
|
||||
session.players.remove(playerId);
|
||||
session.votes.remove(playerId);
|
||||
if (session.players.isEmpty()) {
|
||||
session.voteDeadlineMs = 0L;
|
||||
sessions.remove(minigameId, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void vote(@Nonnull String minigameId, @Nonnull String playerId, @Nonnull String mapId) {
|
||||
Session session = session(minigameId);
|
||||
session.players.add(playerId);
|
||||
session.votes.put(playerId, mapId);
|
||||
/** Removes the player from every queue (used on disconnect). */
|
||||
public void leaveAll(@Nonnull String playerId) {
|
||||
for (String minigameId : sessions.keySet().toArray(String[]::new)) {
|
||||
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
|
||||
public MinigameRuntime startQueued(
|
||||
@Nonnull MinigameDefinition definition,
|
||||
@Nonnull List<MinigameMapCandidate> candidates,
|
||||
@Nonnull MinigameRuntimeService runtimeService
|
||||
@Nonnull MinigameMapCandidate selected,
|
||||
@Nonnull MinigameRuntimeService runtimeService,
|
||||
@Nullable String worldName,
|
||||
boolean adminStarted
|
||||
) {
|
||||
MinigameMapCandidate selected = select(definition, candidates);
|
||||
MinigameRuntime runtime = runtimeService.start(definition, selected);
|
||||
Session session = session(definition.getId());
|
||||
for (String playerId : session.players) {
|
||||
MinigameRuntime runtime = runtimeService.start(definition, selected, worldName, adminStarted);
|
||||
Session session = sessions.get(definition.getId());
|
||||
List<String> joining = new ArrayList<>();
|
||||
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 -> {
|
||||
var player = new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId());
|
||||
player.lives(Math.max(0, definition.getDefaultPlayerLives()));
|
||||
return player;
|
||||
});
|
||||
}
|
||||
sessions.remove(definition.getId());
|
||||
runtimeService.setupTeams(runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -76,8 +190,8 @@ public final class MinigameQueueService {
|
||||
return available.get(random.nextInt(available.size()));
|
||||
}
|
||||
|
||||
Session session = session(minigameId);
|
||||
Optional<String> winningMapId = session.votes.values().stream()
|
||||
Map<String, String> votes = votes(minigameId);
|
||||
Optional<String> winningMapId = votes.values().stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(value -> value, java.util.stream.Collectors.counting()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
@@ -110,20 +224,29 @@ public final class MinigameQueueService {
|
||||
|
||||
@Nonnull
|
||||
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
|
||||
public Map<String, String> votes(@Nonnull String minigameId) {
|
||||
return Map.copyOf(session(minigameId).votes);
|
||||
}
|
||||
|
||||
private Session session(@Nonnull String minigameId) {
|
||||
return sessions.computeIfAbsent(minigameId, ignored -> new Session());
|
||||
Session session = sessions.get(minigameId);
|
||||
if (session == null) {
|
||||
return Map.of();
|
||||
}
|
||||
synchronized (session) {
|
||||
return Map.copyOf(session.votes);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
private final Set<String> players = new LinkedHashSet<>();
|
||||
private final Map<String, String> votes = new HashMap<>();
|
||||
private long voteDeadlineMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface MinigameService {
|
||||
MinigameDefinition create(String id, String name);
|
||||
MinigameDefinition create(String packName, String id, String name);
|
||||
void update(MinigameDefinition definition);
|
||||
void delete(String id);
|
||||
void setEnabled(String id, boolean enabled);
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.service;
|
||||
|
||||
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||
import net.kewwbec.minigames.ui.MinigameMenuService;
|
||||
|
||||
public record MinigameServices(
|
||||
MinigameService minigames,
|
||||
@@ -9,6 +10,7 @@ public record MinigameServices(
|
||||
HytaleServerAdapters adapters,
|
||||
MinigameMapDiscoveryService maps,
|
||||
MinigameQueueService queue,
|
||||
MinigameVolumeSignalService signals
|
||||
MinigameVolumeSignalService signals,
|
||||
MinigameMenuService menus
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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 net.kewwbec.minigames.MinigameCorePlugin;
|
||||
import net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
@@ -23,7 +24,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,8 @@ import java.util.logging.Level;
|
||||
* SignalPhaseTick = <phase> fire repeatedly while the given phase is active
|
||||
* SignalTickDelay = <seconds> repeat interval (0 = 50ms minimum)
|
||||
* 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 {
|
||||
|
||||
@@ -65,146 +68,107 @@ public final class MinigameVolumeSignalService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
cancelLoops(roundLoops, runtime.runtimeId());
|
||||
boolean debug = isDebug(runtime.minigameId());
|
||||
|
||||
String roundKey = String.valueOf(newRound);
|
||||
LOGGER.atInfo().log("Minigame signal: round start runtime=%s minigame=%s map=%s variant=%s round=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), roundKey);
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start runtime=%s minigame=%s round=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), roundKey);
|
||||
}
|
||||
|
||||
int worldsChecked = 0;
|
||||
int volumesChecked = 0;
|
||||
int startMatches = 0;
|
||||
int tickMatches = 0;
|
||||
|
||||
for (World world : worlds()) {
|
||||
worldsChecked++;
|
||||
for (World world : worlds(runtime)) {
|
||||
TriggerVolumeManager manager = manager(world);
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
||||
continue;
|
||||
}
|
||||
if (manager == null) continue;
|
||||
|
||||
ActorPair actor = findActor(runtime, world);
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
world.getName(), runtime.players().keySet());
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no runtime player actor", world.getName());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
volumesChecked++;
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
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;
|
||||
}
|
||||
|
||||
// One-shot: fire immediately
|
||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_START))) {
|
||||
startMatches++;
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart match volume=%s world=%s round=%s tags=%s",
|
||||
volume.getId(), world.getName(), roundKey, tags);
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart match volume=%s world=%s round=%s",
|
||||
volume.getId(), world.getName(), roundKey);
|
||||
}
|
||||
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
|
||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_TICK))) {
|
||||
tickMatches++;
|
||||
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(),
|
||||
TAG_SIGNAL_ROUND_TICK, roundKey, volume.getId(), world.getName(), delay);
|
||||
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.
|
||||
* Must be called from the world thread.
|
||||
* Called when the game phase changes. Cancels old phase loops, fires one-shot PhaseStart
|
||||
* 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());
|
||||
boolean debug = isDebug(runtime.minigameId());
|
||||
|
||||
String phaseKey = newPhase.name();
|
||||
LOGGER.atInfo().log("Minigame signal: phase change runtime=%s minigame=%s map=%s variant=%s phase=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), phaseKey);
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change runtime=%s minigame=%s phase=%s allowLoops=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), phaseKey, allowLoops);
|
||||
}
|
||||
|
||||
int worldsChecked = 0;
|
||||
int volumesChecked = 0;
|
||||
int startMatches = 0;
|
||||
int tickMatches = 0;
|
||||
|
||||
for (World world : worlds()) {
|
||||
worldsChecked++;
|
||||
for (World world : worlds(runtime)) {
|
||||
TriggerVolumeManager manager = manager(world);
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
||||
continue;
|
||||
}
|
||||
if (manager == null) continue;
|
||||
|
||||
ActorPair actor = findActor(runtime, world);
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
world.getName(), runtime.players().keySet());
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no runtime player actor", world.getName());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
volumesChecked++;
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
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;
|
||||
}
|
||||
|
||||
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_START))) {
|
||||
startMatches++;
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart match volume=%s world=%s phase=%s tags=%s",
|
||||
volume.getId(), world.getName(), phaseKey, tags);
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart match volume=%s world=%s phase=%s",
|
||||
volume.getId(), world.getName(), phaseKey);
|
||||
}
|
||||
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))) {
|
||||
tickMatches++;
|
||||
if (allowLoops && phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_TICK))) {
|
||||
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(),
|
||||
TAG_SIGNAL_PHASE_TICK, phaseKey, volume.getId(), world.getName(), delay);
|
||||
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) {
|
||||
cancelTimer(runtime.runtimeId(), timerName);
|
||||
runtime.timers().put(timerName, System.currentTimeMillis() + durationMs);
|
||||
LOGGER.atInfo().log("Minigame signal: start timer runtime=%s minigame=%s timer=%s durationMs=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), timerName, durationMs);
|
||||
if (isDebug(runtime.minigameId())) {
|
||||
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(
|
||||
() -> fireTimerExpiry(runtime.runtimeId(), runtime.minigameId(), timerName),
|
||||
@@ -234,7 +200,6 @@ public final class MinigameVolumeSignalService {
|
||||
Future<?> f = futures.remove(timerName);
|
||||
if (f != null) {
|
||||
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);
|
||||
var futures = timerFutures.remove(runtimeId);
|
||||
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. */
|
||||
@@ -255,85 +219,80 @@ public final class MinigameVolumeSignalService {
|
||||
|
||||
private Future<?> scheduleLoop(String runtimeId, String minigameId, String tagKey, String tagValue,
|
||||
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",
|
||||
runtimeId, minigameId, worldName, volumeId, tagKey, tagValue, delayMs);
|
||||
return scheduler.scheduleAtFixedRate(
|
||||
() -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName),
|
||||
// The task keeps a handle to its own future so it can cancel itself once the
|
||||
// runtime is gone or the tag no longer matches — otherwise a fixed-rate loop
|
||||
// whose owner disappeared would spin forever.
|
||||
AtomicReference<Future<?>> self = new AtomicReference<>();
|
||||
Future<?> future = scheduler.scheduleAtFixedRate(
|
||||
() -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName, self),
|
||||
delayMs, delayMs, TimeUnit.MILLISECONDS
|
||||
);
|
||||
self.set(future);
|
||||
return future;
|
||||
}
|
||||
|
||||
private void tickSignal(String runtimeId, String minigameId, String expectedTagKey, String expectedTagValue,
|
||||
String volumeId, String worldName) {
|
||||
String volumeId, String worldName, AtomicReference<Future<?>> self) {
|
||||
try {
|
||||
Universe universe = Universe.get();
|
||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||
if (universe == null || plugin == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s reason=universe or TriggerVolumesPlugin unavailable",
|
||||
runtimeId, volumeId);
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
|
||||
World world = universe.getWorld(worldName);
|
||||
if (world == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=world not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
|
||||
world.execute(() -> {
|
||||
var services = MinigameCorePlugin.getServices();
|
||||
if (services == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=services unavailable",
|
||||
runtimeId, volumeId, worldName);
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
|
||||
var rtOpt = services.runtime().runtime(runtimeId);
|
||||
var rtOpt = services.runtime().runtimeById(runtimeId);
|
||||
if (rtOpt.isEmpty()) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=runtime not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
MinigameRuntime rt = rtOpt.get();
|
||||
|
||||
if (TAG_SIGNAL_ROUND_TICK.equals(expectedTagKey)) {
|
||||
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",
|
||||
runtimeId, volumeId, expectedTagValue, rt.roundNumber());
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
} else if (TAG_SIGNAL_PHASE_TICK.equals(expectedTagKey)) {
|
||||
if (!expectedTagValue.equals(rt.phase().name())) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick stopped runtime=%s volume=%s reason=phase changed expected=%s actual=%s",
|
||||
runtimeId, volumeId, expectedTagValue, rt.phase().name());
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=no TriggerVolumeManager",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
VolumeEntry volume = manager.getVolume(volumeId);
|
||||
if (volume == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=volume not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
cancelSelf(self);
|
||||
return;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(rt, world);
|
||||
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;
|
||||
}
|
||||
|
||||
LOGGER.atInfo().log("Minigame signal: tick firing runtime=%s minigame=%s world=%s volume=%s tag=%s value=%s tags=%s",
|
||||
runtimeId, minigameId, worldName, volumeId, expectedTagKey, expectedTagValue, volume.getRawTags());
|
||||
if (isDebug(minigameId)) {
|
||||
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);
|
||||
});
|
||||
} 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) {
|
||||
try {
|
||||
Universe universe = Universe.get();
|
||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
var dispatched = new AtomicBoolean(false);
|
||||
LOGGER.atInfo().log("Minigame signal: timer expired runtime=%s minigame=%s timer=%s",
|
||||
runtimeId, minigameId, timerName);
|
||||
world.execute(() -> {
|
||||
var svc = MinigameCorePlugin.getServices();
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
svc.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName));
|
||||
|
||||
var rtOpt = services.runtime().runtime(runtimeId);
|
||||
if (rtOpt.isEmpty()) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=runtime not found",
|
||||
runtimeId, timerName, world.getName());
|
||||
return;
|
||||
}
|
||||
MinigameRuntime rt = rtOpt.get();
|
||||
// Internal timers (name starts with '$') are handled by the runtime service,
|
||||
// not by volume signals.
|
||||
if (timerName.startsWith("$")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatched.compareAndSet(false, true)) {
|
||||
LOGGER.atInfo().log("Minigame signal: dispatch on_timer_expired runtime=%s timer=%s", runtimeId, timerName);
|
||||
services.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName));
|
||||
}
|
||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Internal timers (name starts with '$') are handled by the runtime service,
|
||||
// not by volume signals.
|
||||
if (timerName.startsWith("$")) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry not toggling volumes runtime=%s timer=%s reason=internal timer",
|
||||
ActorPair actor = findActor(rt, world);
|
||||
if (actor == null) {
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s reason=no runtime player actor",
|
||||
runtimeId, timerName);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no TriggerVolumeManager",
|
||||
runtimeId, timerName, world.getName());
|
||||
return;
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
if (!minigameId.equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(rt, world);
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
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);
|
||||
if (timerName.equals(tags.get(TAG_SIGNAL_TIMER_EXPIRE))) {
|
||||
if (debug) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire match volume=%s world=%s timer=%s",
|
||||
volume.getId(), world.getName(), timerName);
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
VolumeEntry volume = manager.getVolume(volumeId);
|
||||
if (volume == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: toggle skipped volume=%s reason=volume not found", volumeId);
|
||||
return;
|
||||
}
|
||||
String current = volume.getRawTags().get(TAG_SIGNAL_KEY);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -468,16 +425,44 @@ public final class MinigameVolumeSignalService {
|
||||
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();
|
||||
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) {
|
||||
List<Future<?>> futures = loopMap.remove(runtimeId);
|
||||
if (futures != null) {
|
||||
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);
|
||||
return Math.max(MIN_DELAY_MS, (long) (seconds * 1000));
|
||||
} catch (NumberFormatException ignored) {
|
||||
LOGGER.atInfo().log("Minigame signal: invalid SignalTickDelay value=%s usingDefaultMs=%s", value, MIN_DELAY_MS);
|
||||
return MIN_DELAY_MS;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasRoundSignal(Map<String, String> tags) {
|
||||
return tags.containsKey(TAG_SIGNAL_ROUND_START) || tags.containsKey(TAG_SIGNAL_ROUND_TICK);
|
||||
}
|
||||
|
||||
private static boolean hasPhaseSignal(Map<String, String> tags) {
|
||||
return tags.containsKey(TAG_SIGNAL_PHASE_START) || tags.containsKey(TAG_SIGNAL_PHASE_TICK);
|
||||
private static boolean isDebug(String minigameId) {
|
||||
MinigameDefinition def = MinigameDefinition.getAssetMap().getAsset(minigameId);
|
||||
return def != null && def.isDebug();
|
||||
}
|
||||
|
||||
private record ActorPair(Ref<EntityStore> ref, UUID uuid) {}
|
||||
|
||||
@@ -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 javax.annotation.Nonnull;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
|
||||
public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
private static final Set<Dependency<EntityStore>> DEPENDENCIES =
|
||||
@@ -63,10 +66,27 @@ public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem
|
||||
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);
|
||||
if (destination.isBlank()) {
|
||||
LOGGER.atInfo().log("Minigame respawn skipped player=%s runtime=%s reason=no checkpoint or team spawn",
|
||||
playerId, runtime.runtimeId());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package net.kewwbec.minigames.system;
|
||||
|
||||
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.VolumeEntry;
|
||||
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.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.volume.effect.AwardKillScoreEffect;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@@ -64,7 +63,7 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
var services = MinigameCorePlugin.getServices();
|
||||
MinigameServices services = MinigameCorePlugin.getServices();
|
||||
if (services == null) {
|
||||
return;
|
||||
}
|
||||
@@ -91,27 +90,16 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
||||
if (!matchesRuntime(volume, runtime) || !volume.getShape().contains(volume.getPosition(), transform.getPosition())) {
|
||||
continue;
|
||||
}
|
||||
for (var effect : effects(volume)) {
|
||||
for (var effect : MinigameMapDiscoveryService.resolveEffects(volume)) {
|
||||
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(
|
||||
@Nonnull MinigameServices services,
|
||||
@Nonnull MinigameRuntime runtime,
|
||||
@Nonnull String attackerId,
|
||||
@Nonnull AwardKillScoreEffect rule,
|
||||
@@ -149,7 +137,7 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
||||
payload.put("player_id", attackerId);
|
||||
payload.put("points", points);
|
||||
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) {
|
||||
@@ -172,7 +160,8 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
||||
String minigameId = clean(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID));
|
||||
String mapId = clean(tags.get(MinigameMapDiscoveryService.TAG_MAP_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;
|
||||
}
|
||||
if (!mapId.isBlank() && !mapId.equals(runtime.mapId())) {
|
||||
|
||||
@@ -4,24 +4,49 @@ import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.system.tick.TickingSystem;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
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> {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final MinigameRuntimeService runtimeService;
|
||||
private final MinigameServices services;
|
||||
|
||||
public MinigamePregameSystem(MinigameRuntimeService runtimeService) {
|
||||
this.runtimeService = runtimeService;
|
||||
public MinigamePregameSystem(MinigameServices services) {
|
||||
this.services = services;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
||||
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) {
|
||||
LOGGER.atWarning().log("Pregame tick failed: %s: %s",
|
||||
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,149 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
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;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
||||
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;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
||||
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.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.runtime.Rankings;
|
||||
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
||||
|
||||
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 javax.annotation.Nullable;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -56,16 +56,11 @@ public final class MinigameHudService {
|
||||
LOGGER.atWarning().log("[MinigameHUD] requestHudShow: playerId is not a UUID: '%s'", playerId);
|
||||
return;
|
||||
}
|
||||
LOGGER.atInfo().log("[MinigameHUD] requestHudShow: queued uuid=%s minigame=%s", uuid, runtime.minigameId());
|
||||
pendingOpens.put(uuid, new PendingHudOpen(uuid, playerId, runtime, System.currentTimeMillis()));
|
||||
}
|
||||
|
||||
public void openQueued(Store<EntityStore> store) {
|
||||
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()) {
|
||||
if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) {
|
||||
LOGGER.atWarning().log("[MinigameHUD] openQueued: TTL expired for uuid=%s, dropping",
|
||||
@@ -74,34 +69,31 @@ public final class MinigameHudService {
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.playerUuid());
|
||||
if (ref == null) {
|
||||
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());
|
||||
if (ref == null || !ref.isValid()) {
|
||||
continue;
|
||||
}
|
||||
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (player == null) {
|
||||
LOGGER.atWarning().log("[MinigameHUD] openQueued: PlayerRef component null for uuid=%s",
|
||||
pending.playerUuid());
|
||||
continue;
|
||||
}
|
||||
if (pendingOpens.remove(pending.playerUuid(), pending)) {
|
||||
LOGGER.atInfo().log("[MinigameHUD] openQueued: opening HUD for uuid=%s", pending.playerUuid());
|
||||
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()) {
|
||||
String playerId = entry.getKey();
|
||||
HudEntry hudEntry = entry.getValue();
|
||||
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)) {
|
||||
activeHuds.remove(playerId, hudEntry);
|
||||
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) {
|
||||
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
|
||||
if (runtimeId.equals(entry.getValue().runtimeId())) {
|
||||
@@ -262,30 +268,8 @@ public final class MinigameHudService {
|
||||
}
|
||||
|
||||
if (content.sbNameLabels.length > 0) {
|
||||
WinCondition wc = runtime.definition().getWinCondition();
|
||||
boolean lowestTime = wc == WinCondition.LOWEST_TIME;
|
||||
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();
|
||||
}
|
||||
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
|
||||
List<Map.Entry<String, PlayerMinigameState>> ranked = Rankings.rank(runtime, true);
|
||||
|
||||
for (int i = 0; i < content.sbNameLabels.length; i++) {
|
||||
if (i < ranked.size()) {
|
||||
@@ -388,31 +372,7 @@ public final class MinigameHudService {
|
||||
}
|
||||
|
||||
private static String positionText(String playerId, MinigameRuntime runtime) {
|
||||
WinCondition wc = runtime.definition().getWinCondition();
|
||||
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();
|
||||
}
|
||||
List<String> ranked = Rankings.rankedPlayerIds(runtime, true);
|
||||
|
||||
int activeCount = ranked.size();
|
||||
if (activeCount == 0)
|
||||
|
||||
@@ -10,7 +10,6 @@ public final class MinigameHudSystem extends TickingSystem<EntityStore> {
|
||||
private static final int REFRESH_EVERY_TICKS = 20;
|
||||
|
||||
private final MinigameHudService hudService;
|
||||
private int tickCount = 0;
|
||||
|
||||
public MinigameHudSystem(MinigameHudService 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) {
|
||||
try {
|
||||
hudService.openQueued(store);
|
||||
if (tickCount++ % REFRESH_EVERY_TICKS == 0) {
|
||||
hudService.refreshAll();
|
||||
// Use the world tick counter — a shared field would drift with world count.
|
||||
if (tick % REFRESH_EVERY_TICKS == 0) {
|
||||
hudService.refreshAll(store);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.atWarning().log("HUD system tick failed: %s: %s",
|
||||
|
||||
@@ -13,7 +13,9 @@ import au.ellie.hyui.builders.UIElementBuilder;
|
||||
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.service.MinigameStarter;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||
@@ -124,7 +126,7 @@ public final class MinigameQueueUIService {
|
||||
var def = defOpt.get();
|
||||
int queued = services.queue().players(gameId).size();
|
||||
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();
|
||||
gameList.addChild(inset(
|
||||
ButtonBuilder.secondaryTextButton()
|
||||
@@ -361,6 +363,8 @@ public final class MinigameQueueUIService {
|
||||
.withTooltipText("Vote for " + prettify(map.mapId()))
|
||||
.onClick((ignored, ui) -> {
|
||||
services.queue().vote(minigameId, playerId, map.mapId());
|
||||
services.minigames().definition(minigameId)
|
||||
.ifPresent(d -> maybeStartAfterVote(d, store, System.currentTimeMillis()));
|
||||
openVote(player, minigameId, store, manager);
|
||||
}),
|
||||
572, 510, 46));
|
||||
@@ -378,6 +382,8 @@ public final class MinigameQueueUIService {
|
||||
.withTooltipText("Vote for " + prettify(map.mapId()) + " — " + prettify(variant))
|
||||
.onClick((ignored, ui) -> {
|
||||
services.queue().vote(minigameId, playerId, voteKey);
|
||||
services.minigames().definition(minigameId)
|
||||
.ifPresent(d -> maybeStartAfterVote(d, store, System.currentTimeMillis()));
|
||||
openVote(player, minigameId, store, manager);
|
||||
}),
|
||||
572, 510, 38));
|
||||
@@ -434,26 +440,62 @@ public final class MinigameQueueUIService {
|
||||
|
||||
private void joinQueue(PlayerRef player, String minigameId, String mapId, Store<EntityStore> store) {
|
||||
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()) {
|
||||
services.queue().vote(minigameId, playerId, mapId);
|
||||
}
|
||||
services.minigames().definition(minigameId).ifPresent(def -> {
|
||||
Set<String> queued = services.queue().players(minigameId);
|
||||
if (services.runtime().runtimes(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) {
|
||||
if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) {
|
||||
// Show vote UI — to all players when threshold is first hit, otherwise just the new joiner
|
||||
Set<UUID> targets = queued.size() == def.getMinPlayers()
|
||||
? queued.stream().map(id -> { try { return UUID.fromString(id); } catch (IllegalArgumentException e) { return null; } }).filter(u -> u != null).collect(Collectors.toSet())
|
||||
: Set.of(player.getUuid());
|
||||
long now = System.currentTimeMillis();
|
||||
targets.forEach(uuid -> pendingOpens.put(uuid, new PendingOpen(uuid, UIQueueMode.VOTE, minigameId, "", now)));
|
||||
} else {
|
||||
var discovered = services.maps().discover(store, minigameId);
|
||||
services.queue().startQueued(def, discovered.candidates(), services.runtime());
|
||||
|
||||
Set<String> queued = services.queue().players(minigameId);
|
||||
if (services.runtime().runtimesForMinigame(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) {
|
||||
boolean newlyOpened = services.queue().openVoteWindow(minigameId, now);
|
||||
// Show the vote UI to everyone who has not voted yet — also when the
|
||||
// threshold is re-reached after someone left and rejoined.
|
||||
Map<String, String> votes = services.queue().votes(minigameId);
|
||||
var targets = newlyOpened
|
||||
? queued.stream().filter(id -> !votes.containsKey(id)).toList()
|
||||
: List.of(playerId);
|
||||
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) {
|
||||
|
||||
@@ -41,21 +41,27 @@ public abstract class MinigameRuntimeCondition extends TriggerCondition {
|
||||
if (services == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
String declaredId = !candidate.minigameId().isBlank()
|
||||
? candidate.minigameId()
|
||||
: (minigameId != null ? minigameId : "");
|
||||
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId;
|
||||
if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) {
|
||||
if (declaredId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtime(resolvedMinigameId));
|
||||
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class WinConditionMetCondition extends MinigameRuntimeCondition {
|
||||
.count();
|
||||
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);
|
||||
case OBJECTIVE_COMPLETE -> !rt.get().objectives().isEmpty()
|
||||
&& 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);
|
||||
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) {
|
||||
@@ -88,14 +88,17 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
||||
}
|
||||
|
||||
List<String> players = runtime.players().keySet().stream().sorted().toList();
|
||||
List<String> assignments = new ArrayList<>(players.size());
|
||||
for (int i = 0; i < players.size(); i++) {
|
||||
String playerId = players.get(i);
|
||||
TeamState team = teams.get(i % teams.size());
|
||||
runtime.players().get(playerId).teamId(team.teamId());
|
||||
team.players().add(playerId);
|
||||
assignments.add(displayName(playerId) + " -> " + team.teamId());
|
||||
}
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED balanced players=" + players.size() + " teams=" + teams.size());
|
||||
debugMessage(context, TYPE_ID, "FIRED balanced " + players.size() + " players across "
|
||||
+ teams.size() + " teams: " + String.join(", ", assignments));
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
if (removeNpc && npcRef != null && npcRef.isValid()) {
|
||||
|
||||
@@ -29,7 +29,7 @@ public final class EndMinigameEffect extends MinigameRuntimeEffect {
|
||||
var rt = runtime(context);
|
||||
if (rt.isPresent()) {
|
||||
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 + "'");
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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.server.core.Message;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
@@ -20,11 +20,27 @@ public final class JoinMinigameQueueEffect extends MinigameRuntimeEffect {
|
||||
var services = services();
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
String id = resolvedMinigameId(context);
|
||||
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 {
|
||||
if (services == null || playerId == null || id.isBlank()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')");
|
||||
return;
|
||||
}
|
||||
var defOpt = services.minigames().definition(id);
|
||||
if (defOpt.isEmpty()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')");
|
||||
return;
|
||||
}
|
||||
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), playerId);
|
||||
switch (result) {
|
||||
case JOINED -> debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' joined queue for minigame='" + id + "'");
|
||||
case ALREADY_QUEUED -> debugMessage(context, TYPE_ID, "SKIPPED (player='" + playerId + "' already queued)");
|
||||
case QUEUE_FULL -> {
|
||||
services.adapters().sendMessage(playerId, Message.translation("minigames.queue.full").param("id", id));
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (queue full for minigame='" + id + "')");
|
||||
}
|
||||
case DISABLED -> {
|
||||
services.adapters().sendMessage(playerId, Message.translation("minigames.queue.disabled").param("id", id));
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (minigame='" + id + "' is disabled)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.volume.effect;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
|
||||
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.
|
||||
// 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();
|
||||
if (phase == net.kewwbec.minigames.model.Enums.MinigamePhase.WAITING_FOR_PLAYERS
|
||||
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.COUNTDOWN
|
||||
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.STARTING) {
|
||||
runtime.players().remove(playerId);
|
||||
if (phase == MinigamePhase.WAITING_FOR_PLAYERS
|
||||
|| phase == MinigamePhase.COUNTDOWN
|
||||
|| phase == MinigamePhase.STARTING) {
|
||||
services.runtime().removePlayer(runtime, playerId, "left_queue");
|
||||
debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'");
|
||||
}
|
||||
});
|
||||
@@ -41,4 +42,3 @@ public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect {
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,21 +42,27 @@ public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
||||
if (services == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
String declaredId = !candidate.minigameId().isBlank()
|
||||
? candidate.minigameId()
|
||||
: (minigameId != null ? minigameId : "");
|
||||
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId;
|
||||
if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) {
|
||||
if (declaredId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtime(resolvedMinigameId));
|
||||
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -104,6 +110,19 @@ public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
||||
}
|
||||
}
|
||||
|
||||
/** Player username for debug output, falling back to the raw id when offline/unresolved. */
|
||||
@Nonnull
|
||||
protected static String displayName(@Nonnull String playerId) {
|
||||
MinigameServices services = services();
|
||||
if (services != null) {
|
||||
String name = services.adapters().getDisplayName(playerId);
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return playerId;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) {
|
||||
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) {
|
||||
|
||||
@@ -16,14 +16,14 @@ public final class ModifyCounterEffect extends MinigameRuntimeEffect {
|
||||
BuilderCodec.builder(ModifyCounterEffect.class, ModifyCounterEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("CounterId", Codec.STRING), (effect, value) -> effect.counterId = value, effect -> effect.counterId)
|
||||
.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()
|
||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String counterId;
|
||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
||||
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD;
|
||||
private int amount = 1;
|
||||
|
||||
@Override
|
||||
@@ -37,7 +37,7 @@ public final class ModifyCounterEffect extends MinigameRuntimeEffect {
|
||||
if (rt.isPresent()) {
|
||||
String key = "counter:" + counterId;
|
||||
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);
|
||||
debugMessage(context, TYPE_ID, "FIRED counter='" + counterId + "' " + current + "->" + next);
|
||||
} 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)
|
||||
.append(new KeyedCodec<>("ObjectiveId", Codec.STRING), (effect, value) -> effect.objectiveId = value, effect -> effect.objectiveId)
|
||||
.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()
|
||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String objectiveId;
|
||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
||||
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD;
|
||||
private int amount = 1;
|
||||
|
||||
@Override
|
||||
@@ -45,7 +45,7 @@ public final class ModifyObjectiveProgressEffect extends MinigameRuntimeEffect {
|
||||
k -> new ObjectiveState(k, k, "generic"));
|
||||
|
||||
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);
|
||||
|
||||
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()) : "";
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,51 +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 ModifyTeamScoreEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "ModifyTeamScore";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<ModifyTeamScoreEffect> CODEC =
|
||||
BuilderCodec.builder(ModifyTeamScoreEffect.class, ModifyTeamScoreEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("TeamId", Codec.STRING), (effect, value) -> effect.teamId = value, effect -> effect.teamId)
|
||||
.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 teamId;
|
||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
||||
private int amount = 1;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
if (teamId == null || teamId.isBlank()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (teamId blank)");
|
||||
return;
|
||||
}
|
||||
|
||||
var rt = runtime(context);
|
||||
if (rt.isPresent()) {
|
||||
var team = rt.get().teams().get(teamId);
|
||||
if (team != null) {
|
||||
int prev = team.score();
|
||||
int next = ModifyPlayerScoreEffect.apply(prev, operation, amount);
|
||||
team.score(next);
|
||||
debugMessage(context, TYPE_ID, "FIRED team='" + teamId + "' score " + prev + "->" + next);
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (team='" + teamId + "' not in runtime)");
|
||||
}
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,10 @@ public final class MountPlayerEffect extends MinigameRuntimeEffect {
|
||||
RoleChangeSystem.requestRoleChange(npcRef, npc.getRole(), emptyRoleIndex, false, null, null, context.getStore());
|
||||
applyMovementConfig(context, playerRef, playerComponent, playerRefComponent);
|
||||
if (preventManualDismount) {
|
||||
context.getStore().ensureComponent(playerRef, LockMountComponent.getComponentType());
|
||||
var lockType = LockMountComponent.getComponentType();
|
||||
if (lockType != null) {
|
||||
context.getStore().ensureComponent(playerRef, lockType);
|
||||
}
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED player mounted npcRole='" + npc.getRoleName() + "' locked=" + preventManualDismount);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public final class ResetMinigameEffect extends MinigameRuntimeEffect {
|
||||
}
|
||||
var rt = runtime(context);
|
||||
if (rt.isPresent()) {
|
||||
services.runtime().reset(rt.get().runtimeId());
|
||||
services.runtime().resetRuntime(rt.get().runtimeId());
|
||||
debugMessage(context, TYPE_ID, "FIRED minigame='" + resolvedMinigameId(context) + "'");
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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 net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Respawns every player in the runtime to their spawn point in a single pass — the
|
||||
* "round reset" primitive. Combine with SetRound/ResetScores/SwapTeams on a trigger to
|
||||
* rebuild the arena between rounds.
|
||||
*/
|
||||
public final class RespawnAllPlayersEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "RespawnAllPlayers";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<RespawnAllPlayersEffect> CODEC =
|
||||
BuilderCodec.builder(RespawnAllPlayersEffect.class, RespawnAllPlayersEffect::new, MINIGAME_BASE_CODEC)
|
||||
// Overrides every player's stored spawn point for this respawn only (e.g. a shared race start line).
|
||||
.append(new KeyedCodec<>("DestinationId", Codec.STRING, false), (effect, value) -> effect.destinationId = value, effect -> effect.destinationId)
|
||||
.add()
|
||||
// Clear stored checkpoints first so players go to their team spawn / start instead of where they last were.
|
||||
.append(new KeyedCodec<>("ClearCheckpoints", Codec.BOOLEAN, false), (effect, value) -> effect.clearCheckpoints = value, effect -> effect.clearCheckpoints)
|
||||
.add()
|
||||
// When true, only ACTIVE players are moved (spectators / eliminated are left alone).
|
||||
.append(new KeyedCodec<>("ActiveOnly", Codec.BOOLEAN, false), (effect, value) -> effect.activeOnly = value, effect -> effect.activeOnly)
|
||||
.add()
|
||||
// When true, eliminated players are returned to ACTIVE before respawning (fresh round).
|
||||
.append(new KeyedCodec<>("ReactivateEliminated", Codec.BOOLEAN, false), (effect, value) -> effect.reactivateEliminated = value, effect -> effect.reactivateEliminated)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String destinationId;
|
||||
private boolean clearCheckpoints = false;
|
||||
private boolean activeOnly = true;
|
||||
private boolean reactivateEliminated = false;
|
||||
|
||||
@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;
|
||||
}
|
||||
MinigameServices services = services();
|
||||
if (services == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no services)");
|
||||
return;
|
||||
}
|
||||
|
||||
int respawned = 0;
|
||||
for (Map.Entry<String, PlayerMinigameState> entry : rt.get().players().entrySet()) {
|
||||
String id = entry.getKey();
|
||||
PlayerMinigameState player = entry.getValue();
|
||||
|
||||
if (reactivateEliminated && player.status() == PlayerStatus.ELIMINATED) {
|
||||
player.status(PlayerStatus.ACTIVE);
|
||||
rt.get().eliminatedPlayers().remove(id);
|
||||
rt.get().spectators().remove(id);
|
||||
services.runtime().onPlayerActivated(rt.get(), id);
|
||||
}
|
||||
|
||||
if (activeOnly && player.status() != PlayerStatus.ACTIVE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (clearCheckpoints) {
|
||||
player.checkpointId(null);
|
||||
}
|
||||
|
||||
String dest = services.maps().respawnDestination(context.getStore(), rt.get(), player, destinationId);
|
||||
if (dest.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
services.adapters().teleport(id, dest);
|
||||
respawned++;
|
||||
}
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED respawned=" + respawned);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
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.MinigamePhase;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public final class SetMinigamePhaseEffect extends MinigameRuntimeEffect {
|
||||
@@ -23,20 +20,32 @@ public final class SetMinigamePhaseEffect extends MinigameRuntimeEffect {
|
||||
|
||||
private MinigamePhase phase = MinigamePhase.ACTIVE;
|
||||
|
||||
@Nonnull
|
||||
public MinigamePhase targetPhase() {
|
||||
return phase != null ? phase : MinigamePhase.ACTIVE;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public String configuredMinigameId() {
|
||||
return minigameId != null ? minigameId : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
MinigamePhase target = phase != null ? phase : MinigamePhase.ACTIVE;
|
||||
MinigamePhase target = targetPhase();
|
||||
var rt = runtime(context);
|
||||
if (rt.isPresent()) {
|
||||
rt.get().phase(target);
|
||||
var services = services();
|
||||
if (services != null) {
|
||||
services.signals().onPhaseChange(rt.get(), target);
|
||||
services.runtime().dispatch(rt.get(), "on_phase_change", Map.of("phase", target.name()));
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED phase=" + target);
|
||||
} else {
|
||||
var services = services();
|
||||
if (rt.isEmpty() || services == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||
return;
|
||||
}
|
||||
// Terminal phases go through the full lifecycle so cleanup, rewards, and
|
||||
// inventory restore actually run — flipping the enum alone would skip them.
|
||||
switch (target) {
|
||||
case ENDING -> services.runtime().endRuntime(rt.get().runtimeId(), "minigames.runtime.reason.trigger_volume");
|
||||
case RESETTING -> services.runtime().resetRuntime(rt.get().runtimeId());
|
||||
default -> services.runtime().setPhase(rt.get(), target);
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED phase=" + target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 net.kewwbec.minigames.model.ItemStackConfig;
|
||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@@ -43,22 +44,50 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadoutId != null && !loadoutId.isBlank()) {
|
||||
boolean found = false;
|
||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||
found = true;
|
||||
break;
|
||||
var services = services();
|
||||
if (services == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Items are only ever granted through this effect — nothing is given automatically
|
||||
// on activation. A blank LoadoutId grants the definition's StartItems instead.
|
||||
if (loadoutId == null || loadoutId.isBlank()) {
|
||||
for (ItemStackConfig item : rt.get().definition().getStartItems()) {
|
||||
if (item != null) {
|
||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
||||
return;
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' granted StartItems");
|
||||
return;
|
||||
}
|
||||
|
||||
LoadoutConfig selected = null;
|
||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||
selected = loadout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
||||
return;
|
||||
}
|
||||
|
||||
String previous = player.loadoutId();
|
||||
player.loadoutId(loadoutId);
|
||||
|
||||
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
||||
services.menus().requestLoadoutSelection(id, rt.get());
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' opened loadout selection");
|
||||
return;
|
||||
}
|
||||
|
||||
for (ItemStackConfig item : selected.getItems()) {
|
||||
if (item != null) {
|
||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' loadout " + previous + "->" + loadoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public final class SetRoundEffect extends MinigameRuntimeEffect {
|
||||
@Nonnull
|
||||
public static final BuilderCodec<SetRoundEffect> CODEC =
|
||||
BuilderCodec.builder(SetRoundEffect.class, SetRoundEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyPlayerScoreEffect.Operation.class), false),
|
||||
.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),
|
||||
@@ -24,7 +24,7 @@ public final class SetRoundEffect extends MinigameRuntimeEffect {
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.SET;
|
||||
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.SET;
|
||||
private int amount = 1;
|
||||
|
||||
@Override
|
||||
@@ -36,7 +36,7 @@ public final class SetRoundEffect extends MinigameRuntimeEffect {
|
||||
}
|
||||
|
||||
int current = rt.get().roundNumber();
|
||||
int next = Math.max(1, ModifyPlayerScoreEffect.apply(current, operation, amount));
|
||||
int next = Math.max(1, ModifyScoreEffect.apply(current, operation, amount));
|
||||
rt.get().roundNumber(next);
|
||||
|
||||
var services = services();
|
||||
|
||||
@@ -15,12 +15,18 @@ public final class SetSpawnPointEffect extends MinigameRuntimeEffect {
|
||||
BuilderCodec.builder(SetSpawnPointEffect.class, SetSpawnPointEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
||||
.add()
|
||||
.append(new KeyedCodec<>("DestinationId", Codec.STRING), (effect, value) -> effect.destinationId = value, effect -> effect.destinationId)
|
||||
.<Double>append(new KeyedCodec<>("X", Codec.DOUBLE, false), (effect, value) -> effect.x = value, effect -> effect.x)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("Y", Codec.DOUBLE, false), (effect, value) -> effect.y = value, effect -> effect.y)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("Z", Codec.DOUBLE, false), (effect, value) -> effect.z = value, effect -> effect.z)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String playerId;
|
||||
private String destinationId;
|
||||
private double x = 0.0D;
|
||||
private double y = 0.0D;
|
||||
private double z = 0.0D;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
@@ -39,7 +45,8 @@ public final class SetSpawnPointEffect extends MinigameRuntimeEffect {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
||||
return;
|
||||
}
|
||||
player.checkpointId(destinationId);
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' spawnPoint='" + destinationId + "'");
|
||||
String destination = x + "," + y + "," + z;
|
||||
player.checkpointId(destination);
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' spawnPoint='" + destination + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("OffsetZ", Codec.DOUBLE, false), (effect, value) -> effect.offsetZ = value, effect -> effect.offsetZ)
|
||||
.add()
|
||||
.<Boolean>append(new KeyedCodec<>("IgnoreRespawnTimer", Codec.BOOLEAN, false), (effect, value) -> effect.ignoreRespawnTimer = value, effect -> effect.ignoreRespawnTimer)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String itemId = "";
|
||||
@@ -54,6 +56,7 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
||||
private double offsetX = 0.0D;
|
||||
private double offsetY = 0.0D;
|
||||
private double offsetZ = 0.0D;
|
||||
private boolean ignoreRespawnTimer = false;
|
||||
|
||||
private final transient Map<String, Ref<EntityStore>> activeItems = new ConcurrentHashMap<>();
|
||||
private final transient Map<String, Instant> nextSpawnTimes = new ConcurrentHashMap<>();
|
||||
@@ -65,7 +68,18 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
SpawnNpcEffect.pruneStaleRuntimeKeys(activeItems, nextSpawnTimes);
|
||||
String key = key(context);
|
||||
|
||||
if (ignoreRespawnTimer) {
|
||||
Instant nextSpawn = nextSpawnTimes.get(key);
|
||||
if (nextSpawn != null && Instant.now().isBefore(nextSpawn)) {
|
||||
return;
|
||||
}
|
||||
spawn(context, key);
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> active = activeItems.get(key);
|
||||
if (active != null && active.isValid()) {
|
||||
return;
|
||||
@@ -113,6 +127,12 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
||||
|
||||
activeItems.put(key, ref);
|
||||
nextSpawnTimes.remove(key);
|
||||
if (ignoreRespawnTimer) {
|
||||
int delay = Math.max(0, respawnDelaySeconds);
|
||||
if (delay > 0) {
|
||||
nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay)));
|
||||
}
|
||||
}
|
||||
runtime(context).ifPresent(runtime -> {
|
||||
UUIDComponent uuid = context.getStore().getComponent(ref, UUIDComponent.getComponentType());
|
||||
if (uuid != null) {
|
||||
@@ -126,6 +146,9 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
||||
private String key(@Nonnull TriggerContext context) {
|
||||
String configured = spawnKey != null ? spawnKey.trim() : "";
|
||||
String volumeId = context.getVolume().getId();
|
||||
return volumeId + "|" + (!configured.isBlank() ? configured : itemId);
|
||||
// Keys are scoped to the runtime so cooldowns from a previous game can never
|
||||
// block spawning in the next one.
|
||||
String runtimeSegment = runtime(context).map(rt -> rt.runtimeId()).orElse("global");
|
||||
return runtimeSegment + "|" + volumeId + "|" + (!configured.isBlank() ? configured : itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
pruneStaleRuntimeKeys(activeNpcs, nextSpawnTimes);
|
||||
String key = key(context);
|
||||
List<Ref<EntityStore>> active = pruneActive(key);
|
||||
int targetCount = Math.max(1, count);
|
||||
@@ -192,6 +193,25 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
||||
private String key(@Nonnull TriggerContext context) {
|
||||
String configured = spawnKey != null ? spawnKey.trim() : "";
|
||||
String volumeId = context.getVolume().getId();
|
||||
return volumeId + "|" + (!configured.isBlank() ? configured : roleId);
|
||||
// Keys are scoped to the runtime so cooldowns and active lists from a previous
|
||||
// game can never block spawning in the next one.
|
||||
String runtimeSegment = runtime(context).map(rt -> rt.runtimeId()).orElse("global");
|
||||
return runtimeSegment + "|" + volumeId + "|" + (!configured.isBlank() ? configured : roleId);
|
||||
}
|
||||
|
||||
/** Drops state belonging to runtimes that no longer exist. */
|
||||
static void pruneStaleRuntimeKeys(Map<String, ?>... maps) {
|
||||
var services = services();
|
||||
if (services == null) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, ?> map : maps) {
|
||||
map.keySet().removeIf(key -> {
|
||||
int separator = key.indexOf('|');
|
||||
String segment = separator > 0 ? key.substring(0, separator) : "";
|
||||
return !"global".equals(segment) && !segment.isEmpty()
|
||||
&& services.runtime().runtimeById(segment).isEmpty();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ public final class StartMinigameEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
var candidate = services.maps().candidateFromVolume(context, id);
|
||||
services.runtime().start(defOpt.get(), candidate);
|
||||
String worldName = context.getStore() != null
|
||||
? context.getStore().getExternalData().getWorld().getName()
|
||||
: null;
|
||||
services.runtime().start(defOpt.get(), candidate, worldName, false);
|
||||
debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 net.kewwbec.minigames.service.MinigameStarter;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
@@ -32,7 +31,7 @@ public final class StartQueuedMinigameEffect extends MinigameRuntimeEffect {
|
||||
|
||||
var def = defOpt.get();
|
||||
|
||||
if (!services.runtime().runtimes(id).isEmpty()) {
|
||||
if (!services.runtime().runtimesForMinigame(id).isEmpty()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (runtime already active for id='" + id + "')");
|
||||
return;
|
||||
}
|
||||
@@ -43,8 +42,11 @@ public final class StartQueuedMinigameEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
var discovered = services.maps().discover(context, id);
|
||||
services.queue().startQueued(def, discovered.candidates(), services.runtime());
|
||||
debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size());
|
||||
var runtime = MinigameStarter.startQueued(services, def, context.getStore(), false);
|
||||
if (runtime != null) {
|
||||
debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' runtime=" + runtime.runtimeId());
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (start refused — check world restriction / activation volume)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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 net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.model.TeamState;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Rotates players between teams — for alternating-sides games (e.g. attack/defend swap
|
||||
* for round 2). With two teams a Rotation of 1 is a straight swap; with N teams it shifts
|
||||
* each team's members onto the next team. Optionally respawns players onto their new side.
|
||||
*/
|
||||
public final class SwapTeamsEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "SwapTeams";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<SwapTeamsEffect> CODEC =
|
||||
BuilderCodec.builder(SwapTeamsEffect.class, SwapTeamsEffect::new, MINIGAME_BASE_CODEC)
|
||||
// Positions to shift each team's members forward in the (id-sorted) team list. 1 == swap for two teams.
|
||||
.append(new KeyedCodec<>("Rotation", Codec.INTEGER, false), (effect, value) -> effect.rotation = value, effect -> effect.rotation)
|
||||
.add()
|
||||
// Clear stored checkpoints so respawn resolves the new team's spawn instead of an old checkpoint.
|
||||
.append(new KeyedCodec<>("ClearCheckpoints", Codec.BOOLEAN, false), (effect, value) -> effect.clearCheckpoints = value, effect -> effect.clearCheckpoints)
|
||||
.add()
|
||||
// Immediately teleport reassigned ACTIVE players to their new team spawn.
|
||||
.append(new KeyedCodec<>("Respawn", Codec.BOOLEAN, false), (effect, value) -> effect.respawn = value, effect -> effect.respawn)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private int rotation = 1;
|
||||
private boolean clearCheckpoints = true;
|
||||
private boolean respawn = true;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
// Sorted by id for a stable, predictable rotation order (matches AssignTeam balancing).
|
||||
List<TeamState> teams = new ArrayList<>(rt.get().teams().values());
|
||||
teams.sort(Comparator.comparing(TeamState::teamId));
|
||||
int n = teams.size();
|
||||
if (n < 2) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (needs at least 2 teams, found " + n + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
int shift = ((rotation % n) + n) % n;
|
||||
if (shift == 0) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (rotation " + rotation + " is a no-op for " + n + " teams)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot current membership before mutating, then rebuild from the snapshot.
|
||||
List<Set<String>> snapshot = new ArrayList<>(n);
|
||||
for (TeamState team : teams) {
|
||||
snapshot.add(new LinkedHashSet<>(team.players()));
|
||||
team.players().clear();
|
||||
}
|
||||
|
||||
int moved = 0;
|
||||
List<String> moves = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
TeamState source = teams.get(i);
|
||||
TeamState target = teams.get((i + shift) % n);
|
||||
for (String playerId : snapshot.get(i)) {
|
||||
target.players().add(playerId);
|
||||
PlayerMinigameState player = rt.get().players().get(playerId);
|
||||
if (player != null) {
|
||||
player.teamId(target.teamId());
|
||||
if (clearCheckpoints) {
|
||||
player.checkpointId(null);
|
||||
}
|
||||
}
|
||||
moves.add(displayName(playerId) + " " + source.teamId() + " -> " + target.teamId());
|
||||
moved++;
|
||||
}
|
||||
}
|
||||
|
||||
if (respawn) {
|
||||
MinigameServices services = services();
|
||||
if (services != null) {
|
||||
for (TeamState team : teams) {
|
||||
for (String playerId : team.players()) {
|
||||
PlayerMinigameState player = rt.get().players().get(playerId);
|
||||
if (player == null || player.status() != PlayerStatus.ACTIVE) {
|
||||
continue;
|
||||
}
|
||||
String dest = services.maps().respawnDestination(context.getStore(), rt.get(), player, null);
|
||||
if (!dest.isBlank()) {
|
||||
services.adapters().teleport(playerId, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED swap rotation=" + shift + " teams=" + n + ": "
|
||||
+ (moves.isEmpty() ? "(no players)" : String.join(", ", moves)));
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@ public final class VoteForMapEffect extends MinigameRuntimeEffect {
|
||||
? mapId
|
||||
: services != null ? services.maps().candidateFromVolume(context, id).mapId() : "";
|
||||
if (services != null && playerId != null && !id.isBlank() && !selectedMapId.isBlank()) {
|
||||
services.queue().vote(id, playerId, selectedMapId);
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' voted map='" + selectedMapId + "' minigame='" + id + "'");
|
||||
boolean voted = services.queue().vote(id, playerId, selectedMapId);
|
||||
debugMessage(context, TYPE_ID, voted
|
||||
? "FIRED player='" + playerId + "' voted map='" + selectedMapId + "' minigame='" + id + "'"
|
||||
: "SKIPPED (player='" + playerId + "' is not queued for minigame='" + id + "')");
|
||||
} else {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "' map='" + selectedMapId + "')");
|
||||
}
|
||||
|
||||
@@ -138,3 +138,45 @@ event.on_game_rounds_complete = All {final_round} rounds have been played.
|
||||
|
||||
endscores.header = --- Final Scores ---
|
||||
endscores.entry = {rank}. {player}: {score}
|
||||
|
||||
# Countdown
|
||||
countdown.tick = Game starting in {seconds}...
|
||||
countdown.cancelled = Countdown cancelled — not enough players. You have been re-queued.
|
||||
|
||||
# Queue
|
||||
queue.full = The queue for {id} is full.
|
||||
queue.disabled = {id} is currently disabled.
|
||||
|
||||
# Start refusals
|
||||
start.error.no_activation_volume = {id} cannot start: no trigger volume sets its phase to ACTIVE. Map setup is incomplete.
|
||||
start.error.wrong_world = {id} can only be started in world '{world}'.
|
||||
|
||||
# Commands
|
||||
command.error.player_required = This command requires a player target.
|
||||
command.error.invalid_id = '{id}' is not a valid minigame id. Use letters, numbers, '_' and '-'.
|
||||
command.error.pack_not_found = Asset pack '{pack}' was not found.
|
||||
command.error.pack_immutable = Asset pack '{pack}' is read-only and cannot store definitions.
|
||||
command.create.error.save_failed = Could not save '{id}': {error}
|
||||
command.edit.success = Updated {property} of {id} to '{value}'.
|
||||
command.edit.error.unknown_property = Unknown property '{property}'. Valid properties: {valid}
|
||||
command.edit.error.save_failed = Could not save '{id}': {error}
|
||||
command.join.success = Joined the queue for {id}.
|
||||
command.join.already_queued = You are already queued for {id}.
|
||||
command.leave.success = You left {id}.
|
||||
command.leave.not_in_game = You are not in a minigame.
|
||||
command.spectate.success = Now spectating {id}.
|
||||
command.spectate.error.not_running = {id} is not running.
|
||||
command.spectate.error.not_allowed = {id} does not allow spectators.
|
||||
command.spectate.error.already_in_game = You are already in a minigame.
|
||||
command.start.error.refused = Could not start {id} — check the world restriction and that a SetMinigamePhase(ACTIVE) volume exists.
|
||||
command.vote.success = Voted for {map} in {id}.
|
||||
command.vote.error.not_queued = You must join the {id} queue before voting.
|
||||
|
||||
# Validation
|
||||
validation.overtime_length_not_positive = OvertimeSeconds must be positive when overtime is enabled.
|
||||
validation.sudden_death_length_not_positive = SuddenDeathSeconds must be positive when sudden death is enabled.
|
||||
|
||||
# Runtime end reasons
|
||||
runtime.reason.admin_stop = Stopped by an administrator.
|
||||
runtime.reason.deleted = The minigame was deleted.
|
||||
runtime.reason.trigger_volume = Ended by the game.
|
||||
|
||||
@@ -48,10 +48,13 @@ customUI.triggerVolumeEffectEditor.field.EndMinigame.MinigameId.tooltip = The mi
|
||||
customUI.triggerVolumeEffectEditor.field.ResetMinigame.MinigameId.tooltip = The minigame runtime state to reset.
|
||||
customUI.triggerVolumeEffectEditor.field.SetMinigamePhase.Phase.tooltip = The phase to assign to the minigame runtime.
|
||||
customUI.triggerVolumeEffectEditor.field.ResetScores.MinigameId.tooltip = The minigame whose player and team scores should be reset.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyPlayerScore.Amount.tooltip = Score amount to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyTeamScore.Amount.tooltip = Team score amount to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyScore.TargetType.tooltip = Whether to modify player score or team score.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyScore.TargetSource.tooltip = Whether to target the triggering player, the triggering player's team, or the TeamId tag on the volume.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyScore.Amount.tooltip = Score amount to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyCounter.Amount.tooltip = Counter amount to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyPlayerLives.Amount.tooltip = Life count to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyLives.TargetType.tooltip = Whether to modify one player or all players on a resolved team.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyLives.TargetSource.tooltip = Whether to target the triggering player, the triggering player's team, or the TeamId tag on the volume.
|
||||
customUI.triggerVolumeEffectEditor.field.ModifyLives.Amount.tooltip = Life count to set, add, or subtract.
|
||||
customUI.triggerVolumeEffectEditor.field.SetPlayerStatus.Status.tooltip = The status to assign to the target player.
|
||||
customUI.triggerVolumeEffectEditor.field.JoinMinigameQueue.MinigameId.tooltip = The minigame queue the triggering player should join.
|
||||
customUI.triggerVolumeEffectEditor.field.LeaveMinigameQueue.MinigameId.tooltip = The minigame queue the triggering player should leave.
|
||||
@@ -191,7 +194,12 @@ customUI.triggerVolumeEffectEditor.conditionType.WinConditionMet = Win Condition
|
||||
customUI.triggerVolumeEffectEditor.field.StartTimer.TimerName.tooltip = Unique name for this timer. Replaces any existing timer with the same name.
|
||||
customUI.triggerVolumeEffectEditor.field.StartTimer.DurationSeconds.tooltip = Seconds until the timer fires.
|
||||
customUI.triggerVolumeEffectEditor.field.StopTimer.TimerName.tooltip = Name of the timer to cancel before it fires.
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.DestinationId.tooltip = Destination saved as this player's respawn location.
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.X = X
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.X.tooltip = Respawn checkpoint X coordinate.
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.Y = Y
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.Y.tooltip = Respawn checkpoint Y coordinate.
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.Z = Z
|
||||
customUI.triggerVolumeEffectEditor.field.SetSpawnPoint.Z.tooltip = Respawn checkpoint Z coordinate.
|
||||
customUI.triggerVolumeEffectEditor.field.RespawnPlayer.DestinationId.tooltip = Optional override destination. Leave empty to use the player's saved spawn point, then a matching team spawn volume.
|
||||
customUI.triggerVolumeEffectEditor.field.SendMinigameMessage.MessageKey.tooltip = Translation key of the message. Must be defined in your minigames.lang file.
|
||||
customUI.triggerVolumeEffectEditor.field.SendMinigameMessage.Target.tooltip = Send to the triggering player, their team, or all active players.
|
||||
@@ -226,10 +234,9 @@ customUI.triggerVolumeEffectEditor.effectType.EndMinigame = End Minigame
|
||||
customUI.triggerVolumeEffectEditor.effectType.ResetMinigame = Reset Minigame
|
||||
customUI.triggerVolumeEffectEditor.effectType.SetMinigamePhase = Set Phase
|
||||
customUI.triggerVolumeEffectEditor.effectType.ResetScores = Reset Scores
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerScore = Modify Player Score
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyTeamScore = Modify Team Score
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyScore = Modify Score
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyCounter = Modify Counter
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerLives = Modify Player Lives
|
||||
customUI.triggerVolumeEffectEditor.effectType.ModifyLives = Modify Lives
|
||||
customUI.triggerVolumeEffectEditor.effectType.SetPlayerStatus = Set Player Status
|
||||
|
||||
customUI.triggerVolumeEffectEditor.conditionType.MinigamePhase = Minigame Phase
|
||||
@@ -314,3 +321,5 @@ server.commands.triggervolume.clonegroup.notFound = No trigger volume group foun
|
||||
server.commands.triggervolume.clonegroup.alreadyExists = A trigger volume group already exists with name '{name}'
|
||||
server.commands.triggervolume.clonegroup.empty = Group '{name}' has no member volumes
|
||||
server.commands.triggervolume.clonegroup.success = Cloned group '{source}' to '{name}' with {count} volumes
|
||||
|
||||
server.commands.triggervolume.create.alreadyExists = A trigger volume named '{name}' already exists
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"Id": "Dockside_Derby",
|
||||
"Name": "Dockside Derby",
|
||||
"Description": "Catch as many valuable fish as possible before time runs out.",
|
||||
"Enabled": true,
|
||||
"GameType": "fishing_derby",
|
||||
"RequiredGameMode": "adventure",
|
||||
"MinPlayers": 1,
|
||||
"MaxPlayers": 12,
|
||||
"TeamMode": "Solo",
|
||||
"TeamCount": 0,
|
||||
"PlayersPerTeam": 0,
|
||||
"RoundLengthSeconds": 300,
|
||||
"CountdownSeconds": 10,
|
||||
"OvertimeEnabled": false,
|
||||
"SuddenDeathEnabled": false,
|
||||
"WinCondition": "HighestScore",
|
||||
"ResetOnEnd": true,
|
||||
"SavePlayerInventory": true,
|
||||
"RestorePlayerInventory": true,
|
||||
"AllowJoinMidgame": false,
|
||||
"AllowSpectators": true,
|
||||
"AllowPvp": false,
|
||||
"AllowBlockBreaking": false,
|
||||
"AllowBlockPlacing": false,
|
||||
"StartItems": [
|
||||
{ "ItemId": "event_fishing_rod", "Amount": 1 }
|
||||
],
|
||||
"Rewards": [
|
||||
{
|
||||
"Target": "winner",
|
||||
"Items": [ { "ItemId": "gold_coin", "Amount": 25 } ]
|
||||
},
|
||||
{
|
||||
"Target": "participant",
|
||||
"Items": [ { "ItemId": "silver_coin", "Amount": 5 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
package net.kewwbec.minigames.service;
|
||||
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinigameQueueServiceTest {
|
||||
|
||||
@Test
|
||||
void highestVoteWins() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
var castle = candidate("King_Of_The_Hill", "Castle", "");
|
||||
var ruins = candidate("King_Of_The_Hill", "Ruins", "");
|
||||
|
||||
queue.join(def, "player-a");
|
||||
queue.join(def, "player-b");
|
||||
queue.join(def, "player-c");
|
||||
queue.vote("King_Of_The_Hill", "player-a", "Ruins");
|
||||
queue.vote("King_Of_The_Hill", "player-b", "Castle");
|
||||
queue.vote("King_Of_The_Hill", "player-c", "Castle");
|
||||
@@ -26,9 +40,12 @@ final class MinigameQueueServiceTest {
|
||||
@Test
|
||||
void tieResolvesToAValidCandidate() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
var castle = candidate("King_Of_The_Hill", "Castle", "");
|
||||
var ruins = candidate("King_Of_The_Hill", "Ruins", "");
|
||||
|
||||
queue.join(def, "player-a");
|
||||
queue.join(def, "player-b");
|
||||
queue.vote("King_Of_The_Hill", "player-a", "Ruins");
|
||||
queue.vote("King_Of_The_Hill", "player-b", "Castle");
|
||||
|
||||
@@ -38,8 +55,10 @@ final class MinigameQueueServiceTest {
|
||||
@Test
|
||||
void randomModeIgnoresVotesWhenOnlyOneCandidateExists() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
var castle = candidate("King_Of_The_Hill", "Castle", "");
|
||||
|
||||
queue.join(def, "player-a");
|
||||
queue.vote("King_Of_The_Hill", "player-a", "Ruins");
|
||||
|
||||
assertEquals(castle, queue.select("King_Of_The_Hill", MapSelectionMode.RANDOM, List.of(castle)));
|
||||
@@ -48,8 +67,9 @@ final class MinigameQueueServiceTest {
|
||||
@Test
|
||||
void leaveRemovesQueuedPlayerAndVote() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
|
||||
queue.join("King_Of_The_Hill", "player-a");
|
||||
queue.join(def, "player-a");
|
||||
queue.vote("King_Of_The_Hill", "player-a", "Castle");
|
||||
queue.leave("King_Of_The_Hill", "player-a");
|
||||
|
||||
@@ -68,7 +88,206 @@ final class MinigameQueueServiceTest {
|
||||
assertEquals("", selected.variantId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinEnforcesMaxPlayers() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("Duels", 2);
|
||||
|
||||
assertEquals(MinigameQueueService.JoinResult.JOINED, queue.join(def, "player-a"));
|
||||
assertEquals(MinigameQueueService.JoinResult.JOINED, queue.join(def, "player-b"));
|
||||
assertEquals(MinigameQueueService.JoinResult.QUEUE_FULL, queue.join(def, "player-c"));
|
||||
assertEquals(MinigameQueueService.JoinResult.ALREADY_QUEUED, queue.join(def, "player-a"));
|
||||
assertEquals(2, queue.players("Duels").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinRejectsDisabledMinigame() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("Closed", 16);
|
||||
def.setEnabled(false);
|
||||
|
||||
assertEquals(MinigameQueueService.JoinResult.DISABLED, queue.join(def, "player-a"));
|
||||
assertTrue(queue.players("Closed").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voteRequiresBeingQueued() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
|
||||
assertFalse(queue.vote("King_Of_The_Hill", "player-a", "Castle"));
|
||||
queue.join(def, "player-a");
|
||||
assertTrue(queue.vote("King_Of_The_Hill", "player-a", "Castle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsDoNotCreateQueueSessions() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
|
||||
assertTrue(queue.players("Never_Joined").isEmpty());
|
||||
assertTrue(queue.votes("Never_Joined").isEmpty());
|
||||
assertTrue(queue.openVoteWindows().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveAllRemovesPlayerFromEveryQueue() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
queue.join(definition("Game_A", 16), "player-a");
|
||||
queue.join(definition("Game_B", 16), "player-a");
|
||||
|
||||
queue.leaveAll("player-a");
|
||||
|
||||
assertTrue(queue.players("Game_A").isEmpty());
|
||||
assertTrue(queue.players("Game_B").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voteWindowCompletesOnTimeoutOrAllVotes() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("King_Of_The_Hill", 16);
|
||||
queue.join(def, "player-a");
|
||||
queue.join(def, "player-b");
|
||||
|
||||
long now = 1_000_000L;
|
||||
assertFalse(queue.voteWindowComplete("King_Of_The_Hill", now));
|
||||
assertTrue(queue.openVoteWindow("King_Of_The_Hill", now));
|
||||
assertFalse(queue.openVoteWindow("King_Of_The_Hill", now), "window must not re-open while active");
|
||||
|
||||
assertFalse(queue.voteWindowComplete("King_Of_The_Hill", now + 1));
|
||||
// All queued players voted -> complete before the deadline
|
||||
queue.vote("King_Of_The_Hill", "player-a", "Castle");
|
||||
queue.vote("King_Of_The_Hill", "player-b", "Ruins");
|
||||
assertTrue(queue.voteWindowComplete("King_Of_The_Hill", now + 1));
|
||||
|
||||
// Timeout alone also completes the window
|
||||
MinigameQueueService timedOut = new MinigameQueueService();
|
||||
timedOut.join(def, "player-c");
|
||||
timedOut.openVoteWindow("King_Of_The_Hill", now);
|
||||
assertTrue(timedOut.voteWindowComplete("King_Of_The_Hill", now + MinigameQueueService.VOTE_WINDOW_MS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startQueuedMovesPlayersAndRecordsHomeWorld() {
|
||||
MinigameQueueService queue = new MinigameQueueService();
|
||||
MinigameDefinition def = definition("Duels", 2);
|
||||
queue.join(def, "player-a");
|
||||
queue.join(def, "player-b");
|
||||
FakeRuntimeService runtimeService = new FakeRuntimeService();
|
||||
|
||||
MinigameRuntime runtime = queue.startQueued(def, candidate("Duels", "Pit", ""), runtimeService, "world1", false);
|
||||
|
||||
assertEquals(2, runtime.players().size());
|
||||
assertTrue(runtime.players().containsKey("player-a"));
|
||||
assertTrue(runtime.players().containsKey("player-b"));
|
||||
assertTrue(queue.players("Duels").isEmpty());
|
||||
assertEquals("world1", runtime.worldName());
|
||||
}
|
||||
|
||||
private static MinigameDefinition definition(String id, int maxPlayerCount) {
|
||||
return new MinigameDefinition(id) {
|
||||
{
|
||||
this.name = id;
|
||||
this.maxPlayers = maxPlayerCount;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static MinigameMapCandidate candidate(String minigameId, String mapId, String variantId) {
|
||||
return new MinigameMapCandidate(minigameId, mapId, variantId, 1, true);
|
||||
}
|
||||
|
||||
/** Minimal runtime service: only the members startQueued touches are implemented. */
|
||||
private static final class FakeRuntimeService implements MinigameRuntimeService {
|
||||
@Override
|
||||
public MinigameRuntime start(MinigameDefinition definition) {
|
||||
return start(definition, MinigameMapCandidate.none(definition.getId()), null, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
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 runtime = new MinigameRuntime("runtime-1", definition, map.mapId(), map.variantId());
|
||||
runtime.worldName(worldName);
|
||||
runtime.adminStarted(adminStarted);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setupTeams(MinigameRuntime runtime) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endRuntime(String runtimeId, String reason) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endAll(String minigameId, String reason) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetRuntime(String runtimeId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(String minigameId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPhase(MinigameRuntime runtime, MinigamePhase phase) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerReconnected(MinigameRuntime runtime, String playerId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<MinigameRuntime> runtimeById(String runtimeId) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<MinigameRuntime> runtimesForMinigame(String minigameId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<MinigameRuntime> runtimeForArena(String minigameId, String mapId, String variantId) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<MinigameRuntime> runtimeForPlayer(String playerId) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<MinigameRuntime> runtimes() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdownAll(String reason) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tickPregame(long nowMs, String worldName) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user