From 8eb6e563c5e264779c1c0b0aa3d5d0167262fd32 Mon Sep 17 00:00:00 2001 From: Dakroach Date: Sun, 7 Jun 2026 14:34:30 -0700 Subject: [PATCH] First commit --- .gitignore | 63 +++ README.md | 155 ++++++++ build.gradle | 206 ++++++++++ docs/architecture.md | 139 +++++++ docs/commands.md | 100 +++++ docs/events-conditions-effects.md | 138 +++++++ docs/examples.md | 101 +++++ docs/minigame-definition.md | 147 +++++++ docs/tags.md | 103 +++++ docs/volumes.md | 122 ++++++ gradle.properties | 37 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 +++++++++++ gradlew.bat | 89 +++++ libs/.gitkeep | 0 settings.gradle | 1 + .../kewwbec/minigames/MinigameCorePlugin.java | 287 ++++++++++++++ .../minigames/adapter/HytaleAdapters.java | 54 +++ .../adapter/HytaleServerAdapters.java | 283 ++++++++++++++ .../minigames/command/MinigameCommand.java | 33 ++ .../command/sub/MinigameCreateCommand.java | 40 ++ .../command/sub/MinigameDeleteCommand.java | 38 ++ .../command/sub/MinigameDisableCommand.java | 34 ++ .../command/sub/MinigameEditCommand.java | 33 ++ .../command/sub/MinigameEnableCommand.java | 34 ++ .../command/sub/MinigameExportCommand.java | 33 ++ .../command/sub/MinigameImportCommand.java | 26 ++ .../command/sub/MinigameInfoCommand.java | 53 +++ .../command/sub/MinigameJoinCommand.java | 44 +++ .../command/sub/MinigameLeaveCommand.java | 33 ++ .../command/sub/MinigameListCommand.java | 45 +++ .../command/sub/MinigameMapsCommand.java | 50 +++ .../command/sub/MinigameResetCommand.java | 34 ++ .../command/sub/MinigameSpectateCommand.java | 48 +++ .../command/sub/MinigameStartCommand.java | 48 +++ .../command/sub/MinigameStopCommand.java | 46 +++ .../command/sub/MinigameValidateCommand.java | 52 +++ .../command/sub/MinigameVoteCommand.java | 41 ++ .../sub/volume/MinigameVolumeCommand.java | 14 + .../volume/MinigameVolumeCreateCommand.java | 27 ++ .../sub/volume/MinigameVolumeEditCommand.java | 26 ++ .../sub/volume/MinigameVolumeLinkCommand.java | 35 ++ .../sub/volume/MinigameVolumeTestCommand.java | 26 ++ .../controlplane/ControlPlaneAdapters.java | 33 ++ .../net/kewwbec/minigames/model/Enums.java | 38 ++ .../minigames/model/ItemStackConfig.java | 38 ++ .../minigames/model/MinigameDefinition.java | 363 ++++++++++++++++++ .../minigames/model/MinigameMapCandidate.java | 20 + .../model/MinigameMapDiscoveryResult.java | 13 + .../minigames/model/ObjectiveState.java | 39 ++ .../minigames/model/PlayerMinigameState.java | 48 +++ .../kewwbec/minigames/model/RewardConfig.java | 38 ++ .../minigames/model/SpawnItemConfig.java | 10 + .../minigames/model/SpawnItemEntry.java | 12 + .../kewwbec/minigames/model/TeamState.java | 32 ++ .../minigames/model/ValidationIssue.java | 13 + .../LocalJsonMinigameDefinitionStore.java | 39 ++ .../persistence/MinigameDefinitionStore.java | 10 + .../DefaultMinigameRuntimeService.java | 228 +++++++++++ .../minigames/runtime/MinigameEvent.java | 8 + .../minigames/runtime/MinigameRuntime.java | 68 ++++ .../runtime/MinigameRuntimeService.java | 22 ++ .../minigames/service/ArenaResetService.java | 8 + .../minigames/service/CheckpointService.java | 19 + .../service/DefaultMinigameService.java | 113 ++++++ .../minigames/service/InteractionService.java | 14 + .../service/MinigameMapDiscoveryService.java | 178 +++++++++ .../service/MinigameQueueService.java | 112 ++++++ .../minigames/service/MinigameService.java | 22 ++ .../minigames/service/MinigameServices.java | 14 + .../service/MinigameVolumeSignalService.java | 315 +++++++++++++++ .../minigames/service/RewardService.java | 10 + .../minigames/service/ScoreService.java | 28 ++ .../minigames/service/StreakService.java | 27 ++ .../minigames/service/TeamService.java | 22 ++ .../system/MinigameKillScoreSystem.java | 229 +++++++++++ .../volume/condition/CounterCondition.java | 57 +++ .../condition/CurrentRoundCondition.java | 40 ++ .../condition/MinigamePhaseCondition.java | 37 ++ .../condition/MinigameRuntimeCondition.java | 116 ++++++ .../volume/condition/NumberCompare.java | 21 + .../condition/ObjectiveStatusCondition.java | 50 +++ .../condition/PlayerInMinigameCondition.java | 39 ++ .../condition/PlayerLivesCondition.java | 52 +++ .../condition/PlayerScoreCondition.java | 52 +++ .../condition/PlayerStatusCondition.java | 49 +++ .../volume/condition/TeamCondition.java | 48 +++ .../volume/condition/TeamScoreCondition.java | 47 +++ .../condition/TimerElapsedCondition.java | 43 +++ .../condition/WinConditionMetCondition.java | 55 +++ .../volume/effect/AssignTeamEffect.java | 61 +++ .../volume/effect/AwardKillScoreEffect.java | 58 +++ .../volume/effect/ClearInventoryEffect.java | 37 ++ .../volume/effect/EndMinigameEffect.java | 38 ++ .../effect/JoinMinigameQueueEffect.java | 30 ++ .../effect/LeaveMinigameQueueEffect.java | 29 ++ .../volume/effect/MinigameRuntimeEffect.java | 121 ++++++ .../volume/effect/ModifyCounterEffect.java | 61 +++ .../effect/ModifyObjectiveProgressEffect.java | 63 +++ .../effect/ModifyPlayerLivesEffect.java | 64 +++ .../effect/ModifyPlayerScoreEffect.java | 66 ++++ .../volume/effect/ModifyTeamScoreEffect.java | 51 +++ .../volume/effect/ResetMinigameEffect.java | 33 ++ .../volume/effect/ResetScoresEffect.java | 29 ++ .../volume/effect/RespawnPlayerEffect.java | 60 +++ .../volume/effect/RestoreInventoryEffect.java | 51 +++ .../volume/effect/SaveInventoryEffect.java | 49 +++ .../effect/SendMinigameMessageEffect.java | 77 ++++ .../volume/effect/SetMinigamePhaseEffect.java | 42 ++ .../effect/SetObjectiveStatusEffect.java | 58 +++ .../volume/effect/SetPlayerStatusEffect.java | 64 +++ .../volume/effect/SetRoundEffect.java | 57 +++ .../volume/effect/SetSpawnPointEffect.java | 45 +++ .../volume/effect/SpawnItemEffect.java | 130 +++++++ .../volume/effect/StartMinigameEffect.java | 35 ++ .../effect/StartQueuedMinigameEffect.java | 36 ++ .../volume/effect/StartTimerEffect.java | 45 +++ .../volume/effect/StopTimerEffect.java | 40 ++ .../volume/effect/VoteForMapEffect.java | 37 ++ .../Server/Languages/en-US/minigames.lang | 140 +++++++ .../Server/Languages/en-US/server.lang | 193 ++++++++++ .../Server/Minigames/Dockside_Derby.json.old | 39 ++ src/main/resources/manifest.json | 22 ++ .../service/MinigameQueueServiceTest.java | 74 ++++ wiki/FAQ.md | 45 +++ wiki/Getting-Started.md | 61 +++ wiki/Home.md | 46 +++ wiki/How-It-Works.md | 102 +++++ workflows/build.yml | 76 ++++ 130 files changed, 8412 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle create mode 100644 docs/architecture.md create mode 100644 docs/commands.md create mode 100644 docs/events-conditions-effects.md create mode 100644 docs/examples.md create mode 100644 docs/minigame-definition.md create mode 100644 docs/tags.md create mode 100644 docs/volumes.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 libs/.gitkeep create mode 100644 settings.gradle create mode 100644 src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java create mode 100644 src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java create mode 100644 src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java create mode 100644 src/main/java/net/kewwbec/minigames/command/MinigameCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameCreateCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameDeleteCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameDisableCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameEditCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameEnableCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameExportCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameImportCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameInfoCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameLeaveCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameListCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameMapsCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameResetCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameStartCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameStopCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameValidateCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/MinigameVoteCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCreateCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeEditCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeLinkCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeTestCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/controlplane/ControlPlaneAdapters.java create mode 100644 src/main/java/net/kewwbec/minigames/model/Enums.java create mode 100644 src/main/java/net/kewwbec/minigames/model/ItemStackConfig.java create mode 100644 src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java create mode 100644 src/main/java/net/kewwbec/minigames/model/MinigameMapCandidate.java create mode 100644 src/main/java/net/kewwbec/minigames/model/MinigameMapDiscoveryResult.java create mode 100644 src/main/java/net/kewwbec/minigames/model/ObjectiveState.java create mode 100644 src/main/java/net/kewwbec/minigames/model/PlayerMinigameState.java create mode 100644 src/main/java/net/kewwbec/minigames/model/RewardConfig.java create mode 100644 src/main/java/net/kewwbec/minigames/model/SpawnItemConfig.java create mode 100644 src/main/java/net/kewwbec/minigames/model/SpawnItemEntry.java create mode 100644 src/main/java/net/kewwbec/minigames/model/TeamState.java create mode 100644 src/main/java/net/kewwbec/minigames/model/ValidationIssue.java create mode 100644 src/main/java/net/kewwbec/minigames/persistence/LocalJsonMinigameDefinitionStore.java create mode 100644 src/main/java/net/kewwbec/minigames/persistence/MinigameDefinitionStore.java create mode 100644 src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java create mode 100644 src/main/java/net/kewwbec/minigames/runtime/MinigameEvent.java create mode 100644 src/main/java/net/kewwbec/minigames/runtime/MinigameRuntime.java create mode 100644 src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/ArenaResetService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/CheckpointService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/DefaultMinigameService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/InteractionService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/MinigameService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/MinigameServices.java create mode 100644 src/main/java/net/kewwbec/minigames/service/MinigameVolumeSignalService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/RewardService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/ScoreService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/StreakService.java create mode 100644 src/main/java/net/kewwbec/minigames/service/TeamService.java create mode 100644 src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/CounterCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/CurrentRoundCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/MinigamePhaseCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/MinigameRuntimeCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/NumberCompare.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/ObjectiveStatusCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/PlayerInMinigameCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/PlayerLivesCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/PlayerScoreCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/PlayerStatusCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/TeamCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/TeamScoreCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/TimerElapsedCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/condition/WinConditionMetCondition.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/AssignTeamEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/AwardKillScoreEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ClearInventoryEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/EndMinigameEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/JoinMinigameQueueEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ModifyCounterEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ModifyObjectiveProgressEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerLivesEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerScoreEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ModifyTeamScoreEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ResetMinigameEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/ResetScoresEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/RespawnPlayerEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/RestoreInventoryEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SaveInventoryEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SendMinigameMessageEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SetMinigamePhaseEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SetObjectiveStatusEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SetRoundEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SetSpawnPointEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StartMinigameEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StartTimerEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StopTimerEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/VoteForMapEffect.java create mode 100644 src/main/resources/Server/Languages/en-US/minigames.lang create mode 100644 src/main/resources/Server/Languages/en-US/server.lang create mode 100644 src/main/resources/Server/Minigames/Dockside_Derby.json.old create mode 100644 src/main/resources/manifest.json create mode 100644 src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java create mode 100644 wiki/FAQ.md create mode 100644 wiki/Getting-Started.md create mode 100644 wiki/Home.md create mode 100644 wiki/How-It-Works.md create mode 100644 workflows/build.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31342f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.kotlin/ + +# Server testing directory +run/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# VS Code +.vscode/* +!.vscode/java-formatter.xml +!.vscode/settings.json + +# Mac OS +.DS_Store + +# Windows +Thumbs.db +desktop.ini + +# Logs +*.log + +# Temporary files +*.tmp +*.bak +*.swp +*~ + +# Template specific +libs/HytaleServer.jar +buildSrc/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc5299c --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# core-minigames + +A Hytale-native minigame extension for server-side minigame definitions, runtime state, and trigger-volume logic. + +The mod mirrors Hytale's own asset and trigger systems instead of maintaining a separate minigame-only registry layer. Minigames are `MinigameDefinition` assets, trigger logic is registered as real `TriggerEffect` and `TriggerCondition` codec entries, and runtime events are dispatched on Hytale's event bus. + +## Features + +- Asset-driven minigame definitions in `Server/Minigames/` +- Hytale Asset Editor support through `MinigameDefinition.CODEC` +- Hytale trigger-volume integration through native `TriggerEffect` and `TriggerCondition` types +- Asset-backed `MinigameId` picker fields in the trigger-volume editor +- Runtime lifecycle commands for creating, enabling, starting, stopping, and resetting minigames +- Multi-map runtime support using Hytale trigger-volume tags +- Player, team, score, lives, counter, status, and phase runtime state +- Hytale event-bus dispatch through `MinigameEvent` + +## Quick Start + +### Build + +```bash +gradlew.bat shadowJar +``` + +### Run with the local Hytale server + +```bash +gradlew.bat runServer +``` + +### Create a minigame definition +Manually by adding a file under `src/main/resources/Server/Minigames/`: +or via the asset editor. + + +```json +{ + "Id": "My_Minigame", + "Name": "My Minigame", + "Description": "A short description shown in menus.", + "Enabled": true, + "MinPlayers": 2, + "MaxPlayers": 8, + "TeamMode": "SOLO", + "WinCondition": "HIGHEST_SCORE", + "RoundLengthSeconds": 180, + "CountdownSeconds": 10 +} +``` + +Or create one from the server: + +```text +/minigame create My_Minigame "My Minigame" +/minigame enable My_Minigame +/minigame start My_Minigame +``` +TODO make this into its own UI in game. + + +## Trigger Volume Logic + +Use normal Hytale trigger volumes for the area shape and interaction point, then attach the minigame trigger conditions and effects. + +For multi-map minigames, tag volumes with `MinigameId=`, `MapId=`, optional `VariantId=`, and `MapRole=MainArena` on the primary arena volume. Team spawn volumes use `SpawnRole=TeamSpawn` and `TeamId=`. `MapSelectionMode` controls whether a queued match uses votes or random selection. + +Available condition types: + +- `MinigamePhase` +- `PlayerInMinigame` +- `PlayerStatus` +- `PlayerScore` +- `TeamScore` +- `Counter` +- `CurrentRound` +- `PlayerLives` +- `TeamCondition` +- `TimerElapsed` +- `ObjectiveStatus` +- `WinConditionMet` + +Available effect types: + +- `StartMinigame` +- `JoinMinigameQueue` +- `LeaveMinigameQueue` +- `VoteForMap` +- `StartQueuedMinigame` +- `SpawnItem` +- `AwardKillScore` +- `EndMinigame` +- `ResetMinigame` +- `SetMinigamePhase` +- `ResetScores` +- `ModifyPlayerScore` +- `ModifyTeamScore` +- `ModifyCounter` +- `ModifyPlayerLives` +- `SetPlayerStatus` +- `SetRound` +- `StartTimer` +- `StopTimer` +- `SetSpawnPoint` +- `RespawnPlayer` +- `SendMinigameMessage` +- `AssignTeam` +- `SaveInventory` +- `RestoreInventory` +- `ClearInventory` +- `SetObjectiveStatus` +- `ModifyObjectiveProgress` + +Fields named `MinigameId` use an editor dropdown backed by registered `MinigameDefinition` assets. + +## Project Structure + +```text +core-minigames/ +|-- src/main/java/net/kewwbec/minigames/ +| |-- MinigameCorePlugin.java +| |-- command/ +| |-- event/ +| |-- model/ +| |-- runtime/ +| |-- service/ +| `-- volume/ +| |-- condition/ +| `-- effect/ +|-- src/main/resources/Server/ +| |-- Languages/en-US/ +| `-- Minigames/ +|-- docs/ +`-- wiki/ +``` + +## Documentation + +| Doc | Description | +|-----|-------------| +| [Architecture](docs/architecture.md) | How the Hytale-native pieces fit together | +| [Minigame Definition](docs/minigame-definition.md) | `MinigameDefinition` JSON fields | +| [Events, Conditions & Effects](docs/events-conditions-effects.md) | Current trigger condition/effect reference | +| [Volumes](docs/volumes.md) | How to use normal Hytale trigger volumes for minigames | +| [Trigger Volume Tags](docs/tags.md) | Complete tag reference for maps, spawns, and signals | +| [Commands](docs/commands.md) | `/minigame` command reference | +| [Examples](docs/examples.md) | Small setup patterns | + +## Build Commands + +```bash +gradlew.bat compileJava +gradlew.bat processResources +gradlew.bat shadowJar +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..82948bf --- /dev/null +++ b/build.gradle @@ -0,0 +1,206 @@ +plugins { + id 'java' + id 'org.jetbrains.gradle.plugin.idea-ext' version '1.3' + id 'com.gradleup.shadow' version '9.3.1' +} + +import org.gradle.internal.os.OperatingSystem + +ext { + if (project.hasProperty('hytale_home')) { + hytaleHome = project.findProperty('hytale_home') + } else if (System.getenv('HYTALE_HOME')) { + hytaleHome = System.getenv('HYTALE_HOME') + } else { + def os = OperatingSystem.current() + if (os.isWindows()) { + hytaleHome = "${System.getProperty("user.home")}/AppData/Roaming/Hytale" + } + else if (os.isMacOsX()) { + hytaleHome = "${System.getProperty("user.home")}/Library/Application Support/Hytale" + } + else if (os.isLinux()) { + hytaleHome = "${System.getProperty("user.home")}/.var/app/com.hypixel.HytaleLauncher/data/Hytale" + if (!file(hytaleHome).exists()) { + hytaleHome = "${System.getProperty("user.home")}/.local/share/Hytale" + } + } + } +} + +def hytaleHomePath = ext.has('hytaleHome') ? hytaleHome : null +def hasHytaleHome = (hytaleHomePath != null) && file(hytaleHomePath).exists() +if (!hasHytaleHome) { + logger.lifecycle("Hytale install not detected; run configs that launch the server will be skipped. Set -Phytale_home=/path/to/Hytale to enable them.") +} + +java { + toolchain.languageVersion = JavaLanguageVersion.of(java_version) + withSourcesJar() + withJavadocJar() +} + +// Quiet warnings about missing Javadocs. +javadoc { + options.addStringOption('Xdoclint:-missing', '-quiet') +} + +// Adds the Hytale server as a build dependency, allowing you to reference and +// compile against their code without bundling it. When a local install is +// present, we still use its jar for launching the server in IDE run configs. +dependencies { + compileOnly("com.hypixel.hytale:Server:$hytale_build") + testCompileOnly("com.hypixel.hytale:Server:$hytale_build") + testRuntimeOnly("com.hypixel.hytale:Server:$hytale_build") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + if (hasHytaleHome) { + runtimeOnly(files("$hytaleHome/install/$patchline/package/game/latest/Server/HytaleServer.jar")) + } + + // Your dependencies here +} + +test { + useJUnitPlatform() +} + +repositories { + mavenCentral() + maven { + name = "hytale-release" + url = uri("https://maven.hytale.com/release") + } + maven { + name = "hytale-pre-release" + url = uri("https://maven.hytale.com/pre-release") + } +} + +// Updates the manifest.json file with the latest properties defined in the +// build.properties file. Currently we update the version and if packs are +// included with the plugin. +tasks.register('updatePluginManifest') { + def manifestFile = file('src/main/resources/manifest.json') + doLast { + if (!manifestFile.exists()) { + throw new GradleException("Could not find manifest.json at ${manifestFile.path}!") + } + def manifestJson = new groovy.json.JsonSlurper().parseText(manifestFile.text) + manifestJson.Version = version + manifestJson.IncludesAssetPack = includes_pack.toBoolean() + manifestFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(manifestJson)) + } +} + +// Makes sure the plugin manifest is up to date. +tasks.named('processResources') { + dependsOn 'updatePluginManifest' +} + +tasks.named('shadowJar') { + archiveClassifier.set('') + mergeServiceFiles() +} + +// Ensure the shaded jar is produced during a normal build. +tasks.named('build') { + dependsOn 'shadowJar' +} + +if (hasHytaleHome) { + // Create the working directory to run the server if it does not already exist. + def serverRunDir = file("$projectDir/run") + if (!serverRunDir.exists()) { + serverRunDir.mkdirs() + } + + def createServerRunArguments = { String srcDir -> + def programParameters = '--allow-op --disable-sentry --assets="' + "${hytaleHome}/install/$patchline/package/game/latest/Assets.zip" + '"' + def modPaths = [] + if (includes_pack.toBoolean()) { + modPaths << srcDir + } + if (load_user_mods.toBoolean()) { + modPaths << "${hytaleHome}/UserData/Mods" + } + if (!modPaths.isEmpty()) { + programParameters += ' --mods="' + modPaths.join(',') + '"' + } + return programParameters + } + + def serverJar = file("$hytaleHome/install/$patchline/package/game/latest/Server/HytaleServer.jar") + def assetsZip = file("$hytaleHome/install/$patchline/package/game/latest/Assets.zip") + def shadowJarTask = tasks.named('shadowJar') + + tasks.register('runServerJar', JavaExec) { + dependsOn shadowJarTask + mainClass = 'com.hypixel.hytale.Main' + classpath = files(serverJar) + workingDir = serverRunDir.absolutePath + doFirst { + def modPaths = [shadowJarTask.get().archiveFile.get().asFile.parentFile.absolutePath] + if (load_user_mods.toBoolean()) { + modPaths << "${hytaleHome}/UserData/Mods" + } + def args = [ + '--allow-op', + '--disable-sentry', + "--assets=${assetsZip}", + "--mod=${modPaths.join(',')}" + ] + logger.lifecycle("Running server with command: java -cp ${serverJar} ${mainClass.get()} ${args.join(' ')}") + setArgs(args) + } + + } + + tasks.register('runServer') { + + dependsOn 'runServerJar' + } + + // Creates a run configuration in IDEA that will run the Hytale server with + // your plugin and the default assets. + idea.project.settings.runConfigurations { + 'HytaleServer'(org.jetbrains.gradle.ext.Application) { + mainClass = 'com.hypixel.hytale.Main' + moduleName = project.idea.module.name + '.main' + programParameters = createServerRunArguments(sourceSets.main.java.srcDirs.first().parentFile.absolutePath) + workingDirectory = serverRunDir.absolutePath + } + } + + // Creates a launch.json file for VSCode with the same configuration + tasks.register('generateVSCodeLaunch') { + def vscodeDir = file("$projectDir/.vscode") + def launchFile = file("$vscodeDir/launch.json") + doLast { + if (!vscodeDir.exists()) { + vscodeDir.mkdirs() + } + def programParams = createServerRunArguments("\${workspaceFolder}") + def launchConfig = [ + version: "0.2.0", + configurations: [ + [ + type: "java", + name: "HytaleServer", + request: "launch", + mainClass: "com.hypixel.hytale.Main", + args: programParams, + cwd: "\${workspaceFolder}/run" + ] + ] + ] + launchFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(launchConfig)) + } + } +} else { + tasks.register('generateVSCodeLaunch') { + doLast { + logger.lifecycle("Skipped VSCode launch configuration because hytale_home is not set or the install path is missing.") + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ad3a1e6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,139 @@ +# Architecture + +## Overview + +core-minigames extends Hytale's existing asset, trigger-volume, and event systems. It does not run a separate minigame logic registry beside Hytale's trigger-volume registry. + +```text +Server/Minigames/*.json + | + v +MinigameDefinition asset store + | + v +MinigameRuntimeService + | + +--> Hytale event bus: MinigameEvent + | + +--> TriggerEffect.CODEC entries + | + `--> TriggerCondition.CODEC entries +``` + +## Asset Store + +`MinigameDefinition` is registered as a Hytale asset store in `MinigameCorePlugin.setup()`. + +Important details: + +- Path: `Server/Minigames/` +- Codec: `MinigameDefinition.CODEC` +- Key function: `MinigameDefinition::getId` +- Editor dropdown source: `MinigameDefinition` + +The trigger-volume editor can use this asset source for fields named `MinigameId`, so minigame trigger effects and conditions can pick from loaded minigame assets instead of free-typing IDs. + +## Runtime + +`DefaultMinigameRuntimeService` owns active `MinigameRuntime` instances keyed by runtime ID. A single minigame ID can have multiple active runtimes when multiple discovered maps or variants are started. + +Runtime state includes: + +- Current `MinigamePhase` +- Player state, score, lives, team, tags, and status +- Team membership and team score +- Objective state +- Counter and variable state +- Spectator and eliminated-player sets +- Runtime ID, map ID, and variant ID + +`DefaultMinigameService` handles definition-level actions and delegates live actions to the runtime service. + +## Events + +`MinigameEvent` implements Hytale's `IEvent`. + +Runtime events dispatch through: + +```java +HytaleServer.get() + .getEventBus() + .dispatchFor(MinigameEvent.class, event.minigameId()) + .dispatch(event); +``` + +This keeps minigame events available to Hytale's event system instead of routing them through a custom `EventRegistry`. + +## Trigger Conditions + +Minigame trigger conditions extend Hytale's `TriggerCondition` and are registered directly into `TriggerCondition.CODEC`. + +Current condition type IDs: + +- `MinigamePhase` +- `PlayerInMinigame` +- `PlayerStatus` +- `PlayerScore` +- `TeamScore` +- `Counter` +- `CurrentRound` +- `PlayerLives` +- `TeamCondition` +- `TimerElapsed` +- `ObjectiveStatus` +- `WinConditionMet` + +## Trigger Effects + +Minigame trigger effects extend Hytale's `TriggerEffect` and are registered directly into `TriggerEffect.CODEC`. + +Current effect type IDs: + +- `StartMinigame` +- `EndMinigame` +- `ResetMinigame` +- `SetMinigamePhase` +- `ResetScores` +- `ModifyPlayerScore` +- `ModifyTeamScore` +- `ModifyCounter` +- `ModifyPlayerLives` +- `SetPlayerStatus` +- `JoinMinigameQueue` +- `LeaveMinigameQueue` +- `VoteForMap` +- `StartQueuedMinigame` +- `SpawnItem` +- `SetRound` +- `StartTimer` +- `StopTimer` +- `SetSpawnPoint` +- `RespawnPlayer` +- `SendMinigameMessage` +- `AssignTeam` +- `SaveInventory` +- `RestoreInventory` +- `ClearInventory` +- `SetObjectiveStatus` +- `ModifyObjectiveProgress` +- `AwardKillScore` + +## Volumes + +There are no custom minigame volume classes after the refactor. Hytale trigger volumes provide the region shape, enter/exit behavior, tags, and trigger effect assets. + +To define a minigame area, create a normal Hytale trigger volume and tag it with `MinigameId=`, `MapId=`, optional `VariantId=`, and optionally `MapRole=MainArena`. Then attach minigame trigger conditions and effects to that trigger volume or to reusable Hytale trigger effect assets. + +## Removed Layer + +The following old architecture pieces were removed because they duplicated Hytale systems: + +- `BuiltinRegistries` +- `ConditionRegistry` +- `EventRegistry` +- `MinigameRegistries` +- `TemplateRegistry` +- `VolumeRegistry` +- `VolumeTypeDefinition` + +Future minigame features should keep following the same pattern: use Hytale asset stores, Hytale trigger codec registration, and Hytale event dispatch unless there is a concrete engine gap that requires a local adapter. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..d5792d6 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,100 @@ +# Commands Reference + +All commands are subcommands of `/minigame`. + +## Definition Management + +### `/minigame list` + +Lists registered minigames and basic runtime state. + +### `/minigame create ` + +Creates a new minigame definition with default settings. + +```text +/minigame create Beach_Race "Beach Race" +``` + +Definitions created by command start disabled. Enable them when the asset is configured. + +### `/minigame info ` + +Shows the loaded definition and runtime status for a minigame. + +### `/minigame edit ` + +Edits a single definition field and saves the definition. + +```text +/minigame edit Beach_Race MaxPlayers 16 +/minigame edit Beach_Race RoundLengthSeconds 600 +``` + +### `/minigame enable ` + +Sets `Enabled = true`. + +### `/minigame disable ` + +Sets `Enabled = false`. This prevents new starts but does not necessarily stop an already running runtime. + +### `/minigame validate ` + +Runs definition validation. Current validation focuses on definition fields. The old custom-volume requirement for `MinigameAreaVolume` no longer applies. + +### `/minigame delete ` + +Deletes the minigame definition. + +### `/minigame export ` + +Exports the current definition JSON. + +### `/minigame import ` + +Imports a definition from a JSON file path relative to the data directory. + +## Runtime Management + +### `/minigame start ` + +Starts a queued runtime for the given minigame. The selected map comes from `MapSelectionMode`: highest vote for `VOTE`, random discovered map for `RANDOM`. + +### `/minigame stop ` + +Stops all active runtimes for the minigame ID. An optional greedy `reason` argument can override the default admin-stop reason. + +### `/minigame reset ` + +Resets all active runtimes for the minigame ID. + +## Map Commands + +### `/minigame maps ` + +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 ` + +Casts or updates the sender's vote in the minigame waiting session. + +## Player Commands + +### `/minigame join [player]` + +Adds the command sender, or the optional player target, to the minigame waiting queue. + +### `/minigame leave [player]` + +Currently registered but not implemented. Use the `LeaveMinigameQueue` trigger effect for in-world queue leaving until the command is wired. + +### `/minigame spectate ` + +Currently registered but not implemented. The command validates that the minigame exists and has a running runtime, then returns a not-implemented message. + +## Volume Commands + +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 ` currently remains as a command stub for future Hytale trigger-volume integration. It should not be used as the primary way to define arenas. diff --git a/docs/events-conditions-effects.md b/docs/events-conditions-effects.md new file mode 100644 index 0000000..f046f47 --- /dev/null +++ b/docs/events-conditions-effects.md @@ -0,0 +1,138 @@ +# Events, Conditions & Effects + +Minigame logic is implemented as Hytale-native trigger conditions and trigger effects. These are registered into `TriggerCondition.CODEC` and `TriggerEffect.CODEC`, so they appear beside Hytale's built-in trigger-volume logic. + +## Events + +`MinigameEvent` implements `IEvent` and is dispatched through Hytale's event bus, keyed by minigame ID. + +Current runtime events emitted by the service layer: + +| Event ID | Fired When | +|----------|------------| +| `on_game_start` | A minigame runtime starts. | +| `on_game_end` | A minigame runtime ends. | +| `on_arena_reset` | A minigame runtime is reset. | +| `on_phase_change` | `SetMinigamePhase` changes the runtime phase. | +| `on_round_start` | `SetRound` starts a round, or the round timer advances to the next round. | +| `on_round_end` | The internal round timer expires. | +| `on_game_rounds_complete` | The final configured round ends. | +| `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_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. | + +The event payload is a string-object map. Runtime events include `runtime_id`, `minigame_id`, `map_id`, and `variant_id`. End events also include `reason`. + +## Conditions + +All minigame conditions share a `MinigameId` field. In the editor this is registered as an asset picker backed by `MinigameDefinition` assets. + +| Type ID | Fields | Description | +|---------|--------|-------------| +| `MinigamePhase` | `MinigameId`, `Phase` | Passes when the runtime phase matches. | +| `PlayerInMinigame` | `MinigameId`, optional `PlayerId` | Passes when the player is in the runtime. If `PlayerId` is blank, the trigger context player is used. | +| `PlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Passes when the player's minigame status matches. | +| `PlayerScore` | `MinigameId`, optional `PlayerId`, `Compare`, `Value` | Compares a player's score. | +| `TeamScore` | `MinigameId`, `TeamId`, `Compare`, `Value` | Compares a team's score. | +| `Counter` | `MinigameId`, `CounterId`, `Compare`, `Value` | Compares a named runtime counter. | +| `CurrentRound` | `MinigameId`, `Compare`, `Round` | Compares the current runtime round number. | +| `PlayerLives` | `MinigameId`, optional `PlayerId`, `Compare`, `Value` | Compares a player's remaining lives. | +| `TeamCondition` | `MinigameId`, optional `PlayerId`, `TeamId` | Passes when the player is assigned to the expected team. | +| `TimerElapsed` | `MinigameId`, `TimerName` | Passes when a named runtime timer exists and has elapsed. | +| `ObjectiveStatus` | `MinigameId`, `ObjectiveId`, `Status` | Passes when an objective has the expected status. | +| `WinConditionMet` | `MinigameId`, optional `TargetScore` | Evaluates the runtime definition's win condition for `LAST_ALIVE`, `FIRST_TO_SCORE`, and `OBJECTIVE_COMPLETE`. | + +### Compare Values + +`PlayerScore`, `TeamScore`, `Counter`, `CurrentRound`, and `PlayerLives` use the `NumberCompare` enum: + +| Value | Meaning | +|-------|---------| +| `EQUAL` | Actual value equals `Value`. | +| `NOT_EQUAL` | Actual value does not equal `Value`. | +| `GREATER_THAN` | Actual value is greater than `Value`. | +| `GREATER_THAN_OR_EQUAL` | Actual value is greater than or equal to `Value`. | +| `LESS_THAN` | Actual value is less than `Value`. | +| `LESS_THAN_OR_EQUAL` | Actual value is less than or equal to `Value`. | + +## 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. + +| Type ID | Fields | Description | +|---------|--------|-------------| +| `StartMinigame` | `MinigameId` | Starts a minigame runtime using the triggering volume's map tags when present. | +| `JoinMinigameQueue` | `MinigameId` | Adds the triggering player to the minigame waiting session. | +| `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. | +| `AwardKillScore` | optional `MinigameId`, `TargetIds`, `Points`, `AwardTarget` | Configures kill scoring for deaths inside this volume. `TargetIds` accepts `Player`, NPC role ids such as `fox`, or `*`. `AwardTarget` is `PLAYER`, `TEAM`, or `BOTH`. | +| `EndMinigame` | `MinigameId`, optional `Reason` | Ends the selected minigame runtime. | +| `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. | +| `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. | +| `SetPlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Sets a player's minigame status. | +| `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. | +| `RespawnPlayer` | `MinigameId`, optional `PlayerId`, optional `DestinationId` | Respawns a player using explicit destination, checkpoint, then team spawn fallback. | +| `SendMinigameMessage` | `MinigameId`, `MessageKey`, `Target` | Sends a translated message to `PLAYER`, `TEAM`, or `ALL`. | +| `AssignTeam` | `MinigameId`, optional `PlayerId`, `TeamId` | Assigns a player to a team in the runtime. | +| `SaveInventory` | `MinigameId`, optional `PlayerId` | Saves the player's current inventory into runtime state. | +| `RestoreInventory` | `MinigameId`, optional `PlayerId` | Restores the player's saved runtime inventory. | +| `ClearInventory` | `MinigameId`, optional `PlayerId` | Clears the player's inventory. | +| `SetObjectiveStatus` | `MinigameId`, `ObjectiveId`, `Status` | Creates or updates an objective status and dispatches `on_objective_complete` when complete. | +| `ModifyObjectiveProgress` | `MinigameId`, `ObjectiveId`, `Operation`, `Amount` | Sets, adds, or subtracts objective progress and completes an active objective when progress reaches the required threshold. | + +### Operation Values + +Score, counter, and lives mutation effects use: + +| Value | Meaning | +|-------|---------| +| `SET` | Replace the current value with `Amount`. | +| `ADD` | Add `Amount` to the current value. | +| `SUBTRACT` | Subtract `Amount` from the current value. | + +## Player Resolution + +Several conditions and effects allow `PlayerId` to be omitted. When it is blank, the implementation attempts to resolve the triggering player from Hytale's trigger context. This is the intended setup for normal enter/interact trigger volumes. + +Use an explicit `PlayerId` only when the trigger is not naturally tied to the player who caused it. + +## Respawn Resolution + +`RespawnPlayer` resolves where to send the player in this order: + +1. The effect's explicit `DestinationId`. +2. The player's stored checkpoint from `SetSpawnPoint`. +3. A random team spawn volume matching the player's `TeamId`. + +Team spawn volumes are normal Hytale trigger volumes tagged with `MinigameId`, `MapId`, optional `VariantId`, `SpawnRole=TeamSpawn`, and `TeamId=`. + +## Item Spawners + +`SpawnItem` creates a normal Hytale item drop, not a direct inventory grant. Use it for health packs, ammo, weapons, armor, and other pickups placed in the arena. + +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`. + +## Kill Scoring + +`AwardKillScore` is configured on a normal trigger volume, but scoring is applied by the minigame death system rather than by volume enter/exit/tick. The volume must cover the area where deaths should count and should use the normal `MinigameId`, `MapId`, and optional `VariantId` tags for runtime routing. + +Use `TargetIds = ["Player"]` for player kills, NPC role ids such as `fox` for specific NPC kills, or `["*"]` for any supported target. Friendly kills use `FriendlyFireKillScoreMode` on the `MinigameDefinition`. + +## Runtime Resolution + +Effects and conditions resolve a runtime in this order: + +1. The triggering player's active runtime. +2. A runtime matching the triggering volume's `MinigameId`, `MapId`, and `VariantId` tags. +3. The configured `MinigameId` as a fallback. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..4bba178 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,101 @@ +# Examples + +These examples describe the current Hytale-native setup: minigame definitions are assets, and world interaction is done with normal Hytale trigger volumes using the minigame trigger condition/effect types. + +## Simple Start Pad + +Definition file: `Server/Minigames/Quick_Brawl.json` + +```json +{ + "Id": "Quick_Brawl", + "Name": "Quick Brawl", + "Description": "A short solo score test.", + "Enabled": true, + "MinPlayers": 1, + "MaxPlayers": 8, + "TeamMode": "SOLO", + "WinCondition": "HIGHEST_SCORE", + "RoundLengthSeconds": 120, + "CountdownSeconds": 5, + "AllowPvp": true +} +``` + +Editor setup: + +1. Create a normal Hytale trigger volume at the start pad. +2. Add the `StartMinigame` effect. +3. Set `MinigameId` to `Quick_Brawl` using the asset picker. + +## Score Zone + +Definition file: `Server/Minigames/Hill_Points.json` + +```json +{ + "Id": "Hill_Points", + "Name": "Hill Points", + "Enabled": true, + "MinPlayers": 1, + "MaxPlayers": 12, + "TeamMode": "SOLO", + "WinCondition": "FIRST_TO_SCORE", + "RoundLengthSeconds": 300 +} +``` + +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`. + +If the trigger context already contains the player who entered the volume, leave `PlayerId` blank. + +## End When Score Reaches a Target + +Condition: + +- Type: `PlayerScore` +- `MinigameId = Hill_Points` +- `Compare = GREATER_THAN_OR_EQUAL` +- `Value = 100` + +Effect: + +- Type: `EndMinigame` +- `MinigameId = Hill_Points` +- `Reason = score_reached` + +## Team Score Zone + +Use `ModifyTeamScore` when the score belongs to a team instead of a player. + +Required fields: + +- `MinigameId` +- `TeamId` +- `Operation` +- `Amount` + +The current implementation expects an explicit `TeamId`. + +## Counter Gate + +Increment: + +- Effect: `ModifyCounter` +- `CounterId = captured_points` +- `Operation = ADD` +- `Amount = 1` + +Gate: + +- Condition: `Counter` +- `CounterId = captured_points` +- `Compare = GREATER_THAN_OR_EQUAL` +- `Value = 3` + +Then attach a follow-up effect, such as `SetMinigamePhase` or `EndMinigame`. diff --git a/docs/minigame-definition.md b/docs/minigame-definition.md new file mode 100644 index 0000000..d5d728a --- /dev/null +++ b/docs/minigame-definition.md @@ -0,0 +1,147 @@ +# Minigame Definition Reference + +Minigame definitions live in `src/main/resources/Server/Minigames/.json`. JSON keys use PascalCase. The filename should match the `Id` field. + +## Identity + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `Id` | String | Yes | none | Unique asset ID. Use underscores instead of spaces. | +| `Name` | String | Yes | none | Display name shown in commands and UI. | +| `Description` | String | No | `""` | Short display description. | +| `Enabled` | Boolean | No | `true` | Whether the minigame can be started. | +| `Debug` | Boolean | No | `false` | Enables verbose minigame debug messages when supported by runtime logic. | +| `GameType` | Enum | No | `GENERIC` | `GENERIC`, `BATTLE`, `PUZZLE`, `RACE`, `CAPTURE`, or `SURVIVAL`. | + +## World Binding + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `WorldId` | String | No | `""` | Optional world identifier. | +| `RequiredGameMode` | String | No | `"adventure"` | Game mode expected for players. | + +## Players + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `MinPlayers` | Integer | No | `1` | Minimum player count. | +| `MaxPlayers` | Integer | No | `16` | Maximum player count. | +| `AllowJoinMidgame` | Boolean | No | `false` | Whether players can join after the game starts. | +| `AllowSpectators` | Boolean | No | `true` | Whether spectators are allowed. | + +## Teams + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `TeamMode` | Enum | No | `SOLO` | `SOLO`, `TEAMS`, or `FREE_FOR_ALL`. | +| `TeamCount` | Integer | No | `0` | Number of teams when team mode is enabled. `0` means automatic. | +| `PlayersPerTeam` | Integer | No | `0` | Maximum players per team. `0` means no explicit limit. | + +## Timing + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `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` | Enables overtime handling. | +| `SuddenDeathEnabled` | Boolean | No | `false` | Enables sudden-death handling. | + +## Win Condition + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `WinCondition` | Enum | No | `HIGHEST_SCORE` | How the winner is determined. | +| `MapSelectionMode` | Enum | No | `VOTE` | How a waiting session chooses a discovered map: `VOTE` or `RANDOM`. | +| `FriendlyFireKillScoreMode` | Enum | No | `NO_POINTS` | How same-team player kills affect kill scoring: `OFF`, `LOSES_POINTS`, or `NO_POINTS`. | + +Supported values: + +| Value | Description | +|-------|-------------| +| `HIGHEST_SCORE` | Highest score at the end wins. | +| `FIRST_TO_SCORE` | First player or team to a target score wins. | +| `LAST_ALIVE` | Last active player or team wins. | +| `OBJECTIVE_COMPLETE` | Objective completion determines the winner. | +| `CUSTOM` | Trigger logic determines the winner. | + +## World Rules + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `AllowPvp` | Boolean | No | `false` | Whether players can damage each other. | +| `AllowBlockBreaking` | Boolean | No | `false` | Whether blocks can be broken. | +| `AllowBlockPlacing` | Boolean | No | `false` | Whether blocks can be placed. | + +## Inventory + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `SavePlayerInventory` | Boolean | No | `true` | Save player inventory on join. | +| `RestorePlayerInventory` | Boolean | No | `true` | Restore player inventory when the game ends. | +| `ResetOnEnd` | Boolean | No | `true` | Reset runtime state when the game ends. | + +## Items + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `StartItems` | `ItemStackConfig[]` | No | `[]` | Items given to players at game start. | +| `Rewards` | `RewardConfig[]` | No | `[]` | Rewards distributed at game end. | + +## ItemStackConfig + +```json +{ + "ItemId": "event_fishing_rod", + "Amount": 1 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ItemId` | String | Yes | Hytale item ID. | +| `Amount` | Integer | Yes | Stack amount. | + +## RewardConfig + +```json +{ + "Target": "winner", + "Items": [ + { "ItemId": "gold_coin", "Amount": 25 } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `Target` | String | Yes | `winner`, `participant`, `team_winner`, or a team ID. | +| `Items` | `ItemStackConfig[]` | Yes | Items to grant. | + +## Minimal Example + +```json +{ + "Id": "Quick_Brawl", + "Name": "Quick Brawl", + "Enabled": true, + "MinPlayers": 2, + "MaxPlayers": 8, + "TeamMode": "SOLO", + "WinCondition": "LAST_ALIVE", + "RoundLengthSeconds": 120, + "AllowPvp": true +} +``` + +## Validation + +Current validation is definition-focused: + +- `Name` must not be blank. +- `MinPlayers` must be greater than or equal to 1. +- `MaxPlayers` must be greater than or equal to `MinPlayers`. +- `RoundLengthSeconds` must be greater than 0. +- `CountdownSeconds` must be greater than or equal to 0. + +The old custom `MinigameAreaVolume` validation requirement no longer applies. Minigame areas are normal Hytale trigger volumes configured in the editor. diff --git a/docs/tags.md b/docs/tags.md new file mode 100644 index 0000000..249c2d9 --- /dev/null +++ b/docs/tags.md @@ -0,0 +1,103 @@ +# Trigger Volume Tags + +core-minigames uses normal Hytale trigger-volume tags for map discovery, runtime routing, team spawns, kill scoring, and runtime signals. Tag keys and values are case-sensitive. + +## Map And Runtime Tags + +These tags identify which minigame map or variant a volume belongs to. + +| Tag | Required | Values | Used By | +|-----|----------|--------|---------| +| `MinigameId` | Yes for discovered map volumes | A loaded `MinigameDefinition` ID, such as `KOTH` | Map discovery, runtime routing, queue start, kill scoring, respawns, signals | +| `MapId` | Yes for discovered map volumes | Map or arena ID, such as `Castle` | Map discovery, voting, runtime routing | +| `VariantId` | No | Variant ID, such as `Night` or `AltA` | Runtime routing and random variant selection | +| `MapRole` | No | `MainArena` | Marks the primary arena volume for a discovered map candidate | + +Minimum map candidate: + +```text +MinigameId=KOTH +MapId=Castle +MapRole=MainArena +``` + +Variant candidate: + +```text +MinigameId=KOTH +MapId=Castle +VariantId=Night +MapRole=MainArena +``` + +Overlapping volumes are allowed. Volumes with the same `MinigameId`, `MapId`, and `VariantId` are grouped into one discovered map candidate. + +## Spawn Tags + +Team spawn volumes are normal trigger volumes used as teleport destinations by `RespawnPlayer`. + +| Tag | Required | Values | Used By | +|-----|----------|--------|---------| +| `MinigameId` | Recommended | Minigame ID | Filters spawns to the current runtime's minigame | +| `MapId` | Recommended | Map ID | Filters spawns to the current runtime's map | +| `VariantId` | No | Variant ID | Filters spawns to the current runtime's variant when present | +| `SpawnRole` | Yes | `TeamSpawn` | Marks the volume as a team spawn candidate | +| `TeamId` | Yes | Team ID, such as `red` or `blue` | Selects spawns for the player's assigned team | + +Example: + +```text +MinigameId=KOTH +MapId=Castle +SpawnRole=TeamSpawn +TeamId=red +``` + +`RespawnPlayer` chooses a random matching team spawn when no explicit `DestinationId` and no player checkpoint are available. + +## Signal Tags + +Signal tags let runtime state changes fire normal Hytale trigger-volume logic by toggling a tag on matching volumes. + +| Tag | Required | Values | Behavior | +|-----|----------|--------|----------| +| `SignalRoundStart` | No | Round number, such as `1` | Fires once when that round starts. | +| `SignalRoundTick` | No | Round number, such as `1` | Fires repeatedly while that round is active. | +| `SignalPhaseTick` | No | `MinigamePhase` enum name, such as `ACTIVE` | Fires repeatedly while the runtime is in that phase. | +| `SignalTickDelay` | No | Seconds, such as `1` or `0.5` | Delay between repeated signal fires. Minimum effective delay is 50ms. | +| `SignalOnTimerExpire` | No | Timer name from `StartTimer` | Fires once when the named timer expires. | +| `MinigameSignal` | Internal | `0` or `1` | Written by the runtime signal service to trigger Hytale `TAG_ADDED` behavior. Do not configure manually. | + +Signal volumes must also have `MinigameId=`. Timer names starting with `$` are internal runtime timers and do not fire trigger-volume signal tags. + +Round-start example: + +```text +MinigameId=KOTH +MapId=Castle +SignalRoundStart=1 +``` + +Active-phase tick example: + +```text +MinigameId=KOTH +MapId=Castle +SignalPhaseTick=ACTIVE +SignalTickDelay=1 +``` + +Timer-expire example: + +```text +MinigameId=KOTH +MapId=Castle +SignalOnTimerExpire=overtime_warning +``` + +## Validation Notes + +- A volume with `MapId` but no `MinigameId` produces a warning and is ignored for map discovery. +- A discovered map candidate without `MapRole=MainArena` produces a warning but does not stop the minigame from loading. +- Missing map discovery is a warning, not a startup failure. +- Duplicate or overlapping variants are allowed when they intentionally represent alternate zones for the same map. diff --git a/docs/volumes.md b/docs/volumes.md new file mode 100644 index 0000000..224d044 --- /dev/null +++ b/docs/volumes.md @@ -0,0 +1,122 @@ +# Volumes + +Minigame areas now use Hytale's built-in trigger volumes. The mod no longer defines custom volume types such as `MinigameAreaVolume`, `JoinQueueVolume`, or `ScoreVolume`. + +This is intentional: the area, shape, enter/exit behavior, tags, and trigger-effect asset wiring belong to Hytale's trigger-volume system. core-minigames adds minigame-specific conditions and effects that can be attached to those trigger volumes. + +## Defining Minigame Maps + +1. Create a normal Hytale trigger volume in the editor. +2. Shape it around the arena or interaction area. +3. Tag it with `MinigameId=` and `MapId=`. +4. Add `VariantId=` when the same map can randomly use overlapping alternate zones. +5. Add `MapRole=MainArena` to the primary arena volume for each map or variant. +6. Attach trigger effects and conditions for the relevant minigame. +7. Use the `MinigameId` dropdown to select the `MinigameDefinition` asset. + +There is no required root `MinigameAreaVolume` type. If validation needs an arena boundary later, it should inspect Hytale trigger-volume metadata instead of requiring a custom minigame volume class. + +Map candidates are discovered from trigger-volume tags. See [Trigger Volume Tags](tags.md) for the full tag reference. + +Overlapping volumes are valid. Runtime routing uses the selected `MinigameId`, `MapId`, and `VariantId`, so the same Hytale build can host multiple arena definitions. + +## Common Patterns + +### Start Zone + +Use a normal trigger volume at an entrance, sign, NPC, or lobby pad. + +Attach: + +- Effect: `JoinMinigameQueue` +- Field: `MinigameId = ` + +For exits or cancel pads: + +- Effect: `LeaveMinigameQueue` +- Field: `MinigameId = ` + +For voting buttons or pads: + +- Effect: `VoteForMap` +- Field: `MapId = `, or leave blank to use the volume's `MapId` tag. + +To start the queued match: + +- Effect: `StartQueuedMinigame` +- Field: `MinigameId = ` + +Optionally guard it with: + +- Condition: `MinigamePhase` +- Field: `Phase = WAITING_FOR_PLAYERS` + +### Score Zone + +Use a normal trigger volume around the scoring area. + +Attach: + +- Condition: `PlayerInMinigame` +- Effect: `ModifyPlayerScore` +- Fields: `Operation = ADD`, `Amount = ` + +If the score is team-based, use `ModifyTeamScore` instead. + +### Phase Gate + +Use Hytale's normal volume behavior for the area and guard attached effects with: + +- Condition: `MinigamePhase` +- Field: `Phase = ACTIVE` + +### Team Spawns + +Use normal trigger volumes as spawn markers. The volume position is the teleport destination. + +Tag each team spawn volume with: + +- `MinigameId=` +- `MapId=` +- optional `VariantId=` +- `SpawnRole=TeamSpawn` +- `TeamId=` + +`RespawnPlayer` resolves destinations in this order: + +1. The effect's explicit `DestinationId`. +2. The player's stored checkpoint from `SetSpawnPoint`. +3. A random matching team spawn volume for the player's `TeamId`. + +This means checkpoint-based games can still override spawns per player, while team arena games can rely on tagged team spawn volumes. + +### Counters + +Use a trigger volume to increment a named runtime counter: + +- Effect: `ModifyCounter` +- Fields: `CounterId = `, `Operation = ADD`, `Amount = 1` + +Then use the `Counter` condition to branch once a threshold is reached. + +### Kill Scoring + +Use a trigger volume covering the arena or scoring zone and attach: + +- Effect: `AwardKillScore` +- Fields: `TargetIds = ["Player"]` or NPC role ids such as `["fox"]`, `Points = `, `AwardTarget = PLAYER` + +The death system checks this volume when an entity dies inside it. It routes by the volume's `MinigameId`, `MapId`, and optional `VariantId` tags, so overlapping arenas can have different kill scoring rules. + +### Pickup Spawners + +Use a small trigger volume at the pickup location and attach: + +- Effect: `SpawnItem` +- Fields: `ItemId = `, `Amount = `, `RespawnDelaySeconds = ` + +The item spawns at the volume position plus optional `OffsetX`, `OffsetY`, and `OffsetZ`. Attach the effect to a `TICK` trigger when the pickup should automatically respawn after being collected or despawning. If the same volume has multiple item spawners, set a different `SpawnKey` for each one. + +## Reusable Effect Assets + +For repeated logic, define a Hytale trigger effect asset and attach it to multiple trigger volumes. The minigame trigger condition/effect entries are normal codec types, so they can be reused the same way Hytale's built-in trigger-volume effects are reused. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1768768 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,37 @@ +# The current version of your project. Please use semantic versioning! +version=0.5.3 + +# The group ID used for maven publishing. Usually the same as your package name +# but not the same as your plugin group! +maven_group=net.kewwbec + +# The version of Java used by your plugin. The game is built on Java 21 but +# actually runs on Java 25. +java_version=25 + +# Determines if your plugin should also be loaded as an asset pack. If your +# pack contains assets, or you intend to use the in-game asset editor, you +# want this to be true. +includes_pack=true + +# The release channel your plugin should be built and ran against. This is +# usually release or pre-release. You can verify your settings in the +# official launcher. +patchline=release + +# The exact Hytale build to compile against. Use the build string from the +# launcher (format YYYY.MM.DD-) so Gradle pulls the matching server jar +# for your selected patchline. +hytale_build=0.5.3 + +# Determines if the development server should also load mods from the user's +# standard mods folder. This lets you test mods by installing them where a +# normal player would, instead of adding them as dependencies or adding them +# to the development server manually. +load_user_mods=false + +# If Hytale was installed to a custom location, you must set the home path +# manually. You may also want to use a custom path if you are building in +# a non-standard environment like a build server. The home path should +# the folder that contains the install and UserData folder. +# hytale_home=./test-file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/.gitkeep b/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..cad5b32 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'core-minigames' diff --git a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java new file mode 100644 index 0000000..964b83e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java @@ -0,0 +1,287 @@ +package net.kewwbec.minigames; + +import com.hypixel.hytale.assetstore.AssetRegistry; +import com.hypixel.hytale.assetstore.map.DefaultAssetMap; +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.asset.TriggerEffectAsset; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.asset.HytaleAssetStore; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import net.kewwbec.minigames.adapter.HytaleServerAdapters; +import net.kewwbec.minigames.command.MinigameCommand; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService; +import net.kewwbec.minigames.service.DefaultMinigameService; +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 net.kewwbec.minigames.system.MinigameKillScoreSystem; +import net.kewwbec.minigames.volume.condition.CounterCondition; +import net.kewwbec.minigames.volume.condition.CurrentRoundCondition; +import net.kewwbec.minigames.volume.condition.MinigamePhaseCondition; +import net.kewwbec.minigames.volume.condition.ObjectiveStatusCondition; +import net.kewwbec.minigames.volume.condition.PlayerInMinigameCondition; +import net.kewwbec.minigames.volume.condition.PlayerLivesCondition; +import net.kewwbec.minigames.volume.condition.PlayerScoreCondition; +import net.kewwbec.minigames.volume.condition.PlayerStatusCondition; +import net.kewwbec.minigames.volume.condition.TeamCondition; +import net.kewwbec.minigames.volume.condition.TeamScoreCondition; +import net.kewwbec.minigames.volume.condition.TimerElapsedCondition; +import net.kewwbec.minigames.volume.condition.WinConditionMetCondition; +import net.kewwbec.minigames.volume.effect.AssignTeamEffect; +import net.kewwbec.minigames.volume.effect.AwardKillScoreEffect; +import net.kewwbec.minigames.volume.effect.ClearInventoryEffect; +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.RespawnPlayerEffect; +import net.kewwbec.minigames.volume.effect.ResetMinigameEffect; +import net.kewwbec.minigames.volume.effect.ResetScoresEffect; +import net.kewwbec.minigames.volume.effect.RestoreInventoryEffect; +import net.kewwbec.minigames.volume.effect.SaveInventoryEffect; +import net.kewwbec.minigames.volume.effect.SendMinigameMessageEffect; +import net.kewwbec.minigames.volume.effect.SetMinigamePhaseEffect; +import net.kewwbec.minigames.volume.effect.SetObjectiveStatusEffect; +import net.kewwbec.minigames.volume.effect.SetPlayerStatusEffect; +import net.kewwbec.minigames.volume.effect.SetRoundEffect; +import net.kewwbec.minigames.volume.effect.SetSpawnPointEffect; +import net.kewwbec.minigames.volume.effect.JoinMinigameQueueEffect; +import net.kewwbec.minigames.volume.effect.SpawnItemEffect; +import net.kewwbec.minigames.volume.effect.StartMinigameEffect; +import net.kewwbec.minigames.volume.effect.StartQueuedMinigameEffect; +import net.kewwbec.minigames.volume.effect.StartTimerEffect; +import net.kewwbec.minigames.volume.effect.StopTimerEffect; +import net.kewwbec.minigames.volume.effect.VoteForMapEffect; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.nio.file.Path; + +public final class MinigameCorePlugin extends JavaPlugin { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static MinigameCorePlugin instance; + private MinigameServices services; + + public MinigameCorePlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Override + protected void setup() { + instance = this; + getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem()); + registerTriggerVolumeEffectTypes(); + registerTriggerVolumeConditionTypes(); + + AssetRegistry.register( + HytaleAssetStore.builder(MinigameDefinition.class, new DefaultAssetMap()) + .setPath("Minigames") + .setCodec(MinigameDefinition.CODEC) + .setKeyFunction(MinigameDefinition::getId) + .loadsAfter(TriggerEffectAsset.class) + .build() + ); + + var dataRoot = Path.of("minigame-core"); + var runtimeService = new DefaultMinigameRuntimeService(); + var minigameService = new DefaultMinigameService(dataRoot, runtimeService); + var adapters = new HytaleServerAdapters(); + var signals = new MinigameVolumeSignalService(); + runtimeService.setSignalService(signals); + runtimeService.setPlayerAdapter(adapters); + this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), new MinigameQueueService(), signals); + registerTriggerVolumeAssetSources(); + + this.getCommandRegistry().registerCommand(new MinigameCommand(services)); + LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount() + + " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects."); + } + + @Override + public void shutdown() { + if (services != null) { + services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown"); + services.signals().shutdown(); + } + services = null; + instance = null; + } + + @Nullable + public static MinigameServices getServices() { + return instance != null ? instance.services : null; + } + + private void registerTriggerVolumeEffectTypes() { + var triggerVolumes = TriggerVolumesPlugin.get(); + registerTriggerVolumeEffectType(triggerVolumes, StartMinigameEffect.TYPE_ID, StartMinigameEffect.class, StartMinigameEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, EndMinigameEffect.TYPE_ID, EndMinigameEffect.class, EndMinigameEffect.CODEC); + 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, ModifyCounterEffect.TYPE_ID, ModifyCounterEffect.class, ModifyCounterEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID, ModifyPlayerLivesEffect.class, ModifyPlayerLivesEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, SetPlayerStatusEffect.TYPE_ID, SetPlayerStatusEffect.class, SetPlayerStatusEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID, JoinMinigameQueueEffect.class, JoinMinigameQueueEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, LeaveMinigameQueueEffect.TYPE_ID, LeaveMinigameQueueEffect.class, LeaveMinigameQueueEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, VoteForMapEffect.TYPE_ID, VoteForMapEffect.class, VoteForMapEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID, StartQueuedMinigameEffect.class, StartQueuedMinigameEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, SpawnItemEffect.TYPE_ID, SpawnItemEffect.class, SpawnItemEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, SetRoundEffect.TYPE_ID, SetRoundEffect.class, SetRoundEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, StartTimerEffect.TYPE_ID, StartTimerEffect.class, StartTimerEffect.CODEC); + 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, 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); + registerTriggerVolumeEffectType(triggerVolumes, RestoreInventoryEffect.TYPE_ID, RestoreInventoryEffect.class, RestoreInventoryEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, ClearInventoryEffect.TYPE_ID, ClearInventoryEffect.class, ClearInventoryEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, SetObjectiveStatusEffect.TYPE_ID, SetObjectiveStatusEffect.class, SetObjectiveStatusEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID, ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC); + } + + private void registerTriggerVolumeEffectType( + @Nullable TriggerVolumesPlugin triggerVolumes, + @Nonnull String typeId, + @Nonnull Class clazz, + @Nonnull BuilderCodec codec + ) { + if (TriggerEffect.CODEC.getCodecFor(typeId) != null) { + return; + } + + if (triggerVolumes != null) { + triggerVolumes.registerEffectType(typeId, clazz, codec); + } else { + TriggerEffect.CODEC.register(typeId, clazz, codec); + } + } + + private void registerTriggerVolumeConditionTypes() { + registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC); + registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC); + registerTriggerVolumeConditionType(PlayerStatusCondition.TYPE_ID, PlayerStatusCondition.class, PlayerStatusCondition.CODEC); + registerTriggerVolumeConditionType(PlayerScoreCondition.TYPE_ID, PlayerScoreCondition.class, PlayerScoreCondition.CODEC); + registerTriggerVolumeConditionType(TeamScoreCondition.TYPE_ID, TeamScoreCondition.class, TeamScoreCondition.CODEC); + registerTriggerVolumeConditionType(CounterCondition.TYPE_ID, CounterCondition.class, CounterCondition.CODEC); + registerTriggerVolumeConditionType(CurrentRoundCondition.TYPE_ID, CurrentRoundCondition.class, CurrentRoundCondition.CODEC); + registerTriggerVolumeConditionType(PlayerLivesCondition.TYPE_ID, PlayerLivesCondition.class, PlayerLivesCondition.CODEC); + registerTriggerVolumeConditionType(TeamCondition.TYPE_ID, TeamCondition.class, TeamCondition.CODEC); + registerTriggerVolumeConditionType(TimerElapsedCondition.TYPE_ID, TimerElapsedCondition.class, TimerElapsedCondition.CODEC); + registerTriggerVolumeConditionType(ObjectiveStatusCondition.TYPE_ID, ObjectiveStatusCondition.class, ObjectiveStatusCondition.CODEC); + registerTriggerVolumeConditionType(WinConditionMetCondition.TYPE_ID, WinConditionMetCondition.class, WinConditionMetCondition.CODEC); + } + + private void registerTriggerVolumeConditionType( + @Nonnull String typeId, + @Nonnull Class clazz, + @Nonnull BuilderCodec codec + ) { + if (TriggerCondition.CODEC.getCodecFor(typeId) == null) { + TriggerCondition.CODEC.register(typeId, clazz, codec); + } + } + + private void registerTriggerVolumeAssetSources() { + var triggerVolumes = TriggerVolumesPlugin.get(); + if (triggerVolumes == null) { + return; + } + + triggerVolumes.registerAssetSource("MinigameDefinition", () -> MinigameDefinition.getAssetMap().getAssetMap().keySet()); + registerMinigameIdAssetField(triggerVolumes, StartMinigameEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, EndMinigameEffect.TYPE_ID); + 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, ModifyCounterEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SetPlayerStatusEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, LeaveMinigameQueueEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, VoteForMapEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SpawnItemEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SetRoundEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, StartTimerEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, StopTimerEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SetSpawnPointEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, RespawnPlayerEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SendMinigameMessageEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, AssignTeamEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SaveInventoryEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, RestoreInventoryEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, ClearInventoryEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, SetObjectiveStatusEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, MinigamePhaseCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, PlayerStatusCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, PlayerScoreCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, TeamScoreCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, CounterCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, CurrentRoundCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, PlayerLivesCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, TeamCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, TimerElapsedCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, ObjectiveStatusCondition.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, WinConditionMetCondition.TYPE_ID); + } + + private void registerMinigameIdAssetField(@Nonnull TriggerVolumesPlugin triggerVolumes, @Nonnull String typeId) { + triggerVolumes.registerAssetField(typeId, "MinigameId", "MinigameDefinition"); + } + + 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(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)) { + count++; + } + } + return 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; + } +} diff --git a/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java b/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java new file mode 100644 index 0000000..4524ff0 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java @@ -0,0 +1,54 @@ +package net.kewwbec.minigames.adapter; + +import com.hypixel.hytale.server.core.Message; + +import java.util.Map; + +public final class HytaleAdapters { + private HytaleAdapters() { + } + + public interface TriggerVolumeAdapter { + void registerVolumeHandler(String volumeTypeId, VolumeHandler handler); + } + + public interface VolumeHandler { + void handle(String volumeId, String eventId, Map context); + } + + public interface PlayerAdapter { + boolean isOnline(String playerId); + void sendMessage(String playerId, Message message); + String getDisplayName(String playerId); + } + + public interface WorldAdapter { + boolean worldExists(String worldId); + } + + public interface EntityAdapter { + void despawn(String entityId); + } + + public interface InventoryAdapter { + Map saveInventory(String playerId); + void restoreInventory(String playerId, Map inventory); + void clearInventory(String playerId); + } + + public interface ScoreboardAdapter { + void show(String playerId, String title, Map lines); + } + + public interface TeleportAdapter { + void teleport(String playerId, String destinationId); + } + + public interface SoundAdapter { + void play(String playerId, String soundId); + } + + public interface VfxAdapter { + void playAt(String effectId, Map location); + } +} diff --git a/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java new file mode 100644 index 0000000..c93e019 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java @@ -0,0 +1,283 @@ +package net.kewwbec.minigames.adapter; + +import com.hypixel.hytale.component.Component; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.RemoveReason; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.math.vector.Transform; +import com.hypixel.hytale.protocol.SoundCategory; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent; +import com.hypixel.hytale.server.core.inventory.InventoryComponent; +import com.hypixel.hytale.server.core.inventory.InventoryUtils; +import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.SoundUtil; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import org.joml.Vector3d; + +import java.util.HashMap; +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 + HytaleAdapters.TriggerVolumeAdapter, + HytaleAdapters.PlayerAdapter, + HytaleAdapters.WorldAdapter, + HytaleAdapters.EntityAdapter, + HytaleAdapters.InventoryAdapter, + HytaleAdapters.ScoreboardAdapter, + HytaleAdapters.TeleportAdapter, + HytaleAdapters.SoundAdapter, + HytaleAdapters.VfxAdapter { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final Map volumeHandlers = new ConcurrentHashMap<>(); + + @Override + public void registerVolumeHandler(String volumeTypeId, HytaleAdapters.VolumeHandler handler) { + volumeHandlers.put(volumeTypeId, handler); + } + + public void dispatchVolumeEvent(String volumeTypeId, String volumeId, String eventId, Map context) { + var handler = volumeHandlers.get(volumeTypeId); + if (handler != null) { + handler.handle(volumeId, eventId, context); + } + } + + @Override + public boolean isOnline(String playerId) { + return findPlayer(playerId).isPresent(); + } + + @Override + public void sendMessage(String playerId, Message message) { + findPlayer(playerId).ifPresent(player -> player.sendMessage(message)); + } + + @Override + public String getDisplayName(String playerId) { + return findPlayer(playerId).map(PlayerRef::getUsername).orElse(playerId); + } + + @Override + public boolean worldExists(String worldId) { + var universe = Universe.get(); + return universe != null && universe.getWorld(worldId) != null; + } + + @Override + public void despawn(String entityId) { + parseUuid(entityId).ifPresent(uuid -> { + var universe = Universe.get(); + if (universe == null) { + return; + } + for (World world : universe.getWorlds().values()) { + Ref ref = world.getEntityRef(uuid); + if (ref != null && ref.isValid()) { + runInWorld(world, () -> { + if (ref.isValid()) { + ref.getStore().removeEntity(ref, RemoveReason.REMOVE); + } + }); + return; + } + } + }); + } + + @Override + public Map saveInventory(String playerId) { + return withPlayerRef(playerId, ref -> { + var store = ref.getStore(); + var snapshot = new HashMap(); + copyInventory(snapshot, "armor", store.getComponent(ref, InventoryComponent.Armor.getComponentType())); + copyInventory(snapshot, "hotbar", store.getComponent(ref, InventoryComponent.Hotbar.getComponentType())); + copyInventory(snapshot, "utility", store.getComponent(ref, InventoryComponent.Utility.getComponentType())); + copyInventory(snapshot, "storage", store.getComponent(ref, InventoryComponent.Storage.getComponentType())); + copyInventory(snapshot, "backpack", store.getComponent(ref, InventoryComponent.Backpack.getComponentType())); + return Map.copyOf(snapshot); + }).orElseGet(Map::of); + } + + @Override + public void restoreInventory(String playerId, Map inventory) { + withPlayerRef(playerId, ref -> { + var store = ref.getStore(); + restoreInventory(store, ref, "armor", inventory, InventoryComponent.Armor.getComponentType()); + restoreInventory(store, ref, "hotbar", inventory, InventoryComponent.Hotbar.getComponentType()); + restoreInventory(store, ref, "utility", inventory, InventoryComponent.Utility.getComponentType()); + restoreInventory(store, ref, "storage", inventory, InventoryComponent.Storage.getComponentType()); + restoreInventory(store, ref, "backpack", inventory, InventoryComponent.Backpack.getComponentType()); + return null; + }); + } + + @Override + public void clearInventory(String playerId) { + withPlayerRef(playerId, ref -> { + InventoryUtils.clear(ref, ref.getStore()); + return null; + }); + } + + @Override + public void show(String playerId, String title, Map lines) { + LOGGER.atInfo().log("Scoreboard adapter is not wired to a Hytale UI API yet: player=%s title=%s lines=%s", playerId, title, lines); + } + + @Override + public void teleport(String playerId, String destinationId) { + findPlayer(playerId).ifPresent(player -> { + var ref = player.getReference(); + if (ref == null || !ref.isValid()) { + return; + } + var currentWorld = ref.getStore().getExternalData().getWorld(); + var destination = parseDestination(destinationId, currentWorld); + if (destination == null) { + LOGGER.atWarning().log("Invalid teleport destination '%s' for player %s", destinationId, playerId); + return; + } + runInWorld(currentWorld, () -> { + if (ref.isValid()) { + ref.getStore().putComponent(ref, Teleport.getComponentType(), Teleport.createForPlayer(destination.world(), destination.transform())); + } + }); + }); + } + + @Override + public void play(String playerId, String soundId) { + findPlayer(playerId).ifPresent(player -> { + int soundIndex = SoundEvent.getAssetMap().getIndex(soundId); + SoundUtil.playSoundEvent2dToPlayer(player, soundIndex, SoundCategory.UI); + }); + } + + @Override + public void playAt(String effectId, Map location) { + LOGGER.atInfo().log("VFX adapter is not wired to a Hytale particle/effect API yet: effect=%s location=%s", effectId, location); + } + + private Optional findPlayer(String playerId) { + var universe = Universe.get(); + if (universe == null || playerId == null || playerId.isBlank()) { + return Optional.empty(); + } + var uuid = parseUuid(playerId); + if (uuid.isPresent()) { + return Optional.ofNullable(universe.getPlayer(uuid.get())); + } + return universe.getPlayers().stream() + .filter(player -> player.getUsername().equalsIgnoreCase(playerId)) + .findFirst(); + } + + private Optional parseUuid(String value) { + try { + return Optional.of(UUID.fromString(value)); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + private Optional withPlayerRef(String playerId, WorldOperation operation) { + return findPlayer(playerId).flatMap(player -> { + var ref = player.getReference(); + if (ref == null || !ref.isValid()) { + return Optional.empty(); + } + var world = ref.getStore().getExternalData().getWorld(); + if (world.isInThread()) { + return Optional.ofNullable(operation.run(ref)); + } + return Optional.ofNullable(CompletableFuture.supplyAsync(() -> operation.run(ref), world).join()); + }); + } + + private void runInWorld(World world, Runnable action) { + if (world.isInThread()) { + action.run(); + } else { + CompletableFuture.runAsync(action, world).join(); + } + } + + private void copyInventory(Map snapshot, String key, InventoryComponent component) { + if (component != null) { + snapshot.put(key, component.clone()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void restoreInventory( + com.hypixel.hytale.component.Store store, + Ref ref, + String key, + Map inventory, + ComponentType componentType + ) { + Object value = inventory.get(key); + if (value instanceof Component component) { + store.putComponent(ref, (ComponentType) componentType, (Component) component); + } + } + + private Destination parseDestination(String destinationId, World currentWorld) { + if (destinationId == null || destinationId.isBlank()) { + return null; + } + + var parts = destinationId.split(","); + if (parts.length == 3 || parts.length == 6) { + return coordinates(currentWorld, parts, 0); + } + if (parts.length == 4 || parts.length == 7) { + var world = Universe.get().getWorld(parts[0]); + return world == null ? null : coordinates(world, parts, 1); + } + if (destinationId.toLowerCase(Locale.ROOT).startsWith("world:")) { + var world = Universe.get().getWorld(destinationId.substring("world:".length())); + return world == null ? null : new Destination(world, new Transform()); + } + return null; + } + + private Destination coordinates(World world, String[] parts, int offset) { + try { + double x = Double.parseDouble(parts[offset]); + double y = Double.parseDouble(parts[offset + 1]); + double z = Double.parseDouble(parts[offset + 2]); + var rotation = new Rotation3f(); + if (parts.length - offset >= 6) { + rotation.set( + Float.parseFloat(parts[offset + 3]), + Float.parseFloat(parts[offset + 4]), + Float.parseFloat(parts[offset + 5])); + } + return new Destination(world, new Transform(new Vector3d(x, y, z), rotation)); + } catch (NumberFormatException ignored) { + return null; + } + } + + private record Destination(World world, Transform transform) { + } + + @FunctionalInterface + private interface WorldOperation { + T run(Ref ref); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java b/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java new file mode 100644 index 0000000..541ccab --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java @@ -0,0 +1,33 @@ +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 { + public MinigameCommand(MinigameServices services) { + super("minigame", "minigames.command.root.description"); + this.setPermissionGroups("hytale:WorldEditor"); + + addSubCommand(new MinigameCreateCommand(services)); + addSubCommand(new MinigameEditCommand(services)); + addSubCommand(new MinigameDeleteCommand(services)); + addSubCommand(new MinigameEnableCommand(services)); + addSubCommand(new MinigameDisableCommand(services)); + addSubCommand(new MinigameStartCommand(services)); + 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)); + addSubCommand(new MinigameVoteCommand(services)); + addSubCommand(new MinigameJoinCommand(services)); + addSubCommand(new MinigameLeaveCommand(services)); + addSubCommand(new MinigameSpectateCommand(services)); + addSubCommand(new MinigameVolumeCommand(services)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameCreateCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameCreateCommand.java new file mode 100644 index 0000000..83e13f0 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameCreateCommand.java @@ -0,0 +1,40 @@ +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.model.MinigameDefinition; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameCreateCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + private final RequiredArg nameArg; + + public MinigameCreateCommand(MinigameServices services) { + super("create", "minigames.command.spec.create.description"); + this.services = services; + 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 id = ctx.get(idArg); + String name = ctx.get(nameArg); + + 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())); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameDeleteCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameDeleteCommand.java new file mode 100644 index 0000000..3fd4e7b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameDeleteCommand.java @@ -0,0 +1,38 @@ +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 MinigameDeleteCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameDeleteCommand(MinigameServices services) { + super("delete", "minigames.command.spec.delete.description", true); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.delete.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; + } + + 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)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameDisableCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameDisableCommand.java new file mode 100644 index 0000000..e4a5880 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameDisableCommand.java @@ -0,0 +1,34 @@ +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 MinigameDisableCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameDisableCommand(MinigameServices services) { + super("disable", "minigames.command.spec.disable.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.disable.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; + } + + services.minigames().setEnabled(id, false); + ctx.sendMessage(Message.translation("minigames.command.disable.success").param("id", id)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameEditCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameEditCommand.java new file mode 100644 index 0000000..4c8bc00 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameEditCommand.java @@ -0,0 +1,33 @@ +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 MinigameEditCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + 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); + } + + @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", "edit")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameEnableCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameEnableCommand.java new file mode 100644 index 0000000..c4b527a --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameEnableCommand.java @@ -0,0 +1,34 @@ +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 MinigameEnableCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameEnableCommand(MinigameServices services) { + super("enable", "minigames.command.spec.enable.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.enable.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; + } + + services.minigames().setEnabled(id, true); + ctx.sendMessage(Message.translation("minigames.command.enable.success").param("id", id)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameExportCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameExportCommand.java new file mode 100644 index 0000000..a472433 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameExportCommand.java @@ -0,0 +1,33 @@ +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 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameImportCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameImportCommand.java new file mode 100644 index 0000000..f315b4a --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameImportCommand.java @@ -0,0 +1,26 @@ +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 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameInfoCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameInfoCommand.java new file mode 100644 index 0000000..a96560e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameInfoCommand.java @@ -0,0 +1,53 @@ +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.model.MinigameDefinition; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameInfoCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameInfoCommand(MinigameServices services) { + super("info", "minigames.command.spec.info.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.info.arg.id", ArgTypes.STRING); + } + + @Override + protected void executeSync(@Nonnull CommandContext ctx) { + String id = ctx.get(idArg); + var def = services.minigames().definition(id); + + if (def.isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id)); + return; + } + + MinigameDefinition d = def.get(); + boolean running = !services.runtime().runtimes(id).isEmpty(); + + ctx.sendMessage(Message.translation("minigames.command.info.header") + .param("id", d.getId()) + .param("name", d.getName()) + .param("description", d.getDescription().isBlank() ? "-" : d.getDescription()) + .param("enabled", d.isEnabled()) + .param("running", running) + .param("gameType", d.getGameType().name()) + .param("worldId", d.getWorldId().isBlank() ? "-" : d.getWorldId()) + .param("minPlayers", d.getMinPlayers()) + .param("maxPlayers", d.getMaxPlayers()) + .param("teamMode", d.getTeamMode().name()) + .param("winCondition", d.getWinCondition().name()) + .param("roundLength", d.getRoundLengthSeconds()) + .param("allowJoinMidgame", d.isAllowJoinMidgame()) + .param("allowSpectators", d.isAllowSpectators()) + .param("allowPvp", d.isAllowPvp())); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java new file mode 100644 index 0000000..aac2a78 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java @@ -0,0 +1,44 @@ +package net.kewwbec.minigames.command.sub; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameJoinCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + private final OptionalArg playerArg; + + public MinigameJoinCommand(MinigameServices services) { + super("join", "minigames.command.spec.join.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.join.arg.id", ArgTypes.STRING); + this.playerArg = withOptionalArg("player", "minigames.command.spec.join.arg.player", ArgTypes.PLAYER_REF); + } + + @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; + } + + PlayerRef target = ctx.provided(playerArg) ? ctx.get(playerArg) : ArgTypes.PLAYER_REF.processedGet(ctx.sender(), ctx, playerArg); + if (target == null) { + ctx.sendMessage(Message.translation("minigames.command.error.player_required")); + return; + } + + services.queue().join(id, target.getUuid().toString()); + ctx.sendMessage(Message.raw("[Minigames] Joined queue for " + id + ".")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameLeaveCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameLeaveCommand.java new file mode 100644 index 0000000..2b715c5 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameLeaveCommand.java @@ -0,0 +1,33 @@ +package net.kewwbec.minigames.command.sub; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameLeaveCommand extends CommandBase { + private final MinigameServices services; + private final OptionalArg playerArg; + + public MinigameLeaveCommand(MinigameServices services) { + super("leave", "minigames.command.spec.leave.description"); + this.services = services; + this.playerArg = withOptionalArg("player", "minigames.command.spec.leave.arg.player", ArgTypes.PLAYER_REF); + } + + @Override + protected void executeSync(@Nonnull CommandContext ctx) { + PlayerRef target = ctx.provided(playerArg) ? ctx.get(playerArg) : ArgTypes.PLAYER_REF.processedGet(ctx.sender(), ctx, playerArg); + if (target == null) { + ctx.sendMessage(Message.translation("minigames.command.error.player_required")); + return; + } + + ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "leave")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameListCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameListCommand.java new file mode 100644 index 0000000..16a2887 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameListCommand.java @@ -0,0 +1,45 @@ +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.basecommands.CommandBase; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; +import java.util.Collection; + +public final class MinigameListCommand extends CommandBase { + private final MinigameServices services; + + public MinigameListCommand(MinigameServices services) { + super("list", "minigames.command.spec.list.description"); + this.services = services; + } + + @Override + protected void executeSync(@Nonnull CommandContext ctx) { + Collection defs = services.minigames().definitions(); + + if (defs.isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.list.empty")); + return; + } + + Message list = Message.translation("minigames.command.list.header") + .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(); + list.insert("\n").insert( + Message.translation("minigames.command.list.entry") + .param("id", def.getId()) + .param("name", def.getName()) + .param("enabled", def.isEnabled()) + .param("running", running) + ); + } + + ctx.sendMessage(list); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameMapsCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameMapsCommand.java new file mode 100644 index 0000000..7a99f5d --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameMapsCommand.java @@ -0,0 +1,50 @@ +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 com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.component.Store; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameMapsCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameMapsCommand(MinigameServices services) { + super("maps", "minigames.command.spec.maps.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.maps.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; + } + + PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null; + Store store = player != null && player.getReference() != null ? player.getReference().getStore() : null; + var result = services.maps().discover(store, id); + if (result.candidates().isEmpty()) { + ctx.sendMessage(Message.raw("[Minigames] No maps discovered for " + id + ". Run this as a player in the world that owns the trigger volumes.")); + return; + } + + Message message = Message.raw("[Minigames] Maps for " + id + ":"); + for (var candidate : result.candidates()) { + message.insert("\n - " + candidate.displayId() + " volumes=" + candidate.volumeCount() + " mainArena=" + candidate.hasMainArena()); + } + for (var warning : result.warnings()) { + message.insert("\n ! " + warning.translationKey() + " " + warning.params()); + } + ctx.sendMessage(message); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameResetCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameResetCommand.java new file mode 100644 index 0000000..b4d4da2 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameResetCommand.java @@ -0,0 +1,34 @@ +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 MinigameResetCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameResetCommand(MinigameServices services) { + super("reset", "minigames.command.spec.reset.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.reset.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; + } + + services.minigames().reset(id); + ctx.sendMessage(Message.translation("minigames.command.reset.success").param("id", id)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java new file mode 100644 index 0000000..6d9660e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java @@ -0,0 +1,48 @@ +package net.kewwbec.minigames.command.sub; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameSpectateCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + private final OptionalArg playerArg; + + public MinigameSpectateCommand(MinigameServices services) { + super("spectate", "minigames.command.spec.spectate.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.spectate.arg.id", ArgTypes.STRING); + this.playerArg = withOptionalArg("player", "minigames.command.spec.spectate.arg.player", ArgTypes.PLAYER_REF); + } + + @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; + } + + if (services.runtime().runtimes(id).isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.spectate.error.not_running").param("id", id)); + return; + } + + PlayerRef target = ctx.provided(playerArg) ? ctx.get(playerArg) : ArgTypes.PLAYER_REF.processedGet(ctx.sender(), ctx, playerArg); + if (target == null) { + ctx.sendMessage(Message.translation("minigames.command.error.player_required")); + return; + } + + ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "spectate")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameStartCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStartCommand.java new file mode 100644 index 0000000..3bb8a1e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStartCommand.java @@ -0,0 +1,48 @@ +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 com.hypixel.hytale.server.core.universe.PlayerRef; +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 javax.annotation.Nonnull; +import java.util.Optional; + +public final class MinigameStartCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameStartCommand(MinigameServices services) { + super("start", "minigames.command.spec.start.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.start.arg.id", ArgTypes.STRING); + } + + @Override + protected void executeSync(@Nonnull CommandContext ctx) { + String id = ctx.get(idArg); + Optional def = services.minigames().definition(id); + + if (def.isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id)); + return; + } + + if (!def.get().isEnabled()) { + ctx.sendMessage(Message.translation("minigames.command.start.error.disabled").param("id", id)); + return; + } + + PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null; + Store 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)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameStopCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStopCommand.java new file mode 100644 index 0000000..1f04bdd --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStopCommand.java @@ -0,0 +1,46 @@ +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.DefaultArg; +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 MinigameStopCommand extends CommandBase { + private static final String DEFAULT_STOP_REASON = "minigames.runtime.reason.admin_stop"; + + private final MinigameServices services; + private final RequiredArg idArg; + private final DefaultArg reasonArg; + + public MinigameStopCommand(MinigameServices services) { + super("stop", "minigames.command.spec.stop.description", true); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.stop.arg.id", ArgTypes.STRING); + this.reasonArg = withDefaultArg("reason", "minigames.command.spec.stop.arg.reason", + ArgTypes.GREEDY_STRING, DEFAULT_STOP_REASON, "minigames.command.spec.stop.arg.reason.default"); + } + + @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; + } + + if (services.runtime().runtimes(id).isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.stop.error.not_running").param("id", id)); + return; + } + + String reason = ctx.get(reasonArg); + services.minigames().end(id, reason); + ctx.sendMessage(Message.translation("minigames.command.stop.success").param("id", id)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameValidateCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameValidateCommand.java new file mode 100644 index 0000000..9d94e81 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameValidateCommand.java @@ -0,0 +1,52 @@ +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.model.ValidationIssue; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; +import java.util.List; + +public final class MinigameValidateCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + + public MinigameValidateCommand(MinigameServices services) { + super("validate", "minigames.command.spec.validate.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.validate.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; + } + + List issues = services.minigames().validate(id); + + if (issues.isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.validate.success").param("id", id)); + return; + } + + Message report = Message.translation("minigames.command.validate.issues") + .param("id", id) + .param("count", issues.size()); + + for (ValidationIssue issue : issues) { + Message issueMsg = Message.translation(issue.translationKey()); + issue.params().forEach((k, v) -> issueMsg.param(k, String.valueOf(v))); + report.insert("\n - ").insert(issueMsg); + } + + ctx.sendMessage(report); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameVoteCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameVoteCommand.java new file mode 100644 index 0000000..9f933b7 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameVoteCommand.java @@ -0,0 +1,41 @@ +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 com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; + +public final class MinigameVoteCommand extends CommandBase { + private final MinigameServices services; + private final RequiredArg idArg; + private final RequiredArg mapArg; + + public MinigameVoteCommand(MinigameServices services) { + super("vote", "minigames.command.spec.vote.description"); + this.services = services; + this.idArg = withRequiredArg("id", "minigames.command.spec.vote.arg.id", ArgTypes.STRING); + this.mapArg = withRequiredArg("mapId", "minigames.command.spec.vote.arg.mapId", ArgTypes.STRING); + } + + @Override + protected void executeSync(@Nonnull CommandContext ctx) { + String id = ctx.get(idArg); + String mapId = ctx.get(mapArg); + if (services.minigames().definition(id).isEmpty()) { + ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id)); + return; + } + if (!(ctx.sender() instanceof PlayerRef player)) { + ctx.sendMessage(Message.translation("minigames.command.error.player_required")); + return; + } + + services.queue().vote(id, player.getUuid().toString(), mapId); + ctx.sendMessage(Message.raw("[Minigames] Voted for " + mapId + " in " + id + ".")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCommand.java new file mode 100644 index 0000000..0c2ee05 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCommand.java @@ -0,0 +1,14 @@ +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)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCreateCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCreateCommand.java new file mode 100644 index 0000000..8c631ca --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeCreateCommand.java @@ -0,0 +1,27 @@ +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 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeEditCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeEditCommand.java new file mode 100644 index 0000000..4f4ae08 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeEditCommand.java @@ -0,0 +1,26 @@ +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 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeLinkCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeLinkCommand.java new file mode 100644 index 0000000..777336b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeLinkCommand.java @@ -0,0 +1,35 @@ +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 volumeIdArg; + private final RequiredArg 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeTestCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeTestCommand.java new file mode 100644 index 0000000..8474f82 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/volume/MinigameVolumeTestCommand.java @@ -0,0 +1,26 @@ +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 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")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/controlplane/ControlPlaneAdapters.java b/src/main/java/net/kewwbec/minigames/controlplane/ControlPlaneAdapters.java new file mode 100644 index 0000000..7fbac8e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/controlplane/ControlPlaneAdapters.java @@ -0,0 +1,33 @@ +package net.kewwbec.minigames.controlplane; + +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +public final class ControlPlaneAdapters { + private ControlPlaneAdapters() { + } + + public interface ControlPlaneCatalogAdapter { + void syncActivityDefinition(MinigameDefinition definition); + void syncUniverseMap(MinigameDefinition definition); + } + + public interface ControlPlaneResultAdapter { + void reportCompletedAttempt(MinigameRuntime runtime); + void reportMatchResult(MinigameRuntime runtime); + } + + public interface ControlPlaneRewardAdapter { + boolean rewardKeyExists(String rewardKey); + } + + public interface ControlPlanePluginConfigAdapter { + void referenceConfigVersion(String pluginId, String versionId); + } + + public interface ControlPlaneServerRuntimeAdapter { + String serverProfileId(); + void heartbeat(); + void reportPlayerPresence(String playerId, String state); + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/Enums.java b/src/main/java/net/kewwbec/minigames/model/Enums.java new file mode 100644 index 0000000..7a77441 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/Enums.java @@ -0,0 +1,38 @@ +package net.kewwbec.minigames.model; + +public final class Enums { + private Enums() { + } + + public enum GameType { + GENERIC, BATTLE, PUZZLE, RACE, CAPTURE, SURVIVAL + } + + public enum MinigamePhase { + CREATED, WAITING_FOR_PLAYERS, COUNTDOWN, STARTING, ACTIVE, OVERTIME, SUDDEN_DEATH, ENDING, RESETTING, DISABLED + } + + public enum TeamMode { + SOLO, TEAMS, FREE_FOR_ALL + } + + public enum WinCondition { + HIGHEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, CUSTOM + } + + public enum MapSelectionMode { + VOTE, RANDOM + } + + public enum FriendlyFireKillScoreMode { + OFF, LOSES_POINTS, NO_POINTS + } + + public enum PlayerStatus { + QUEUED, WAITING, ACTIVE, SPECTATOR, ELIMINATED, LEFT + } + + public enum ObjectiveStatus { + INACTIVE, ACTIVE, COMPLETE, FAILED + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/ItemStackConfig.java b/src/main/java/net/kewwbec/minigames/model/ItemStackConfig.java new file mode 100644 index 0000000..f05ac1b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/ItemStackConfig.java @@ -0,0 +1,38 @@ +package net.kewwbec.minigames.model; + +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.codec.validation.Validators; + +public class ItemStackConfig { + public static final BuilderCodec CODEC = BuilderCodec.builder(ItemStackConfig.class, ItemStackConfig::new) + .append( + new KeyedCodec<>("ItemId", Codec.STRING), + (cfg, v) -> cfg.itemId = v, + cfg -> cfg.itemId + ) + .addValidator(Validators.nonNull()) + .add() + .append( + new KeyedCodec<>("Amount", Codec.INTEGER), + (cfg, v) -> cfg.amount = v, + cfg -> cfg.amount + ) + .addValidator(Validators.greaterThan(0)) + .add() + .build(); + + protected String itemId; + protected int amount = 1; + + public ItemStackConfig() {} + + public ItemStackConfig(String itemId, int amount) { + this.itemId = itemId; + this.amount = amount; + } + + public String getItemId() { return itemId; } + public int getAmount() { return amount; } +} diff --git a/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java b/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java new file mode 100644 index 0000000..e6cadc8 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java @@ -0,0 +1,363 @@ +package net.kewwbec.minigames.model; + +import com.hypixel.hytale.assetstore.AssetExtraInfo; +import com.hypixel.hytale.assetstore.AssetKeyValidator; +import com.hypixel.hytale.assetstore.AssetRegistry; +import com.hypixel.hytale.assetstore.AssetStore; +import com.hypixel.hytale.assetstore.codec.AssetBuilderCodec; +import com.hypixel.hytale.assetstore.map.DefaultAssetMap; +import com.hypixel.hytale.assetstore.map.JsonAssetWithMap; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.codecs.EnumCodec; +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.TeamMode; +import net.kewwbec.minigames.model.Enums.WinCondition; + +import javax.annotation.Nonnull; + +public class MinigameDefinition implements JsonAssetWithMap> { + + public static final AssetBuilderCodec CODEC = AssetBuilderCodec.builder( + MinigameDefinition.class, + MinigameDefinition::new, + Codec.STRING, + (def, id) -> def.id = id, + def -> def.id, + (asset, data) -> asset.data = data, + asset -> asset.data + ) + .appendInherited( + new KeyedCodec<>("Name", Codec.STRING), + (def, v) -> def.name = v, + def -> def.name, + (def, parent) -> def.name = parent.name + ) + .addValidator(Validators.nonNull()) + .add() + .appendInherited( + new KeyedCodec<>("Description", Codec.STRING), + (def, v) -> def.description = v, + def -> def.description, + (def, parent) -> def.description = parent.description + ) + .add() + .appendInherited( + new KeyedCodec<>("Enabled", Codec.BOOLEAN), + (def, v) -> def.enabled = v, + def -> def.enabled, + (def, parent) -> def.enabled = parent.enabled + ) + .add() + .appendInherited( + new KeyedCodec<>("Debug", Codec.BOOLEAN, false), + (def, v) -> def.debug = v, + def -> def.debug, + (def, parent) -> def.debug = parent.debug + ) + .add() + .appendInherited( + new KeyedCodec<>("GameType", new EnumCodec<>(GameType.class)), + (def, v) -> def.gameType = v, + def -> def.gameType, + (def, parent) -> def.gameType = parent.gameType + ) + .add() + .appendInherited( + new KeyedCodec<>("WorldId", Codec.STRING), + (def, v) -> def.worldId = v, + def -> def.worldId, + (def, parent) -> def.worldId = parent.worldId + ) + .add() + .appendInherited( + new KeyedCodec<>("RequiredGameMode", new EnumCodec<>(GameMode.class)), + (def, v) -> def.requiredGameMode = v, + def -> def.requiredGameMode, + (def, parent) -> def.requiredGameMode = parent.requiredGameMode + ) + .add() + .appendInherited( + new KeyedCodec<>("MinPlayers", Codec.INTEGER), + (def, v) -> def.minPlayers = v, + def -> def.minPlayers, + (def, parent) -> def.minPlayers = parent.minPlayers + ) + .addValidator(Validators.greaterThan(0)) + .add() + .appendInherited( + new KeyedCodec<>("MaxPlayers", Codec.INTEGER), + (def, v) -> def.maxPlayers = v, + def -> def.maxPlayers, + (def, parent) -> def.maxPlayers = parent.maxPlayers + ) + .addValidator(Validators.greaterThan(0)) + .add() + .appendInherited( + new KeyedCodec<>("TeamMode", new EnumCodec<>(TeamMode.class)), + (def, v) -> def.teamMode = v, + def -> def.teamMode, + (def, parent) -> def.teamMode = parent.teamMode + ) + .addValidator(Validators.nonNull()) + .add() + .appendInherited( + new KeyedCodec<>("TeamCount", Codec.INTEGER), + (def, v) -> def.teamCount = v, + def -> def.teamCount, + (def, parent) -> def.teamCount = parent.teamCount + ) + .add() + .appendInherited( + new KeyedCodec<>("PlayersPerTeam", Codec.INTEGER), + (def, v) -> def.playersPerTeam = v, + def -> def.playersPerTeam, + (def, parent) -> def.playersPerTeam = parent.playersPerTeam + ) + .add() + .appendInherited( + new KeyedCodec<>("RoundLengthSeconds", Codec.INTEGER), + (def, v) -> def.roundLengthSeconds = v, + def -> def.roundLengthSeconds, + (def, parent) -> def.roundLengthSeconds = parent.roundLengthSeconds + ) + .addValidator(Validators.greaterThan(0)) + .add() + .appendInherited( + new KeyedCodec<>("TotalRounds", Codec.INTEGER), + (def, v) -> def.totalRounds = v, + def -> def.totalRounds, + (def, parent) -> def.totalRounds = parent.totalRounds + ) + .addValidator(Validators.greaterThan(0)) + .add() + .appendInherited( + new KeyedCodec<>("CountdownSeconds", Codec.INTEGER), + (def, v) -> def.countdownSeconds = v, + def -> def.countdownSeconds, + (def, parent) -> def.countdownSeconds = parent.countdownSeconds + ) + .add() + .appendInherited( + new KeyedCodec<>("OvertimeEnabled", Codec.BOOLEAN), + (def, v) -> def.overtimeEnabled = v, + def -> def.overtimeEnabled, + (def, parent) -> def.overtimeEnabled = parent.overtimeEnabled + ) + .add() + .appendInherited( + new KeyedCodec<>("SuddenDeathEnabled", Codec.BOOLEAN), + (def, v) -> def.suddenDeathEnabled = v, + def -> def.suddenDeathEnabled, + (def, parent) -> def.suddenDeathEnabled = parent.suddenDeathEnabled + ) + .add() + .appendInherited( + new KeyedCodec<>("WinCondition", new EnumCodec<>(WinCondition.class)), + (def, v) -> def.winCondition = v, + def -> def.winCondition, + (def, parent) -> def.winCondition = parent.winCondition + ) + .addValidator(Validators.nonNull()) + .add() + .appendInherited( + new KeyedCodec<>("MapSelectionMode", new EnumCodec<>(MapSelectionMode.class), false), + (def, v) -> def.mapSelectionMode = v, + def -> def.mapSelectionMode, + (def, parent) -> def.mapSelectionMode = parent.mapSelectionMode + ) + .add() + .appendInherited( + new KeyedCodec<>("FriendlyFireKillScoreMode", new EnumCodec<>(FriendlyFireKillScoreMode.class), false), + (def, v) -> def.friendlyFireKillScoreMode = v, + def -> def.friendlyFireKillScoreMode, + (def, parent) -> def.friendlyFireKillScoreMode = parent.friendlyFireKillScoreMode + ) + .add() + .appendInherited( + new KeyedCodec<>("ResetOnEnd", Codec.BOOLEAN), + (def, v) -> def.resetOnEnd = v, + def -> def.resetOnEnd, + (def, parent) -> def.resetOnEnd = parent.resetOnEnd + ) + .add() + .appendInherited( + new KeyedCodec<>("SavePlayerInventory", Codec.BOOLEAN), + (def, v) -> def.savePlayerInventory = v, + def -> def.savePlayerInventory, + (def, parent) -> def.savePlayerInventory = parent.savePlayerInventory + ) + .add() + .appendInherited( + new KeyedCodec<>("RestorePlayerInventory", Codec.BOOLEAN), + (def, v) -> def.restorePlayerInventory = v, + def -> def.restorePlayerInventory, + (def, parent) -> def.restorePlayerInventory = parent.restorePlayerInventory + ) + .add() + .appendInherited( + new KeyedCodec<>("AllowJoinMidgame", Codec.BOOLEAN), + (def, v) -> def.allowJoinMidgame = v, + def -> def.allowJoinMidgame, + (def, parent) -> def.allowJoinMidgame = parent.allowJoinMidgame + ) + .add() + .appendInherited( + new KeyedCodec<>("AllowSpectators", Codec.BOOLEAN), + (def, v) -> def.allowSpectators = v, + def -> def.allowSpectators, + (def, parent) -> def.allowSpectators = parent.allowSpectators + ) + .add() + .appendInherited( + new KeyedCodec<>("AllowPvp", Codec.BOOLEAN), + (def, v) -> def.allowPvp = v, + def -> def.allowPvp, + (def, parent) -> def.allowPvp = parent.allowPvp + ) + .add() + .appendInherited( + new KeyedCodec<>("AllowBlockBreaking", Codec.BOOLEAN), + (def, v) -> def.allowBlockBreaking = v, + def -> def.allowBlockBreaking, + (def, parent) -> def.allowBlockBreaking = parent.allowBlockBreaking + ) + .add() + .appendInherited( + new KeyedCodec<>("AllowBlockPlacing", Codec.BOOLEAN), + (def, v) -> def.allowBlockPlacing = v, + def -> def.allowBlockPlacing, + (def, parent) -> def.allowBlockPlacing = parent.allowBlockPlacing + ) + .add() + .appendInherited( + new KeyedCodec<>("StartItems", new ArrayCodec<>(ItemStackConfig.CODEC, ItemStackConfig[]::new)), + (def, v) -> def.startItems = v, + def -> def.startItems, + (def, parent) -> def.startItems = parent.startItems + ) + .add() + .appendInherited( + new KeyedCodec<>("Rewards", new ArrayCodec<>(RewardConfig.CODEC, RewardConfig[]::new)), + (def, v) -> def.rewards = v, + def -> def.rewards, + (def, parent) -> def.rewards = parent.rewards + ) + .add() + .build(); + + public static final ValidatorCache VALIDATOR_CACHE = new ValidatorCache<>(new AssetKeyValidator<>(MinigameDefinition::getAssetStore)); + + private static AssetStore> ASSET_STORE; + + public static final MinigameDefinition UNKNOWN = new MinigameDefinition("Unknown") {{ + this.name = "Unknown Minigame"; + this.description = ""; + this.enabled = false; + this.gameType = GameType.GENERIC; + this.teamMode = TeamMode.SOLO; + this.winCondition = WinCondition.HIGHEST_SCORE; + }}; + + protected AssetExtraInfo.Data data; + protected String id; + protected String name; + protected String description = ""; + protected boolean enabled = true; + protected boolean debug = false; + protected GameType gameType = GameType.GENERIC; + protected String worldId = ""; + protected GameMode requiredGameMode = GameMode.Adventure; + protected int minPlayers = 1; + protected int maxPlayers = 16; + protected TeamMode teamMode = TeamMode.SOLO; + protected int teamCount = 0; + protected int playersPerTeam = 0; + protected int roundLengthSeconds = 300; + protected int totalRounds = 1; + protected int countdownSeconds = 10; + protected boolean overtimeEnabled = false; + protected boolean suddenDeathEnabled = false; + protected WinCondition winCondition = WinCondition.HIGHEST_SCORE; + protected MapSelectionMode mapSelectionMode = MapSelectionMode.VOTE; + protected FriendlyFireKillScoreMode friendlyFireKillScoreMode = FriendlyFireKillScoreMode.NO_POINTS; + protected boolean resetOnEnd = true; + protected boolean savePlayerInventory = true; + protected boolean restorePlayerInventory = true; + protected boolean allowJoinMidgame = false; + protected boolean allowSpectators = true; + protected boolean allowPvp = false; + protected boolean allowBlockBreaking = false; + protected boolean allowBlockPlacing = false; + protected ItemStackConfig[] startItems = new ItemStackConfig[0]; + protected RewardConfig[] rewards = new RewardConfig[0]; + + protected MinigameDefinition() {} + + public MinigameDefinition(@Nonnull String id) { + this.id = id; + } + + public static AssetStore> getAssetStore() { + if (ASSET_STORE == null) { + ASSET_STORE = AssetRegistry.getAssetStore(MinigameDefinition.class); + } + return ASSET_STORE; + } + + public static DefaultAssetMap getAssetMap() { + return getAssetStore().getAssetMap(); + } + + public static MinigameDefinition create(@Nonnull String id, @Nonnull String name) { + MinigameDefinition def = new MinigameDefinition(id); + def.name = name; + return def; + } + + public AssetExtraInfo.Data getData() { return data; } + + @Override + public String getId() { return id; } + + public String getName() { return name; } + public String getDescription() { return description != null ? description : ""; } + public boolean isEnabled() { return enabled; } + public boolean isDebug() { return debug; } + public GameType getGameType() { return gameType != null ? gameType : GameType.GENERIC; } + public String getWorldId() { return worldId != null ? worldId : ""; } + public GameMode getRequiredGameMode() { return requiredGameMode != null ? requiredGameMode : GameMode.Adventure; } + public int getMinPlayers() { return minPlayers; } + public int getMaxPlayers() { return maxPlayers; } + public TeamMode getTeamMode() { return teamMode != null ? teamMode : TeamMode.SOLO; } + public int getTeamCount() { return teamCount; } + public int getPlayersPerTeam() { return playersPerTeam; } + public int getRoundLengthSeconds() { return roundLengthSeconds; } + public int getTotalRounds() { return totalRounds; } + public int getCountdownSeconds() { return countdownSeconds; } + public boolean isOvertimeEnabled() { return overtimeEnabled; } + public boolean isSuddenDeathEnabled() { return suddenDeathEnabled; } + 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; } + public boolean isResetOnEnd() { return resetOnEnd; } + public boolean isSavePlayerInventory() { return savePlayerInventory; } + public boolean isRestorePlayerInventory() { return restorePlayerInventory; } + public boolean isAllowJoinMidgame() { return allowJoinMidgame; } + public boolean isAllowSpectators() { return allowSpectators; } + public boolean isAllowPvp() { return allowPvp; } + public boolean isAllowBlockBreaking() { return allowBlockBreaking; } + public boolean isAllowBlockPlacing() { return allowBlockPlacing; } + public ItemStackConfig[] getStartItems() { return startItems != null ? startItems : new ItemStackConfig[0]; } + public RewardConfig[] getRewards() { return rewards != null ? rewards : new RewardConfig[0]; } + + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public void setName(String name) { this.name = name; } + public void setDescription(String description) { this.description = description; } +} diff --git a/src/main/java/net/kewwbec/minigames/model/MinigameMapCandidate.java b/src/main/java/net/kewwbec/minigames/model/MinigameMapCandidate.java new file mode 100644 index 0000000..f5ee5c3 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/MinigameMapCandidate.java @@ -0,0 +1,20 @@ +package net.kewwbec.minigames.model; + +import javax.annotation.Nonnull; + +public record MinigameMapCandidate( + @Nonnull String minigameId, + @Nonnull String mapId, + @Nonnull String variantId, + int volumeCount, + boolean hasMainArena +) { + public static MinigameMapCandidate none(@Nonnull String minigameId) { + return new MinigameMapCandidate(minigameId, "", "", 0, false); + } + + @Nonnull + public String displayId() { + return variantId.isBlank() ? mapId : mapId + "/" + variantId; + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/MinigameMapDiscoveryResult.java b/src/main/java/net/kewwbec/minigames/model/MinigameMapDiscoveryResult.java new file mode 100644 index 0000000..6e62d2f --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/MinigameMapDiscoveryResult.java @@ -0,0 +1,13 @@ +package net.kewwbec.minigames.model; + +import javax.annotation.Nonnull; +import java.util.List; + +public record MinigameMapDiscoveryResult( + @Nonnull List candidates, + @Nonnull List warnings +) { + public static MinigameMapDiscoveryResult empty() { + return new MinigameMapDiscoveryResult(List.of(), List.of()); + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/ObjectiveState.java b/src/main/java/net/kewwbec/minigames/model/ObjectiveState.java new file mode 100644 index 0000000..55834e4 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/ObjectiveState.java @@ -0,0 +1,39 @@ +package net.kewwbec.minigames.model; + +import java.util.HashMap; +import java.util.Map; + +import static net.kewwbec.minigames.model.Enums.ObjectiveStatus; + +public final class ObjectiveState { + private final String objectiveId; + private final String name; + private final String type; + private ObjectiveStatus status = ObjectiveStatus.INACTIVE; + private String ownerTeamId; + private String ownerPlayerId; + private int progress; + private int requiredProgress = 1; + private final Map metadata = new HashMap<>(); + + public ObjectiveState(String objectiveId, String name, String type) { + this.objectiveId = objectiveId; + this.name = name; + this.type = type; + } + + public String objectiveId() { return objectiveId; } + public String name() { return name; } + public String type() { return type; } + public ObjectiveStatus status() { return status; } + public void status(ObjectiveStatus status) { this.status = status; } + public String ownerTeamId() { return ownerTeamId; } + public void ownerTeamId(String ownerTeamId) { this.ownerTeamId = ownerTeamId; } + public String ownerPlayerId() { return ownerPlayerId; } + public void ownerPlayerId(String ownerPlayerId) { this.ownerPlayerId = ownerPlayerId; } + public int progress() { return progress; } + public void progress(int progress) { this.progress = progress; } + public int requiredProgress() { return requiredProgress; } + public void requiredProgress(int requiredProgress) { this.requiredProgress = requiredProgress; } + public Map metadata() { return metadata; } +} diff --git a/src/main/java/net/kewwbec/minigames/model/PlayerMinigameState.java b/src/main/java/net/kewwbec/minigames/model/PlayerMinigameState.java new file mode 100644 index 0000000..4bbceb1 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/PlayerMinigameState.java @@ -0,0 +1,48 @@ +package net.kewwbec.minigames.model; + +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static net.kewwbec.minigames.model.Enums.PlayerStatus; + +public final class PlayerMinigameState { + private final String playerId; + private final String minigameId; + private String teamId; + private PlayerStatus status = PlayerStatus.QUEUED; + private int score; + private int lives; + private String checkpointId; + private final Set tags = new HashSet<>(); + private final Map variables = new HashMap<>(); + private final Map savedInventory = new HashMap<>(); + private final Instant joinedAt = Instant.now(); + private Instant lastActiveAt = joinedAt; + + public PlayerMinigameState(String playerId, String minigameId) { + this.playerId = playerId; + this.minigameId = minigameId; + } + + public String playerId() { return playerId; } + public String minigameId() { return minigameId; } + public String teamId() { return teamId; } + public void teamId(String teamId) { this.teamId = teamId; } + public PlayerStatus status() { return status; } + public void status(PlayerStatus status) { this.status = status; touch(); } + public int score() { return score; } + public void score(int score) { this.score = score; touch(); } + public int lives() { return lives; } + public void lives(int lives) { this.lives = lives; touch(); } + public String checkpointId() { return checkpointId; } + public void checkpointId(String checkpointId) { this.checkpointId = checkpointId; touch(); } + public Set tags() { return tags; } + public Map variables() { return variables; } + public Map savedInventory() { return savedInventory; } + public Instant joinedAt() { return joinedAt; } + public Instant lastActiveAt() { return lastActiveAt; } + public void touch() { lastActiveAt = Instant.now(); } +} diff --git a/src/main/java/net/kewwbec/minigames/model/RewardConfig.java b/src/main/java/net/kewwbec/minigames/model/RewardConfig.java new file mode 100644 index 0000000..54aa9ca --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/RewardConfig.java @@ -0,0 +1,38 @@ +package net.kewwbec.minigames.model; + +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.array.ArrayCodec; +import com.hypixel.hytale.codec.validation.Validators; + +public class RewardConfig { + public static final BuilderCodec CODEC = BuilderCodec.builder(RewardConfig.class, RewardConfig::new) + .append( + new KeyedCodec<>("Target", Codec.STRING), + (cfg, v) -> cfg.target = v, + cfg -> cfg.target + ) + .addValidator(Validators.nonNull()) + .add() + .append( + new KeyedCodec<>("Items", new ArrayCodec<>(ItemStackConfig.CODEC, ItemStackConfig[]::new)), + (cfg, v) -> cfg.items = v, + cfg -> cfg.items + ) + .add() + .build(); + + protected String target; + protected ItemStackConfig[] items = new ItemStackConfig[0]; + + public RewardConfig() {} + + public RewardConfig(String target, ItemStackConfig[] items) { + this.target = target; + this.items = items; + } + + public String getTarget() { return target; } + public ItemStackConfig[] getItems() { return items != null ? items : new ItemStackConfig[0]; } +} diff --git a/src/main/java/net/kewwbec/minigames/model/SpawnItemConfig.java b/src/main/java/net/kewwbec/minigames/model/SpawnItemConfig.java new file mode 100644 index 0000000..708f295 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/SpawnItemConfig.java @@ -0,0 +1,10 @@ +package net.kewwbec.minigames.model; + +import java.util.List; + +public record SpawnItemConfig(List items, boolean respawn, int respawnCooldownSeconds) { + public SpawnItemConfig { + if (items == null || items.isEmpty()) throw new IllegalArgumentException("items must not be empty"); + if (respawnCooldownSeconds < 0) throw new IllegalArgumentException("respawnCooldownSeconds must be non-negative"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/SpawnItemEntry.java b/src/main/java/net/kewwbec/minigames/model/SpawnItemEntry.java new file mode 100644 index 0000000..0c82f3e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/SpawnItemEntry.java @@ -0,0 +1,12 @@ +package net.kewwbec.minigames.model; + +public record SpawnItemEntry(ItemStackConfig item, float dropRate) { + public SpawnItemEntry { + if (item == null) throw new IllegalArgumentException("item must not be null"); + if (dropRate < 0f || dropRate > 1f) throw new IllegalArgumentException("dropRate must be between 0.0 and 1.0"); + } + + public SpawnItemEntry(ItemStackConfig item) { + this(item, 1f); + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/TeamState.java b/src/main/java/net/kewwbec/minigames/model/TeamState.java new file mode 100644 index 0000000..aec2c36 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/TeamState.java @@ -0,0 +1,32 @@ +package net.kewwbec.minigames.model; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class TeamState { + private final String teamId; + private final String name; + private final String color; + private final Set players = new LinkedHashSet<>(); + private int score; + private final Set tags = new HashSet<>(); + private final Map variables = new HashMap<>(); + + public TeamState(String teamId, String name, String color) { + this.teamId = teamId; + this.name = name; + this.color = color; + } + + public String teamId() { return teamId; } + public String name() { return name; } + public String color() { return color; } + public Set players() { return players; } + public int score() { return score; } + public void score(int score) { this.score = score; } + public Set tags() { return tags; } + public Map variables() { return variables; } +} diff --git a/src/main/java/net/kewwbec/minigames/model/ValidationIssue.java b/src/main/java/net/kewwbec/minigames/model/ValidationIssue.java new file mode 100644 index 0000000..4e91ac0 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/ValidationIssue.java @@ -0,0 +1,13 @@ +package net.kewwbec.minigames.model; + +import java.util.Map; + +public record ValidationIssue(String translationKey, Map params) { + public static ValidationIssue of(String translationKey) { + return new ValidationIssue(translationKey, Map.of()); + } + + public static ValidationIssue of(String translationKey, String paramName, Object paramValue) { + return new ValidationIssue(translationKey, Map.of(paramName, paramValue)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/persistence/LocalJsonMinigameDefinitionStore.java b/src/main/java/net/kewwbec/minigames/persistence/LocalJsonMinigameDefinitionStore.java new file mode 100644 index 0000000..33e4c4c --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/persistence/LocalJsonMinigameDefinitionStore.java @@ -0,0 +1,39 @@ +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)); + } +} diff --git a/src/main/java/net/kewwbec/minigames/persistence/MinigameDefinitionStore.java b/src/main/java/net/kewwbec/minigames/persistence/MinigameDefinitionStore.java new file mode 100644 index 0000000..b5f0708 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/persistence/MinigameDefinitionStore.java @@ -0,0 +1,10 @@ +package net.kewwbec.minigames.persistence; + +import net.kewwbec.minigames.model.MinigameDefinition; + +import java.io.IOException; + +public interface MinigameDefinitionStore { + void save(MinigameDefinition definition) throws IOException; + void delete(String id) throws IOException; +} diff --git a/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java b/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java new file mode 100644 index 0000000..fbcd846 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java @@ -0,0 +1,228 @@ +package net.kewwbec.minigames.runtime; + +import com.hypixel.hytale.server.core.HytaleServer; +import com.hypixel.hytale.server.core.Message; +import net.kewwbec.minigames.adapter.HytaleAdapters; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.MinigameMapCandidate; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.service.MinigameVolumeSignalService; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static net.kewwbec.minigames.model.Enums.MinigamePhase; + +public final class DefaultMinigameRuntimeService implements MinigameRuntimeService { + private final Map runtimes = new HashMap<>(); + @Nullable + private MinigameVolumeSignalService signalService; + @Nullable + private HytaleAdapters.PlayerAdapter playerAdapter; + + public void setSignalService(@Nullable MinigameVolumeSignalService signalService) { + this.signalService = signalService; + } + + public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) { + this.playerAdapter = playerAdapter; + } + + @Override + public MinigameRuntime start(MinigameDefinition definition) { + return start(definition, MinigameMapCandidate.none(definition.getId())); + } + + @Override + public MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map) { + 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); + runtimes.put(runtime.runtimeId(), runtime); + dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime))); + return runtime; + } + + @Override + public void end(String minigameId, String reason) { + for (MinigameRuntime runtime : matching(minigameId)) { + if (signalService != null) signalService.cancelAll(runtime.runtimeId()); + runtime.phase(MinigamePhase.ENDING); + var payload = new HashMap(eventPayload(runtime)); + payload.put("reason", reason); + dispatch(new MinigameEvent("on_game_end", runtime.minigameId(), payload)); + runtimes.remove(runtime.runtimeId()); + } + } + + @Override + public void reset(String minigameId) { + for (MinigameRuntime runtime : matching(minigameId)) { + if (signalService != null) signalService.cancelAll(runtime.runtimeId()); + runtime.phase(MinigamePhase.RESETTING); + runtime.players().clear(); + runtime.teams().clear(); + runtime.objectives().clear(); + runtime.variables().clear(); + runtime.timers().clear(); + runtime.activeVolumes().clear(); + runtime.spectators().clear(); + runtime.eliminatedPlayers().clear(); + runtime.temporaryBlocks().clear(); + runtime.temporaryEntities().clear(); + dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime))); + } + } + + @Override + public Optional runtime(String id) { + MinigameRuntime byRuntimeId = runtimes.get(id); + if (byRuntimeId != null) { + return Optional.of(byRuntimeId); + } + return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(id)).findFirst(); + } + + @Override + public Collection runtimes(String minigameId) { + return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(minigameId)).toList(); + } + + @Override + public Optional runtimeForArena(String minigameId, String mapId, String variantId) { + String normalizedMap = mapId != null ? mapId : ""; + String normalizedVariant = variantId != null ? variantId : ""; + return runtimes.values().stream() + .filter(runtime -> runtime.minigameId().equals(minigameId)) + .filter(runtime -> normalizedMap.isBlank() || runtime.mapId().equals(normalizedMap)) + .filter(runtime -> normalizedVariant.isBlank() || runtime.variantId().equals(normalizedVariant)) + .findFirst(); + } + + @Override + public Optional runtimeForPlayer(String playerId) { + if (playerId == null || playerId.isBlank()) { + return Optional.empty(); + } + return runtimes.values().stream().filter(runtime -> runtime.players().containsKey(playerId)).findFirst(); + } + + @Override + public Collection runtimes() { + return runtimes.values(); + } + + @Override + public void shutdownAll(String reason) { + for (String runtimeId : runtimes.keySet().toArray(String[]::new)) { + end(runtimeId, reason); + } + runtimes.clear(); + } + + @Override + public void dispatch(MinigameRuntime runtime, String eventId, Map payload) { + // Internal round timer — handle auto-progression, do not propagate as a public event + if ("on_timer_expired".equals(eventId)) { + Object timerName = payload != null ? payload.get("timer_name") : null; + if ("$round".equals(timerName)) { + handleRoundTimerExpiry(runtime); + return; + } + } + var full = new HashMap(eventPayload(runtime)); + if (payload != null) full.putAll(payload); + dispatch(new MinigameEvent(eventId, runtime.minigameId(), Map.copyOf(full))); + } + + private void handleRoundTimerExpiry(MinigameRuntime runtime) { + int current = runtime.roundNumber(); + + // Guard against stale callbacks (e.g. round was manually advanced before timer fired) + Object target = runtime.variables().get("$roundTimerTarget"); + if (!Integer.valueOf(current).equals(target)) return; + + int total = runtime.definition().getTotalRounds(); + + var endPayload = new HashMap<>(eventPayload(runtime)); + endPayload.put("round", current); + dispatch(new MinigameEvent("on_round_end", runtime.minigameId(), Map.copyOf(endPayload))); + + if (total > 0 && current >= total) { + sendEndScores(runtime); + + var donePayload = new HashMap<>(eventPayload(runtime)); + donePayload.put("final_round", current); + dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload))); + } else { + int next = current + 1; + runtime.roundNumber(next); + runtime.variables().put("$roundTimerTarget", next); + + if (signalService != null) { + signalService.onRoundStart(runtime, next); + int roundLen = runtime.definition().getRoundLengthSeconds(); + if (roundLen > 0) { + signalService.startTimer(runtime, "$round", roundLen * 1000L); + } + } + + var startPayload = new HashMap<>(eventPayload(runtime)); + startPayload.put("round", next); + dispatch(new MinigameEvent("on_round_start", runtime.minigameId(), Map.copyOf(startPayload))); + } + } + + private Collection matching(String id) { + MinigameRuntime runtime = runtimes.get(id); + if (runtime != null) { + return java.util.List.of(runtime); + } + return runtimes(id); + } + + private static Map eventPayload(MinigameRuntime runtime) { + return Map.of( + "runtime_id", runtime.runtimeId(), + "minigame_id", runtime.minigameId(), + "map_id", runtime.mapId(), + "variant_id", runtime.variantId() + ); + } + + private void sendEndScores(MinigameRuntime runtime) { + if (playerAdapter == null || runtime.players().isEmpty()) return; + + var ranked = runtime.players().entrySet().stream() + .sorted(Map.Entry.comparingByValue( + Comparator.comparingInt((PlayerMinigameState player) -> player.score()).reversed())) + .toList(); + + var recipients = runtime.players().keySet(); + for (String recipient : recipients) { + playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header")); + int rank = 1; + for (var entry : ranked) { + String name = playerAdapter.getDisplayName(entry.getKey()); + playerAdapter.sendMessage(recipient, + Message.translation("minigames.endscores.entry") + .param("rank", rank) + .param("player", name) + .param("score", entry.getValue().score())); + rank++; + } + } + } + + private static void dispatch(MinigameEvent event) { + HytaleServer server = HytaleServer.get(); + if (server != null) { + server.getEventBus().dispatchFor(MinigameEvent.class, event.minigameId()).dispatch(event); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/runtime/MinigameEvent.java b/src/main/java/net/kewwbec/minigames/runtime/MinigameEvent.java new file mode 100644 index 0000000..8768b5e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/runtime/MinigameEvent.java @@ -0,0 +1,8 @@ +package net.kewwbec.minigames.runtime; + +import com.hypixel.hytale.event.IEvent; + +import java.util.Map; + +public record MinigameEvent(String eventId, String minigameId, Map payload) implements IEvent { +} diff --git a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntime.java b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntime.java new file mode 100644 index 0000000..c7d05b2 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntime.java @@ -0,0 +1,68 @@ +package net.kewwbec.minigames.runtime; + +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.ObjectiveState; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.model.TeamState; + +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static net.kewwbec.minigames.model.Enums.MinigamePhase; + +public final class MinigameRuntime { + private final String runtimeId; + private final MinigameDefinition definition; + private final String minigameId; + private final String mapId; + private final String variantId; + private MinigamePhase phase = MinigamePhase.CREATED; + private int roundNumber = 1; + private final Instant createdAt = Instant.now(); + private final Map players = new HashMap<>(); + private final Map teams = new HashMap<>(); + private final Map objectives = new HashMap<>(); + private final Map variables = new HashMap<>(); + private final Set activeVolumes = new HashSet<>(); + private final Set spectators = new HashSet<>(); + private final Set eliminatedPlayers = new HashSet<>(); + private final Set temporaryEntities = new HashSet<>(); + private final Set temporaryBlocks = new HashSet<>(); + private final Map timers = new HashMap<>(); + + public MinigameRuntime(String runtimeId, MinigameDefinition definition) { + this(runtimeId, definition, "", ""); + } + + public MinigameRuntime(String runtimeId, MinigameDefinition definition, String mapId, String variantId) { + this.runtimeId = runtimeId; + this.definition = definition; + this.minigameId = definition.getId(); + this.mapId = mapId != null ? mapId : ""; + this.variantId = variantId != null ? variantId : ""; + } + + public String runtimeId() { return runtimeId; } + public MinigameDefinition definition() { return definition; } + public String minigameId() { return minigameId; } + public String mapId() { return mapId; } + public String variantId() { return variantId; } + public MinigamePhase phase() { return phase; } + public void phase(MinigamePhase phase) { this.phase = phase; } + public int roundNumber() { return roundNumber; } + public void roundNumber(int roundNumber) { this.roundNumber = roundNumber; } + public Instant createdAt() { return createdAt; } + public Map players() { return players; } + public Map teams() { return teams; } + public Map objectives() { return objectives; } + public Map variables() { return variables; } + public Set activeVolumes() { return activeVolumes; } + public Set spectators() { return spectators; } + public Set eliminatedPlayers() { return eliminatedPlayers; } + public Set temporaryEntities() { return temporaryEntities; } + public Set temporaryBlocks() { return temporaryBlocks; } + public Map timers() { return timers; } +} diff --git a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java new file mode 100644 index 0000000..e865334 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java @@ -0,0 +1,22 @@ +package net.kewwbec.minigames.runtime; + +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.MinigameMapCandidate; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +public interface MinigameRuntimeService { + MinigameRuntime start(MinigameDefinition definition); + MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map); + void end(String id, String reason); + void reset(String id); + Optional runtime(String id); + Collection runtimes(String minigameId); + Optional runtimeForArena(String minigameId, String mapId, String variantId); + Optional runtimeForPlayer(String playerId); + Collection runtimes(); + void shutdownAll(String reason); + void dispatch(MinigameRuntime runtime, String eventId, Map payload); +} diff --git a/src/main/java/net/kewwbec/minigames/service/ArenaResetService.java b/src/main/java/net/kewwbec/minigames/service/ArenaResetService.java new file mode 100644 index 0000000..6319b22 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/ArenaResetService.java @@ -0,0 +1,8 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.runtime.MinigameRuntime; + +public interface ArenaResetService { + void snapshot(MinigameRuntime runtime); + void restore(MinigameRuntime runtime); +} diff --git a/src/main/java/net/kewwbec/minigames/service/CheckpointService.java b/src/main/java/net/kewwbec/minigames/service/CheckpointService.java new file mode 100644 index 0000000..149268b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/CheckpointService.java @@ -0,0 +1,19 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import java.util.Optional; + +public final class CheckpointService { + public void saveCheckpoint(MinigameRuntime runtime, String playerId, String checkpointId) { + var player = runtime.players().get(playerId); + if (player != null) { + player.checkpointId(checkpointId); + } + } + + public Optional getCheckpoint(MinigameRuntime runtime, String playerId) { + var player = runtime.players().get(playerId); + return player == null ? Optional.empty() : Optional.ofNullable(player.checkpointId()); + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/DefaultMinigameService.java b/src/main/java/net/kewwbec/minigames/service/DefaultMinigameService.java new file mode 100644 index 0000000..645b180 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/DefaultMinigameService.java @@ -0,0 +1,113 @@ +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.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; + +public final class DefaultMinigameService implements MinigameService { + private static final String PACK_KEY = "net.kewwbec:core-minigames"; + + private final MinigameRuntimeService runtimeService; + private final LocalJsonMinigameDefinitionStore store; + + public DefaultMinigameService(Path dataRoot, MinigameRuntimeService runtimeService) { + this.runtimeService = runtimeService; + this.store = new LocalJsonMinigameDefinitionStore(dataRoot); + } + + @Override + public MinigameDefinition create(String id, String name) { + var definition = MinigameDefinition.create(id, name); + MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition)); + try { + store.save(definition); + } catch (IOException e) { + throw new RuntimeException("Failed to save minigame definition: " + id, e); + } + return definition; + } + + @Override + public void update(MinigameDefinition definition) { + MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition)); + try { + store.save(definition); + } catch (IOException e) { + throw new RuntimeException("Failed to save minigame definition: " + definition.getId(), e); + } + } + + @Override + public void delete(String id) { + runtimeService.end(id, "minigames.runtime.reason.definition_deleted"); + try { + store.delete(id); + } catch (IOException e) { + throw new RuntimeException("Failed to delete minigame definition: " + id, e); + } + } + + @Override + 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); + } + }); + } + + @Override + public MinigameRuntime start(String id) { + var definition = definition(id).orElseThrow(() -> new IllegalArgumentException("minigames.error.unknown_minigame:" + id)); + return runtimeService.start(definition); + } + + @Override + public void end(String id, String reason) { + runtimeService.end(id, reason); + } + + @Override + public void reset(String id) { + runtimeService.reset(id); + } + + @Override + public Optional definition(String id) { + return Optional.ofNullable(MinigameDefinition.getAssetMap().getAsset(id)); + } + + @Override + public Collection definitions() { + return MinigameDefinition.getAssetMap().getAssetMap().values(); + } + + @Override + public List validate(String id) { + var errors = new ArrayList(); + var definition = definition(id); + if (definition.isEmpty()) { + return List.of(ValidationIssue.of("minigames.validation.minigame_missing", "id", id)); + } + var value = definition.get(); + if (value.getName() == null || value.getName().isBlank()) errors.add(ValidationIssue.of("minigames.validation.display_name_missing")); + if (value.getMinPlayers() < 1) errors.add(ValidationIssue.of("minigames.validation.min_players_below_one")); + 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")); + return errors; + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/InteractionService.java b/src/main/java/net/kewwbec/minigames/service/InteractionService.java new file mode 100644 index 0000000..983f84e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/InteractionService.java @@ -0,0 +1,14 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.runtime.MinigameEvent; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import java.util.Map; + +public final class InteractionService { + public MinigameEvent toEvent(MinigameRuntime runtime, String eventId, Map facts) { + var payload = Map.copyOf(facts); + runtime.variables().put("last_interaction", payload); + return new MinigameEvent(eventId, runtime.definition().getId(), payload); + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java new file mode 100644 index 0000000..cef487e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java @@ -0,0 +1,178 @@ +package net.kewwbec.minigames.service; + +import com.hypixel.hytale.assetstore.AssetRegistry; +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.component.Store; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.model.MinigameMapCandidate; +import net.kewwbec.minigames.model.MinigameMapDiscoveryResult; +import net.kewwbec.minigames.model.ValidationIssue; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +public final class MinigameMapDiscoveryService { + public static final String TAG_MINIGAME_ID = "MinigameId"; + public static final String TAG_MAP_ID = "MapId"; + public static final String TAG_VARIANT_ID = "VariantId"; + public static final String TAG_MAP_ROLE = "MapRole"; + public static final String TAG_SPAWN_ROLE = "SpawnRole"; + public static final String TAG_TEAM_ID = "TeamId"; + public static final String ROLE_MAIN_ARENA = "MainArena"; + public static final String ROLE_TEAM_SPAWN = "TeamSpawn"; + + @Nonnull + public MinigameMapDiscoveryResult discover(@Nullable TriggerContext context, @Nonnull String minigameId) { + if (context == null || context.getStore() == null) { + return MinigameMapDiscoveryResult.empty(); + } + return discover(context.getStore(), minigameId); + } + + @Nonnull + public MinigameMapDiscoveryResult discover(@Nullable Store store, @Nonnull String minigameId) { + TriggerVolumeManager manager = manager(store); + if (manager == null) { + return MinigameMapDiscoveryResult.empty(); + } + return discover(manager, minigameId); + } + + @Nonnull + public MinigameMapDiscoveryResult discover(@Nonnull TriggerVolumeManager manager, @Nonnull String minigameId) { + var warnings = new ArrayList(); + var groups = new HashMap(); + + for (VolumeEntry volume : manager.getVolumes()) { + Map tags = volume.getRawTags(); + String volumeMinigameId = clean(tags.get(TAG_MINIGAME_ID)); + String mapId = clean(tags.get(TAG_MAP_ID)); + String variantId = clean(tags.get(TAG_VARIANT_ID)); + + if (!mapId.isBlank() && volumeMinigameId.isBlank()) { + warnings.add(ValidationIssue.of("minigames.validation.warning.map_volume_missing_minigame", "volumeId", volume.getId())); + continue; + } + + if (!minigameId.equals(volumeMinigameId) || mapId.isBlank()) { + continue; + } + + var key = new Key(mapId, variantId); + MutableCandidate candidate = groups.computeIfAbsent(key, ignored -> new MutableCandidate()); + candidate.volumeCount++; + candidate.hasMainArena |= ROLE_MAIN_ARENA.equals(clean(tags.get(TAG_MAP_ROLE))); + } + + var candidates = groups.entrySet().stream() + .map(entry -> new MinigameMapCandidate(minigameId, entry.getKey().mapId(), entry.getKey().variantId(), entry.getValue().volumeCount, entry.getValue().hasMainArena)) + .sorted(Comparator.comparing(MinigameMapCandidate::mapId).thenComparing(MinigameMapCandidate::variantId)) + .toList(); + + if (candidates.isEmpty()) { + warnings.add(ValidationIssue.of("minigames.validation.warning.no_maps_discovered", "id", minigameId)); + } + for (MinigameMapCandidate candidate : candidates) { + if (!candidate.hasMainArena()) { + warnings.add(ValidationIssue.of("minigames.validation.warning.map_missing_main_arena", "mapId", candidate.displayId())); + } + } + + return new MinigameMapDiscoveryResult(candidates, warnings); + } + + @Nonnull + public MinigameMapCandidate candidateFromVolume(@Nonnull TriggerContext context, @Nonnull String fallbackMinigameId) { + VolumeEntry volume = context.getVolume(); + if (volume == null) { + return MinigameMapCandidate.none(fallbackMinigameId); + } + Map tags = volume.getRawTags(); + String minigameId = clean(tags.get(TAG_MINIGAME_ID)); + if (minigameId.isBlank()) { + minigameId = fallbackMinigameId; + } + return new MinigameMapCandidate(minigameId, clean(tags.get(TAG_MAP_ID)), clean(tags.get(TAG_VARIANT_ID)), 1, ROLE_MAIN_ARENA.equals(clean(tags.get(TAG_MAP_ROLE)))); + } + + @Nonnull + public String teamSpawnDestination(@Nullable Store store, @Nonnull MinigameRuntime runtime, @Nonnull String teamId) { + TriggerVolumeManager manager = manager(store); + if (manager == null || teamId.isBlank()) { + return ""; + } + + List candidates = manager.getVolumes().stream() + .filter(volume -> isTeamSpawn(volume, runtime, teamId)) + .toList(); + if (candidates.isEmpty()) { + return ""; + } + + VolumeEntry selected = candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); + var pos = selected.getPosition(); + if (selected.getWorldName() != null && !selected.getWorldName().isBlank()) { + return selected.getWorldName() + "," + pos.x() + "," + pos.y() + "," + pos.z(); + } + return pos.x() + "," + pos.y() + "," + pos.z(); + } + + private static boolean isTeamSpawn(@Nonnull VolumeEntry volume, @Nonnull MinigameRuntime runtime, @Nonnull String teamId) { + Map tags = volume.getRawTags(); + String minigameId = clean(tags.get(TAG_MINIGAME_ID)); + String mapId = clean(tags.get(TAG_MAP_ID)); + String variantId = clean(tags.get(TAG_VARIANT_ID)); + String spawnRole = clean(tags.get(TAG_SPAWN_ROLE)); + String spawnTeamId = clean(tags.get(TAG_TEAM_ID)); + if (!ROLE_TEAM_SPAWN.equals(spawnRole) || !teamId.equals(spawnTeamId)) { + return false; + } + if (!minigameId.isBlank() && !minigameId.equals(runtime.minigameId())) { + return false; + } + if (!mapId.isBlank() && !mapId.equals(runtime.mapId())) { + return false; + } + return variantId.isBlank() || variantId.equals(runtime.variantId()); + } + + @Nullable + private static TriggerVolumeManager manager(@Nullable Store store) { + TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get(); + if (plugin == null || store == null) { + return null; + } + return store.getResource(plugin.getManagerResourceType()); + } + + @Nonnull + private static String clean(@Nullable String value) { + return value != null && !value.isBlank() ? value.trim() : ""; + } + + public static boolean hasTag(@Nonnull VolumeEntry volume, @Nonnull String key, @Nonnull String value) { + String raw = volume.getRawTags().get(key); + if (raw == null || raw.isBlank()) { + return false; + } + return raw.equals(value) || volume.hasTag(AssetRegistry.getOrCreateTagIndex(key + "=" + value)); + } + + private record Key(String mapId, String variantId) { + } + + private static final class MutableCandidate { + private int volumeCount; + private boolean hasMainArena; + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java b/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java new file mode 100644 index 0000000..cdc8240 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java @@ -0,0 +1,112 @@ +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 javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +import static net.kewwbec.minigames.model.Enums.MapSelectionMode; + +public final class MinigameQueueService { + private final Map sessions = new HashMap<>(); + private final Random random = new Random(); + + public void join(@Nonnull String minigameId, @Nonnull String playerId) { + session(minigameId).players.add(playerId); + } + + public void leave(@Nonnull String minigameId, @Nonnull String playerId) { + Session session = sessions.get(minigameId); + if (session == null) { + return; + } + session.players.remove(playerId); + session.votes.remove(playerId); + if (session.players.isEmpty() && session.votes.isEmpty()) { + sessions.remove(minigameId); + } + } + + 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); + } + + @Nonnull + public MinigameRuntime startQueued( + @Nonnull MinigameDefinition definition, + @Nonnull List candidates, + @Nonnull MinigameRuntimeService runtimeService + ) { + MinigameMapCandidate selected = select(definition, candidates); + MinigameRuntime runtime = runtimeService.start(definition, selected); + Session session = session(definition.getId()); + for (String playerId : session.players) { + runtime.players().putIfAbsent(playerId, new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId())); + } + sessions.remove(definition.getId()); + return runtime; + } + + @Nonnull + public MinigameMapCandidate select(@Nonnull MinigameDefinition definition, @Nonnull List candidates) { + return select(definition.getId(), definition.getMapSelectionMode(), candidates); + } + + @Nonnull + MinigameMapCandidate select(@Nonnull String minigameId, @Nonnull MapSelectionMode selectionMode, @Nonnull List candidates) { + List available = candidates.isEmpty() ? List.of(MinigameMapCandidate.none(minigameId)) : candidates; + if (selectionMode == MapSelectionMode.RANDOM) { + return available.get(random.nextInt(available.size())); + } + + Session session = session(minigameId); + Optional winningMapId = session.votes.values().stream() + .collect(java.util.stream.Collectors.groupingBy(value -> value, java.util.stream.Collectors.counting())) + .entrySet() + .stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()).thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .findFirst(); + + if (winningMapId.isPresent()) { + List voted = available.stream().filter(candidate -> candidate.mapId().equals(winningMapId.get())).toList(); + if (!voted.isEmpty()) { + return voted.get(random.nextInt(voted.size())); + } + } + + return available.get(random.nextInt(available.size())); + } + + @Nonnull + public Set players(@Nonnull String minigameId) { + return Set.copyOf(session(minigameId).players); + } + + @Nonnull + public Map votes(@Nonnull String minigameId) { + return Map.copyOf(session(minigameId).votes); + } + + private Session session(@Nonnull String minigameId) { + return sessions.computeIfAbsent(minigameId, ignored -> new Session()); + } + + private static final class Session { + private final Set players = new LinkedHashSet<>(); + private final Map votes = new HashMap<>(); + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameService.java b/src/main/java/net/kewwbec/minigames/service/MinigameService.java new file mode 100644 index 0000000..1c75505 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/MinigameService.java @@ -0,0 +1,22 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.ValidationIssue; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface MinigameService { + MinigameDefinition create(String id, String name); + void update(MinigameDefinition definition); + void delete(String id); + void setEnabled(String id, boolean enabled); + MinigameRuntime start(String id); + void end(String id, String reason); + void reset(String id); + Optional definition(String id); + Collection definitions(); + List validate(String id); +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameServices.java b/src/main/java/net/kewwbec/minigames/service/MinigameServices.java new file mode 100644 index 0000000..6650319 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/MinigameServices.java @@ -0,0 +1,14 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.adapter.HytaleServerAdapters; +import net.kewwbec.minigames.runtime.MinigameRuntimeService; + +public record MinigameServices( + MinigameService minigames, + MinigameRuntimeService runtime, + HytaleServerAdapters adapters, + MinigameMapDiscoveryService maps, + MinigameQueueService queue, + MinigameVolumeSignalService signals +) { +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameVolumeSignalService.java b/src/main/java/net/kewwbec/minigames/service/MinigameVolumeSignalService.java new file mode 100644 index 0000000..bddba50 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/MinigameVolumeSignalService.java @@ -0,0 +1,315 @@ +package net.kewwbec.minigames.service; + +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.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +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.runtime.MinigameRuntime; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +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.logging.Level; + +/** + * Fires TAG_ADDED signals on trigger volumes based on game state (round changes, phase changes, timer expiry). + * + * Volume designers set config tags to describe WHEN a volume should fire: + * SignalRoundStart = fire once when round n starts + * SignalRoundTick = fire repeatedly while round n is active + * SignalPhaseTick = fire repeatedly while the given phase is active + * SignalTickDelay = repeat interval (0 = 50ms minimum) + * SignalOnTimerExpire = fire once when the named timer expires + */ +public final class MinigameVolumeSignalService { + + // Config tags read from volumes — set by map designers, never written by this service + public static final String TAG_SIGNAL_ROUND_START = "SignalRoundStart"; + public static final String TAG_SIGNAL_ROUND_TICK = "SignalRoundTick"; + public static final String TAG_SIGNAL_PHASE_TICK = "SignalPhaseTick"; + public static final String TAG_SIGNAL_TICK_DELAY = "SignalTickDelay"; + public static final String TAG_SIGNAL_TIMER_EXPIRE = "SignalOnTimerExpire"; + + // Signal tag written/toggled by this service to fire TAG_ADDED + public static final String TAG_SIGNAL_KEY = "MinigameSignal"; + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final long MIN_DELAY_MS = 50L; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "minigame-signal-scheduler"); + t.setDaemon(true); + return t; + }); + + private final Map>> roundLoops = new ConcurrentHashMap<>(); + private final Map>> phaseLoops = new ConcurrentHashMap<>(); + private final Map>> timerFutures = new ConcurrentHashMap<>(); + + /** + * Called when a round advances. Fires one-shot RoundStart signals and starts RoundTick loops. + * Must be called from the world thread. + */ + public void onRoundStart(MinigameRuntime runtime, int newRound) { + cancelLoops(roundLoops, runtime.runtimeId()); + + String roundKey = String.valueOf(newRound); + + for (World world : worlds()) { + TriggerVolumeManager manager = manager(world); + if (manager == null) continue; + + ActorPair actor = findActor(runtime, world); + if (actor == null) continue; + + for (VolumeEntry volume : manager.getVolumes()) { + if (!runtime.minigameId().equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue; + + // One-shot: fire immediately + if (roundKey.equals(volume.getRawTags().get(TAG_SIGNAL_ROUND_START))) { + toggle(manager, volume.getId(), actor); + } + + // Repeating: schedule loop + if (roundKey.equals(volume.getRawTags().get(TAG_SIGNAL_ROUND_TICK))) { + long delay = parseDelayMs(volume.getRawTags().get(TAG_SIGNAL_TICK_DELAY)); + 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); + } + } + } + } + + /** + * Called when the game phase changes. Cancels old phase loops and starts new ones. + * Must be called from the world thread. + */ + public void onPhaseChange(MinigameRuntime runtime, MinigamePhase newPhase) { + cancelLoops(phaseLoops, runtime.runtimeId()); + + String phaseKey = newPhase.name(); + + for (World world : worlds()) { + TriggerVolumeManager manager = manager(world); + if (manager == null) continue; + + ActorPair actor = findActor(runtime, world); + if (actor == null) continue; + + for (VolumeEntry volume : manager.getVolumes()) { + if (!runtime.minigameId().equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue; + + if (phaseKey.equals(volume.getRawTags().get(TAG_SIGNAL_PHASE_TICK))) { + long delay = parseDelayMs(volume.getRawTags().get(TAG_SIGNAL_TICK_DELAY)); + 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); + } + } + } + } + + /** + * Schedules a named timer. When it expires, fires TAG_ADDED on volumes tagged with + * SignalOnTimerExpire=timerName and dispatches an on_timer_expired event. + * Replaces any existing timer with the same name. + */ + public void startTimer(MinigameRuntime runtime, String timerName, long durationMs) { + cancelTimer(runtime.runtimeId(), timerName); + runtime.timers().put(timerName, System.currentTimeMillis() + durationMs); + + Future future = scheduler.schedule( + () -> fireTimerExpiry(runtime.runtimeId(), runtime.minigameId(), timerName), + durationMs, + TimeUnit.MILLISECONDS + ); + timerFutures.computeIfAbsent(runtime.runtimeId(), k -> new ConcurrentHashMap<>()) + .put(timerName, future); + } + + /** Cancels a named timer before it fires. */ + public void cancelTimer(String runtimeId, String timerName) { + var futures = timerFutures.get(runtimeId); + if (futures != null) { + Future f = futures.remove(timerName); + if (f != null) f.cancel(false); + } + } + + /** Cancels all signal loops and timers for the given runtime. Call when the runtime ends. */ + public void cancelAll(String runtimeId) { + cancelLoops(roundLoops, runtimeId); + cancelLoops(phaseLoops, runtimeId); + var futures = timerFutures.remove(runtimeId); + if (futures != null) futures.values().forEach(f -> f.cancel(false)); + } + + /** Shuts down the background scheduler. Call on plugin shutdown. */ + public void shutdown() { + scheduler.shutdownNow(); + } + + private Future scheduleLoop(String runtimeId, String minigameId, String tagKey, String tagValue, + String volumeId, String worldName, long delayMs) { + return scheduler.scheduleAtFixedRate( + () -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName), + delayMs, delayMs, TimeUnit.MILLISECONDS + ); + } + + private void tickSignal(String runtimeId, String minigameId, String expectedTagKey, String expectedTagValue, + String volumeId, String worldName) { + try { + Universe universe = Universe.get(); + TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get(); + if (universe == null || plugin == null) return; + + World world = universe.getWorld(worldName); + if (world == null) return; + + world.execute(() -> { + var services = MinigameCorePlugin.getServices(); + if (services == null) return; + + var rtOpt = services.runtime().runtime(runtimeId); + if (rtOpt.isEmpty()) return; + MinigameRuntime rt = rtOpt.get(); + + if (TAG_SIGNAL_ROUND_TICK.equals(expectedTagKey)) { + if (!expectedTagValue.equals(String.valueOf(rt.roundNumber()))) return; + } else if (TAG_SIGNAL_PHASE_TICK.equals(expectedTagKey)) { + if (!expectedTagValue.equals(rt.phase().name())) return; + } + + TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType()); + if (manager == null) return; + + VolumeEntry volume = manager.getVolume(volumeId); + if (volume == null) return; + + ActorPair actor = findActor(rt, world); + if (actor == null) return; + + toggle(manager, volumeId, actor); + }); + } catch (Exception e) { + LOGGER.at(Level.WARNING).withCause(e).log("Error in minigame signal tick for runtime=%s volume=%s", runtimeId, volumeId); + } + } + + private void fireTimerExpiry(String runtimeId, String minigameId, String timerName) { + try { + Universe universe = Universe.get(); + TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get(); + if (universe == null || plugin == null) return; + + var dispatched = new AtomicBoolean(false); + + for (World world : worlds()) { + world.execute(() -> { + var services = MinigameCorePlugin.getServices(); + if (services == null) return; + + var rtOpt = services.runtime().runtime(runtimeId); + if (rtOpt.isEmpty()) return; + MinigameRuntime rt = rtOpt.get(); + + if (dispatched.compareAndSet(false, true)) { + services.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName)); + } + + // Internal timers (name starts with '$') are handled by the runtime service, + // not by volume signals. + if (timerName.startsWith("$")) return; + + TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType()); + if (manager == null) return; + + ActorPair actor = findActor(rt, world); + if (actor == null) return; + + for (VolumeEntry volume : manager.getVolumes()) { + if (!minigameId.equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue; + if (timerName.equals(volume.getRawTags().get(TAG_SIGNAL_TIMER_EXPIRE))) { + toggle(manager, volume.getId(), actor); + } + } + }); + } + } catch (Exception e) { + LOGGER.at(Level.WARNING).withCause(e).log("Error in minigame timer expiry for runtime=%s timer=%s", runtimeId, timerName); + } + } + + /** Toggles MinigameSignal between "0" and "1" to fire TAG_ADDED. */ + private static void toggle(TriggerVolumeManager manager, String volumeId, ActorPair actor) { + VolumeEntry volume = manager.getVolume(volumeId); + if (volume == null) return; + String current = volume.getRawTags().get(TAG_SIGNAL_KEY); + String next = "1".equals(current) ? "0" : "1"; + manager.setTag(volumeId, TAG_SIGNAL_KEY, next, actor.ref(), actor.uuid()); + } + + @Nullable + private static ActorPair findActor(MinigameRuntime runtime, World world) { + Universe universe = Universe.get(); + if (universe == null) return null; + for (String playerId : runtime.players().keySet()) { + try { + UUID uuid = UUID.fromString(playerId); + PlayerRef playerRef = universe.getPlayer(uuid); + if (playerRef == null) continue; + Ref ref = playerRef.getReference(); + if (ref == null || !ref.isValid()) continue; + if (ref.getStore().getExternalData().getWorld() != world) continue; + return new ActorPair(ref, uuid); + } catch (IllegalArgumentException ignored) {} + } + return null; + } + + @Nullable + private static TriggerVolumeManager manager(World world) { + TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get(); + if (plugin == null) return null; + return world.getEntityStore().getStore().getResource(plugin.getManagerResourceType()); + } + + private static Iterable worlds() { + Universe universe = Universe.get(); + return universe != null ? universe.getWorlds().values() : List.of(); + } + + private static void cancelLoops(Map>> loopMap, String runtimeId) { + List> futures = loopMap.remove(runtimeId); + if (futures != null) futures.forEach(f -> f.cancel(false)); + } + + private static long parseDelayMs(@Nullable String value) { + if (value == null || value.isBlank()) return MIN_DELAY_MS; + try { + double seconds = Double.parseDouble(value); + return Math.max(MIN_DELAY_MS, (long) (seconds * 1000)); + } catch (NumberFormatException ignored) { + return MIN_DELAY_MS; + } + } + + private record ActorPair(Ref ref, UUID uuid) {} +} diff --git a/src/main/java/net/kewwbec/minigames/service/RewardService.java b/src/main/java/net/kewwbec/minigames/service/RewardService.java new file mode 100644 index 0000000..179fc4d --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/RewardService.java @@ -0,0 +1,10 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.model.RewardConfig; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import java.util.List; + +public interface RewardService { + void giveRewards(MinigameRuntime runtime, String targetPlayerId, List rewards); +} diff --git a/src/main/java/net/kewwbec/minigames/service/ScoreService.java b/src/main/java/net/kewwbec/minigames/service/ScoreService.java new file mode 100644 index 0000000..801e5ba --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/ScoreService.java @@ -0,0 +1,28 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import java.util.Comparator; +import java.util.Optional; + +public final class ScoreService { + public void addPlayerScore(MinigameRuntime runtime, String playerId, int amount) { + var player = runtime.players().get(playerId); + if (player != null) { + player.score(player.score() + amount); + } + } + + public void addTeamScore(MinigameRuntime runtime, String teamId, int amount) { + var team = runtime.teams().get(teamId); + if (team != null) { + team.score(team.score() + amount); + } + } + + public Optional resolveWinningPlayer(MinigameRuntime runtime) { + return runtime.players().values().stream() + .max(Comparator.comparingInt(player -> player.score())) + .map(player -> player.playerId()); + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/StreakService.java b/src/main/java/net/kewwbec/minigames/service/StreakService.java new file mode 100644 index 0000000..b5d485b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/StreakService.java @@ -0,0 +1,27 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.runtime.MinigameRuntime; + +public final class StreakService { + private static final String PREFIX = "streak:"; + + public int increment(MinigameRuntime runtime, String ownerId, String streakId) { + var key = key(ownerId, streakId); + var next = current(runtime, ownerId, streakId) + 1; + runtime.variables().put(key, next); + return next; + } + + public void reset(MinigameRuntime runtime, String ownerId, String streakId) { + runtime.variables().remove(key(ownerId, streakId)); + } + + public int current(MinigameRuntime runtime, String ownerId, String streakId) { + var value = runtime.variables().get(key(ownerId, streakId)); + return value instanceof Number number ? number.intValue() : 0; + } + + private String key(String ownerId, String streakId) { + return PREFIX + ownerId + ":" + streakId; + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/TeamService.java b/src/main/java/net/kewwbec/minigames/service/TeamService.java new file mode 100644 index 0000000..f6ec6ff --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/service/TeamService.java @@ -0,0 +1,22 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.model.TeamState; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +public final class TeamService { + public TeamState createTeam(MinigameRuntime runtime, String teamId, String name, String color) { + var team = new TeamState(teamId, name, color); + runtime.teams().put(teamId, team); + return team; + } + + public void assignPlayer(MinigameRuntime runtime, String playerId, String teamId) { + var player = runtime.players().get(playerId); + var team = runtime.teams().get(teamId); + if (player == null || team == null) { + throw new IllegalArgumentException("minigames.error.invalid_team_assignment"); + } + player.teamId(teamId); + team.players().add(playerId); + } +} diff --git a/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java b/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java new file mode 100644 index 0000000..8e228a1 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java @@ -0,0 +1,229 @@ +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; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +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.volume.effect.AwardKillScoreEffect; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import static net.kewwbec.minigames.model.Enums.FriendlyFireKillScoreMode.LOSES_POINTS; + +public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem { + @Nonnull + private static final Query QUERY = Archetype.of(DeathComponent.getComponentType(), TransformComponent.getComponentType()); + + @Nonnull + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void onComponentAdded( + @Nonnull Ref victimRef, + @Nonnull DeathComponent component, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer + ) { + Damage deathInfo = component.getDeathInfo(); + if (!(deathInfo != null && deathInfo.getSource() instanceof Damage.EntitySource source)) { + return; + } + + Ref attackerRef = source.getRef(); + if (!attackerRef.isValid()) { + return; + } + + String attackerId = playerId(store, attackerRef); + if (attackerId == null || attackerId.isBlank()) { + return; + } + + var services = MinigameCorePlugin.getServices(); + if (services == null) { + return; + } + + MinigameRuntime runtime = services.runtime().runtimeForPlayer(attackerId).orElse(null); + if (runtime == null) { + return; + } + + String victimKind = victimKind(store, victimRef); + if (victimKind.isBlank()) { + return; + } + + TransformComponent transform = store.getComponent(victimRef, TransformComponent.getComponentType()); + TriggerVolumeManager manager = manager(store); + if (transform == null || manager == null) { + return; + } + + String victimPlayerId = playerId(store, victimRef); + boolean friendlyKill = victimPlayerId != null && sameTeam(runtime.players().get(attackerId), runtime.players().get(victimPlayerId)); + for (VolumeEntry volume : manager.getVolumes()) { + if (!matchesRuntime(volume, runtime) || !volume.getShape().contains(volume.getPosition(), transform.getPosition())) { + continue; + } + for (var effect : effects(volume)) { + if (effect instanceof AwardKillScoreEffect killScore && matchesRule(killScore, runtime, victimKind)) { + apply(runtime, attackerId, killScore, friendlyKill); + } + } + } + } + + @Nonnull + private static Iterable 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 MinigameRuntime runtime, + @Nonnull String attackerId, + @Nonnull AwardKillScoreEffect rule, + boolean friendlyKill + ) { + int points = Math.max(0, rule.points()); + if (friendlyKill) { + if (runtime.definition().getFriendlyFireKillScoreMode() != LOSES_POINTS) { + return; + } + points = -points; + } + + PlayerMinigameState player = runtime.players().get(attackerId); + if (player == null) { + return; + } + + if (rule.awardTarget() == AwardKillScoreEffect.AwardTarget.PLAYER || rule.awardTarget() == AwardKillScoreEffect.AwardTarget.BOTH) { + player.score(player.score() + points); + } + if ((rule.awardTarget() == AwardKillScoreEffect.AwardTarget.TEAM || rule.awardTarget() == AwardKillScoreEffect.AwardTarget.BOTH) + && player.teamId() != null && !player.teamId().isBlank()) { + var team = runtime.teams().get(player.teamId()); + if (team != null) { + team.score(team.score() + points); + } + } + + Map payload = new HashMap<>(); + payload.put("runtime_id", runtime.runtimeId()); + payload.put("minigame_id", runtime.minigameId()); + payload.put("map_id", runtime.mapId()); + payload.put("variant_id", runtime.variantId()); + payload.put("player_id", attackerId); + payload.put("points", points); + payload.put("friendly_kill", friendlyKill); + MinigameCorePlugin.getServices().runtime().dispatch(runtime, "on_kill_score", payload); + } + + private static boolean matchesRule(@Nonnull AwardKillScoreEffect rule, @Nonnull MinigameRuntime runtime, @Nonnull String victimKind) { + String ruleMinigame = rule.configuredMinigameId(); + if (!ruleMinigame.isBlank() && !ruleMinigame.equals(runtime.minigameId())) { + return false; + } + String normalizedVictim = normalize(victimKind); + for (String target : rule.targetIds()) { + String normalized = normalize(target); + if (normalized.equals("*") || normalized.equals(normalizedVictim)) { + return true; + } + } + return false; + } + + private static boolean matchesRuntime(@Nonnull VolumeEntry volume, @Nonnull MinigameRuntime runtime) { + Map tags = volume.getRawTags(); + 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())) { + return false; + } + if (!mapId.isBlank() && !mapId.equals(runtime.mapId())) { + return false; + } + return variantId.isBlank() || variantId.equals(runtime.variantId()); + } + + private static boolean sameTeam(@Nullable PlayerMinigameState attacker, @Nullable PlayerMinigameState victim) { + return attacker != null + && victim != null + && attacker.teamId() != null + && !attacker.teamId().isBlank() + && attacker.teamId().equals(victim.teamId()); + } + + @Nonnull + private static String victimKind(@Nonnull Store store, @Nonnull Ref victimRef) { + if (store.getComponent(victimRef, PlayerRef.getComponentType()) != null) { + return "Player"; + } + NPCEntity npc = store.getComponent(victimRef, NPCEntity.getComponentType()); + if (npc != null && npc.getRoleName() != null) { + return npc.getRoleName(); + } + return ""; + } + + @Nullable + private static String playerId(@Nonnull Store store, @Nonnull Ref ref) { + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player != null) { + return player.getUuid().toString(); + } + UUIDComponent uuid = store.getComponent(ref, UUIDComponent.getComponentType()); + return uuid != null ? uuid.getUuid().toString() : null; + } + + @Nullable + private static TriggerVolumeManager manager(@Nonnull Store store) { + TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get(); + return plugin != null ? store.getResource(plugin.getManagerResourceType()) : null; + } + + @Nonnull + private static String clean(@Nullable String value) { + return value != null ? value.trim() : ""; + } + + @Nonnull + private static String normalize(@Nullable String value) { + return clean(value).toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/CounterCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/CounterCondition.java new file mode 100644 index 0000000..ce57757 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/CounterCondition.java @@ -0,0 +1,57 @@ +package net.kewwbec.minigames.volume.condition; + +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 CounterCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "Counter"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(CounterCondition.class, CounterCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("CounterId", Codec.STRING), (condition, value) -> condition.counterId = value, condition -> condition.counterId) + .add() + .append(new KeyedCodec<>("Compare", new EnumCodec<>(NumberCompare.class), false), (condition, value) -> condition.compare = value, condition -> condition.compare) + .add() + .append(new KeyedCodec<>("Value", Codec.INTEGER), (condition, value) -> condition.value = value, condition -> condition.value) + .add() + .build(); + + private String counterId; + private NumberCompare compare = NumberCompare.GREATER_THAN_OR_EQUAL; + private int value; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + int actual = number(rt.get().variables().get("counter:" + counterId)); + NumberCompare op = compare != null ? compare : NumberCompare.GREATER_THAN_OR_EQUAL; + boolean result = op.test(actual, value); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " counter=" + counterId + " actual=" + actual + " " + op + " " + value); + return result; + } + + private static int number(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null) { + return 0; + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/CurrentRoundCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/CurrentRoundCondition.java new file mode 100644 index 0000000..896d160 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/CurrentRoundCondition.java @@ -0,0 +1,40 @@ +package net.kewwbec.minigames.volume.condition; + +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 CurrentRoundCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "CurrentRound"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(CurrentRoundCondition.class, CurrentRoundCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("Compare", new EnumCodec<>(NumberCompare.class), false), (condition, value) -> condition.compare = value, condition -> condition.compare) + .add() + .append(new KeyedCodec<>("Round", Codec.INTEGER), (condition, value) -> condition.round = value, condition -> condition.round) + .add() + .build(); + + private NumberCompare compare = NumberCompare.EQUAL; + private int round = 1; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + int actual = rt.get().roundNumber(); + NumberCompare op = compare != null ? compare : NumberCompare.EQUAL; + boolean result = op.test(actual, round); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " actualRound=" + actual + " " + op + " " + round); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/MinigamePhaseCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/MinigamePhaseCondition.java new file mode 100644 index 0000000..cef91da --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/MinigamePhaseCondition.java @@ -0,0 +1,37 @@ +package net.kewwbec.minigames.volume.condition; + +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 javax.annotation.Nonnull; + +public final class MinigamePhaseCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "MinigamePhase"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(MinigamePhaseCondition.class, MinigamePhaseCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("Phase", new EnumCodec<>(MinigamePhase.class)), (condition, value) -> condition.phase = value, condition -> condition.phase) + .add() + .build(); + + private MinigamePhase phase = MinigamePhase.ACTIVE; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + id + " no runtime found, expected phase=" + phase); + return false; + } + MinigamePhase actual = rt.get().phase(); + boolean result = actual == phase; + debugMessage(context, TYPE_ID, result, "minigame=" + id + " expected=" + phase + " actual=" + actual); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/MinigameRuntimeCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/MinigameRuntimeCondition.java new file mode 100644 index 0000000..66dc504 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/MinigameRuntimeCondition.java @@ -0,0 +1,116 @@ +package net.kewwbec.minigames.volume.condition; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition; +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 com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; +import java.util.Optional; + +public abstract class MinigameRuntimeCondition extends TriggerCondition { + + @Nonnull + public static final BuilderCodec MINIGAME_BASE_CODEC = + BuilderCodec.abstractBuilder(MinigameRuntimeCondition.class, BASE_CODEC) + .append( + new KeyedCodec<>("MinigameId", Codec.STRING, false), + (cond, value) -> cond.minigameId = value, + cond -> cond.minigameId + ) + .add() + .build(); + + @Nullable + protected String minigameId; + + private volatile long lastDebugMs = 0L; + + @Nonnull + protected Optional runtime(@Nonnull TriggerContext context) { + var services = MinigameCorePlugin.getServices(); + if (services == null) { + return Optional.empty(); + } + String playerId = resolvePlayerId(context, null); + if (playerId != null) { + Optional playerRuntime = services.runtime().runtimeForPlayer(playerId); + if (playerRuntime.isPresent()) { + return playerRuntime; + } + } + + var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : ""); + String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId; + if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) { + return Optional.empty(); + } + return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId()) + .or(() -> services.runtime().runtime(resolvedMinigameId)); + } + + @Nonnull + protected String resolvedMinigameId(@Nonnull TriggerContext context) { + var services = MinigameCorePlugin.getServices(); + if (services != null) { + var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : ""); + if (!candidate.minigameId().isBlank()) { + return candidate.minigameId(); + } + if (minigameId == null || minigameId.isBlank()) { + String playerId = resolvePlayerId(context, null); + if (playerId != null) { + var playerRuntime = services.runtime().runtimeForPlayer(playerId); + if (playerRuntime.isPresent()) { + return playerRuntime.get().minigameId(); + } + } + } + } + return minigameId != null ? minigameId : ""; + } + + protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String conditionType, boolean result, @Nonnull String details) { + String id = resolvedMinigameId(context); + if (id.isBlank()) return; + var def = MinigameDefinition.getAssetMap().getAsset(id); + if (def == null || !def.isDebug()) return; + long now = System.currentTimeMillis(); + if (now - lastDebugMs < 5000L) return; + lastDebugMs = now; + PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); + if (playerRef != null) { + playerRef.sendMessage( + Message.translation("minigames.debug.condition") + .param("type", conditionType) + .param("details", details) + .param("result", result ? "PASS" : "FAIL") + .color(result ? Color.GREEN : Color.RED) + ); + } + } + + @Nullable + protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) { + if (configuredPlayerId != null && !configuredPlayerId.isBlank()) { + return configuredPlayerId; + } + + PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUuid().toString(); + } + + UUIDComponent uuidComponent = context.getStore().getComponent(context.getEntityRef(), UUIDComponent.getComponentType()); + return uuidComponent != null ? uuidComponent.getUuid().toString() : null; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/NumberCompare.java b/src/main/java/net/kewwbec/minigames/volume/condition/NumberCompare.java new file mode 100644 index 0000000..a99d3ef --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/NumberCompare.java @@ -0,0 +1,21 @@ +package net.kewwbec.minigames.volume.condition; + +public enum NumberCompare { + EQUAL, + NOT_EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL; + + public boolean test(int actual, int expected) { + return switch (this) { + case EQUAL -> actual == expected; + case NOT_EQUAL -> actual != expected; + case GREATER_THAN -> actual > expected; + case GREATER_THAN_OR_EQUAL -> actual >= expected; + case LESS_THAN -> actual < expected; + case LESS_THAN_OR_EQUAL -> actual <= expected; + }; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/ObjectiveStatusCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/ObjectiveStatusCondition.java new file mode 100644 index 0000000..6d6f6d5 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/ObjectiveStatusCondition.java @@ -0,0 +1,50 @@ +package net.kewwbec.minigames.volume.condition; + +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.ObjectiveStatus; + +import javax.annotation.Nonnull; + +public final class ObjectiveStatusCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "ObjectiveStatus"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ObjectiveStatusCondition.class, ObjectiveStatusCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("ObjectiveId", Codec.STRING), (condition, value) -> condition.objectiveId = value, condition -> condition.objectiveId) + .add() + .append(new KeyedCodec<>("Status", new EnumCodec<>(ObjectiveStatus.class)), (condition, value) -> condition.status = value, condition -> condition.status) + .add() + .build(); + + private String objectiveId; + private ObjectiveStatus status = ObjectiveStatus.COMPLETE; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + if (objectiveId == null || objectiveId.isBlank()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no objective id configured"); + return false; + } + var objective = rt.get().objectives().get(objectiveId); + if (objective == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " objective='" + objectiveId + "' not found"); + return false; + } + ObjectiveStatus expected = status != null ? status : ObjectiveStatus.COMPLETE; + boolean result = objective.status() == expected; + debugMessage(context, TYPE_ID, result, + "minigame=" + game + " objective='" + objectiveId + "' status=" + objective.status() + " expected=" + expected); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/PlayerInMinigameCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerInMinigameCondition.java new file mode 100644 index 0000000..75dce95 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerInMinigameCondition.java @@ -0,0 +1,39 @@ +package net.kewwbec.minigames.volume.condition; + +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 javax.annotation.Nonnull; + +public final class PlayerInMinigameCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "PlayerInMinigame"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(PlayerInMinigameCondition.class, PlayerInMinigameCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (condition, value) -> condition.playerId = value, condition -> condition.playerId) + .add() + .build(); + + private String playerId; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + String game = resolvedMinigameId(context); + if (id == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no player resolved"); + return false; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " no runtime found"); + return false; + } + boolean result = rt.get().players().containsKey(id); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " player=" + id + (result ? " is in game" : " is NOT in game (playerCount=" + rt.get().players().size() + ")")); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/PlayerLivesCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerLivesCondition.java new file mode 100644 index 0000000..08a804e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerLivesCondition.java @@ -0,0 +1,52 @@ +package net.kewwbec.minigames.volume.condition; + +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 PlayerLivesCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "PlayerLives"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(PlayerLivesCondition.class, PlayerLivesCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (condition, value) -> condition.playerId = value, condition -> condition.playerId) + .add() + .append(new KeyedCodec<>("Compare", new EnumCodec<>(NumberCompare.class), false), (condition, value) -> condition.compare = value, condition -> condition.compare) + .add() + .append(new KeyedCodec<>("Value", Codec.INTEGER), (condition, value) -> condition.value = value, condition -> condition.value) + .add() + .build(); + + private String playerId; + private NumberCompare compare = NumberCompare.GREATER_THAN_OR_EQUAL; + private int value; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + String game = resolvedMinigameId(context); + if (id == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no player resolved"); + return false; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " no runtime found"); + return false; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " not in runtime"); + return false; + } + NumberCompare op = compare != null ? compare : NumberCompare.GREATER_THAN_OR_EQUAL; + boolean result = op.test(player.lives(), value); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " player=" + id + " lives=" + player.lives() + " " + op + " " + value); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/PlayerScoreCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerScoreCondition.java new file mode 100644 index 0000000..de60dcb --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerScoreCondition.java @@ -0,0 +1,52 @@ +package net.kewwbec.minigames.volume.condition; + +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 PlayerScoreCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "PlayerScore"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(PlayerScoreCondition.class, PlayerScoreCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (condition, value) -> condition.playerId = value, condition -> condition.playerId) + .add() + .append(new KeyedCodec<>("Compare", new EnumCodec<>(NumberCompare.class), false), (condition, value) -> condition.compare = value, condition -> condition.compare) + .add() + .append(new KeyedCodec<>("Value", Codec.INTEGER), (condition, value) -> condition.value = value, condition -> condition.value) + .add() + .build(); + + private String playerId; + private NumberCompare compare = NumberCompare.GREATER_THAN_OR_EQUAL; + private int value; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + String game = resolvedMinigameId(context); + if (id == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no player resolved"); + return false; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " no runtime found"); + return false; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " not in runtime"); + return false; + } + NumberCompare op = compare != null ? compare : NumberCompare.GREATER_THAN_OR_EQUAL; + boolean result = op.test(player.score(), value); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " player=" + id + " score=" + player.score() + " " + op + " " + value); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/PlayerStatusCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerStatusCondition.java new file mode 100644 index 0000000..d9ea5b5 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/PlayerStatusCondition.java @@ -0,0 +1,49 @@ +package net.kewwbec.minigames.volume.condition; + +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; + +public final class PlayerStatusCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "PlayerStatus"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(PlayerStatusCondition.class, PlayerStatusCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (condition, value) -> condition.playerId = value, condition -> condition.playerId) + .add() + .append(new KeyedCodec<>("Status", new EnumCodec<>(PlayerStatus.class)), (condition, value) -> condition.status = value, condition -> condition.status) + .add() + .build(); + + private String playerId; + private PlayerStatus status = PlayerStatus.ACTIVE; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + String game = resolvedMinigameId(context); + if (id == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no player resolved"); + return false; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " no runtime found"); + return false; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " not in runtime"); + return false; + } + boolean result = player.status() == status; + debugMessage(context, TYPE_ID, result, "minigame=" + game + " player=" + id + " expected=" + status + " actual=" + player.status()); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/TeamCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/TeamCondition.java new file mode 100644 index 0000000..ecca5be --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/TeamCondition.java @@ -0,0 +1,48 @@ +package net.kewwbec.minigames.volume.condition; + +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 javax.annotation.Nonnull; + +public final class TeamCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "TeamCondition"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(TeamCondition.class, TeamCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (condition, value) -> condition.playerId = value, condition -> condition.playerId) + .add() + .append(new KeyedCodec<>("TeamId", Codec.STRING), (condition, value) -> condition.teamId = value, condition -> condition.teamId) + .add() + .build(); + + private String playerId; + private String teamId; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + String game = resolvedMinigameId(context); + if (id == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no player resolved"); + return false; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " no runtime found"); + return false; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " player=" + id + " not in runtime"); + return false; + } + boolean result = teamId != null && teamId.equals(player.teamId()); + debugMessage(context, TYPE_ID, result, + "minigame=" + game + " player=" + id + " team=" + player.teamId() + " expected=" + teamId); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/TeamScoreCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/TeamScoreCondition.java new file mode 100644 index 0000000..104590e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/TeamScoreCondition.java @@ -0,0 +1,47 @@ +package net.kewwbec.minigames.volume.condition; + +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 TeamScoreCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "TeamScore"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(TeamScoreCondition.class, TeamScoreCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TeamId", Codec.STRING), (condition, value) -> condition.teamId = value, condition -> condition.teamId) + .add() + .append(new KeyedCodec<>("Compare", new EnumCodec<>(NumberCompare.class), false), (condition, value) -> condition.compare = value, condition -> condition.compare) + .add() + .append(new KeyedCodec<>("Value", Codec.INTEGER), (condition, value) -> condition.value = value, condition -> condition.value) + .add() + .build(); + + private String teamId; + private NumberCompare compare = NumberCompare.GREATER_THAN_OR_EQUAL; + private int value; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + var team = rt.get().teams().get(teamId); + if (team == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " team=" + teamId + " not found"); + return false; + } + NumberCompare op = compare != null ? compare : NumberCompare.GREATER_THAN_OR_EQUAL; + boolean result = op.test(team.score(), value); + debugMessage(context, TYPE_ID, result, "minigame=" + game + " team=" + teamId + " score=" + team.score() + " " + op + " " + value); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/TimerElapsedCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/TimerElapsedCondition.java new file mode 100644 index 0000000..a0b37cf --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/TimerElapsedCondition.java @@ -0,0 +1,43 @@ +package net.kewwbec.minigames.volume.condition; + +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 javax.annotation.Nonnull; + +public final class TimerElapsedCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "TimerElapsed"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(TimerElapsedCondition.class, TimerElapsedCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TimerName", Codec.STRING), (condition, value) -> condition.timerName = value, condition -> condition.timerName) + .add() + .build(); + + private String timerName; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + if (timerName == null || timerName.isBlank()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no timer name configured"); + return false; + } + Long expiry = rt.get().timers().get(timerName); + if (expiry == null) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " timer='" + timerName + "' not started"); + return false; + } + boolean result = System.currentTimeMillis() >= expiry; + debugMessage(context, TYPE_ID, result, "minigame=" + game + " timer='" + timerName + "' elapsed=" + result); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/condition/WinConditionMetCondition.java b/src/main/java/net/kewwbec/minigames/volume/condition/WinConditionMetCondition.java new file mode 100644 index 0000000..42bac2f --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/condition/WinConditionMetCondition.java @@ -0,0 +1,55 @@ +package net.kewwbec.minigames.volume.condition; + +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.ObjectiveStatus; +import net.kewwbec.minigames.model.Enums.PlayerStatus; +import net.kewwbec.minigames.model.Enums.WinCondition; + +import javax.annotation.Nonnull; + +public final class WinConditionMetCondition extends MinigameRuntimeCondition { + public static final String TYPE_ID = "WinConditionMet"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(WinConditionMetCondition.class, WinConditionMetCondition::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TargetScore", Codec.INTEGER, false), (condition, value) -> condition.targetScore = value, condition -> condition.targetScore) + .add() + .build(); + + private int targetScore = 0; + + @Override + public boolean test(@Nonnull TriggerContext context) { + String game = resolvedMinigameId(context); + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, false, "minigame=" + game + " no runtime found"); + return false; + } + + WinCondition winCondition = rt.get().definition().getWinCondition(); + boolean result = switch (winCondition) { + case LAST_ALIVE -> { + long alive = rt.get().players().values().stream() + .filter(p -> p.status() == PlayerStatus.ACTIVE) + .count(); + yield alive <= 1; + } + case FIRST_TO_SCORE -> rt.get().players().values().stream() + .anyMatch(p -> p.score() >= targetScore); + case OBJECTIVE_COMPLETE -> !rt.get().objectives().isEmpty() + && rt.get().objectives().values().stream() + .filter(o -> o.status() != ObjectiveStatus.INACTIVE) + .allMatch(o -> o.status() == ObjectiveStatus.COMPLETE); + default -> false; + }; + + debugMessage(context, TYPE_ID, result, + "minigame=" + game + " winCondition=" + winCondition + " result=" + result); + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/AssignTeamEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/AssignTeamEffect.java new file mode 100644 index 0000000..a66b520 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/AssignTeamEffect.java @@ -0,0 +1,61 @@ +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.TeamState; + +import javax.annotation.Nonnull; + +public final class AssignTeamEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "AssignTeam"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(AssignTeamEffect.class, AssignTeamEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId) + .add() + .append(new KeyedCodec<>("TeamId", Codec.STRING), (effect, value) -> effect.teamId = value, effect -> effect.teamId) + .add() + .build(); + + private String playerId; + private String teamId; + + @Override + public void execute(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + if (teamId == null || teamId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no team id configured)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + + // Remove from old team + String prevTeam = player.teamId(); + if (prevTeam != null && !prevTeam.isBlank()) { + var old = rt.get().teams().get(prevTeam); + if (old != null) old.players().remove(id); + } + + // Add to new team, auto-creating if needed + 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); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/AwardKillScoreEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/AwardKillScoreEffect.java new file mode 100644 index 0000000..9cf30ef --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/AwardKillScoreEffect.java @@ -0,0 +1,58 @@ +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 AwardKillScoreEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "AwardKillScore"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(AwardKillScoreEffect.class, AwardKillScoreEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TargetIds", Codec.STRING_ARRAY, false), (effect, value) -> effect.targetIds = value, effect -> effect.targetIds) + .add() + .append(new KeyedCodec<>("Points", Codec.INTEGER, false), (effect, value) -> effect.points = value, effect -> effect.points) + .add() + .append(new KeyedCodec<>("AwardTarget", new EnumCodec<>(AwardTarget.class), false), (effect, value) -> effect.awardTarget = value, effect -> effect.awardTarget) + .add() + .build(); + + private String[] targetIds = new String[] {"Player"}; + private int points = 1; + private AwardTarget awardTarget = AwardTarget.PLAYER; + + @Override + public void execute(@Nonnull TriggerContext context) { + debugMessage(context, TYPE_ID, "CONFIG_ONLY kill scoring is applied by the minigame death system"); + } + + @Nonnull + public String configuredMinigameId() { + return minigameId != null ? minigameId : ""; + } + + @Nonnull + public String[] targetIds() { + return targetIds != null ? targetIds : new String[0]; + } + + public int points() { + return points; + } + + @Nonnull + public AwardTarget awardTarget() { + return awardTarget != null ? awardTarget : AwardTarget.PLAYER; + } + + public enum AwardTarget { + PLAYER, + TEAM, + BOTH + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ClearInventoryEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ClearInventoryEffect.java new file mode 100644 index 0000000..81300a9 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ClearInventoryEffect.java @@ -0,0 +1,37 @@ +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 javax.annotation.Nonnull; + +public final class ClearInventoryEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "ClearInventory"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ClearInventoryEffect.class, ClearInventoryEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId) + .add() + .build(); + + private String playerId; + + @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 services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + services.adapters().clearInventory(id); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "'"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/EndMinigameEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/EndMinigameEffect.java new file mode 100644 index 0000000..0e93c23 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/EndMinigameEffect.java @@ -0,0 +1,38 @@ +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 javax.annotation.Nonnull; + +public final class EndMinigameEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "EndMinigame"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(EndMinigameEffect.class, EndMinigameEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("Reason", Codec.STRING, false), (effect, value) -> effect.reason = value, effect -> effect.reason) + .add() + .build(); + + private String reason = "minigames.runtime.reason.trigger_volume"; + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services null)"); + return; + } + var rt = runtime(context); + if (rt.isPresent()) { + String stopReason = reason != null ? reason : "minigames.runtime.reason.trigger_volume"; + services.runtime().end(rt.get().runtimeId(), stopReason); + debugMessage(context, TYPE_ID, "FIRED reason='" + stopReason + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/JoinMinigameQueueEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/JoinMinigameQueueEffect.java new file mode 100644 index 0000000..eeb6f0b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/JoinMinigameQueueEffect.java @@ -0,0 +1,30 @@ +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 javax.annotation.Nonnull; + +public final class JoinMinigameQueueEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "JoinMinigameQueue"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(JoinMinigameQueueEffect.class, JoinMinigameQueueEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + 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 { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java new file mode 100644 index 0000000..35e9486 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java @@ -0,0 +1,29 @@ +package net.kewwbec.minigames.volume.effect; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.codec.builder.BuilderCodec; + +import javax.annotation.Nonnull; + +public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "LeaveMinigameQueue"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(LeaveMinigameQueueEffect.class, LeaveMinigameQueueEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + String playerId = resolvePlayerId(context, null); + String id = resolvedMinigameId(context); + if (services != null && playerId != null && !id.isBlank()) { + services.queue().leave(id, playerId); + debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')"); + } + } +} + diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java new file mode 100644 index 0000000..a13c85d --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java @@ -0,0 +1,121 @@ +package net.kewwbec.minigames.volume.effect; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect; +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 com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; +import java.util.Optional; + +public abstract class MinigameRuntimeEffect extends TriggerEffect { + + @Nonnull + public static final BuilderCodec MINIGAME_BASE_CODEC = + BuilderCodec.abstractBuilder(MinigameRuntimeEffect.class, BASE_CODEC) + .append( + new KeyedCodec<>("MinigameId", Codec.STRING, false), + (effect, value) -> effect.minigameId = value, + effect -> effect.minigameId + ) + .add() + .build(); + + @Nullable + protected String minigameId; + + private volatile long lastDebugMs = 0L; + + @Nonnull + protected Optional runtime(@Nonnull TriggerContext context) { + MinigameServices services = MinigameCorePlugin.getServices(); + if (services == null) { + return Optional.empty(); + } + String playerId = resolvePlayerId(context, null); + if (playerId != null) { + Optional playerRuntime = services.runtime().runtimeForPlayer(playerId); + if (playerRuntime.isPresent()) { + return playerRuntime; + } + } + + var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : ""); + String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId; + if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) { + return Optional.empty(); + } + return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId()) + .or(() -> services.runtime().runtime(resolvedMinigameId)); + } + + @Nullable + protected static MinigameServices services() { + return MinigameCorePlugin.getServices(); + } + + @Nonnull + protected String resolvedMinigameId(@Nonnull TriggerContext context) { + var services = services(); + if (services != null) { + var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : ""); + if (!candidate.minigameId().isBlank()) { + return candidate.minigameId(); + } + if (minigameId == null || minigameId.isBlank()) { + String playerId = resolvePlayerId(context, null); + if (playerId != null) { + var playerRuntime = services.runtime().runtimeForPlayer(playerId); + if (playerRuntime.isPresent()) { + return playerRuntime.get().minigameId(); + } + } + } + } + return minigameId != null ? minigameId : ""; + } + + protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) { + String id = resolvedMinigameId(context); + if (id.isBlank()) return; + var def = MinigameDefinition.getAssetMap().getAsset(id); + if (def == null || !def.isDebug()) return; + long now = System.currentTimeMillis(); + if (now - lastDebugMs < 5000L) return; + lastDebugMs = now; + PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); + if (playerRef != null) { + playerRef.sendMessage( + Message.translation("minigames.debug.effect") + .param("type", effectType) + .param("details", details) + .color(Color.CYAN) + ); + } + } + + @Nullable + protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) { + if (configuredPlayerId != null && !configuredPlayerId.isBlank()) { + return configuredPlayerId; + } + + PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUuid().toString(); + } + + UUIDComponent uuidComponent = context.getStore().getComponent(context.getEntityRef(), UUIDComponent.getComponentType()); + return uuidComponent != null ? uuidComponent.getUuid().toString() : null; + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyCounterEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyCounterEffect.java new file mode 100644 index 0000000..bf504d4 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyCounterEffect.java @@ -0,0 +1,61 @@ +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 ModifyCounterEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "ModifyCounter"; + + @Nonnull + public static final BuilderCodec CODEC = + 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) + .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 int amount = 1; + + @Override + public void execute(@Nonnull TriggerContext context) { + if (counterId == null || counterId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (counterId blank)"); + return; + } + + var rt = runtime(context); + if (rt.isPresent()) { + String key = "counter:" + counterId; + int current = number(rt.get().variables().get(key)); + int next = ModifyPlayerScoreEffect.apply(current, operation, amount); + rt.get().variables().put(key, next); + debugMessage(context, TYPE_ID, "FIRED counter='" + counterId + "' " + current + "->" + next); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } + + private static int number(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null) { + return 0; + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyObjectiveProgressEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyObjectiveProgressEffect.java new file mode 100644 index 0000000..c053fe7 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyObjectiveProgressEffect.java @@ -0,0 +1,63 @@ +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.ObjectiveStatus; +import net.kewwbec.minigames.model.ObjectiveState; + +import javax.annotation.Nonnull; +import java.util.Map; + +public final class ModifyObjectiveProgressEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "ModifyObjectiveProgress"; + + @Nonnull + public static final BuilderCodec CODEC = + 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) + .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 int amount = 1; + + @Override + public void execute(@Nonnull TriggerContext context) { + if (objectiveId == null || objectiveId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no objective id configured)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + + var objective = rt.get().objectives().computeIfAbsent(objectiveId, + k -> new ObjectiveState(k, k, "generic")); + + int prev = objective.progress(); + int next = Math.max(0, ModifyPlayerScoreEffect.apply(prev, operation, amount)); + objective.progress(next); + + if (next >= objective.requiredProgress() && objective.status() == ObjectiveStatus.ACTIVE) { + objective.status(ObjectiveStatus.COMPLETE); + var services = services(); + if (services != null) { + services.runtime().dispatch(rt.get(), "on_objective_complete", + Map.of("objective_id", objectiveId, "progress", next)); + } + } + + debugMessage(context, TYPE_ID, "FIRED objective='" + objectiveId + "' progress=" + prev + "->" + next + + "/" + objective.requiredProgress()); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerLivesEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerLivesEffect.java new file mode 100644 index 0000000..9e6f927 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerLivesEffect.java @@ -0,0 +1,64 @@ +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 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) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerScoreEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerScoreEffect.java new file mode 100644 index 0000000..e9f169b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyPlayerScoreEffect.java @@ -0,0 +1,66 @@ +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 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 + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyTeamScoreEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyTeamScoreEffect.java new file mode 100644 index 0000000..32488df --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyTeamScoreEffect.java @@ -0,0 +1,51 @@ +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 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) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ResetMinigameEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ResetMinigameEffect.java new file mode 100644 index 0000000..06d9c1b --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ResetMinigameEffect.java @@ -0,0 +1,33 @@ +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 javax.annotation.Nonnull; + +public final class ResetMinigameEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "ResetMinigame"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ResetMinigameEffect.class, ResetMinigameEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services null)"); + return; + } + var rt = runtime(context); + if (rt.isPresent()) { + services.runtime().reset(rt.get().runtimeId()); + debugMessage(context, TYPE_ID, "FIRED minigame='" + resolvedMinigameId(context) + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ResetScoresEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ResetScoresEffect.java new file mode 100644 index 0000000..eec5142 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ResetScoresEffect.java @@ -0,0 +1,29 @@ +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 javax.annotation.Nonnull; + +public final class ResetScoresEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "ResetScores"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ResetScoresEffect.class, ResetScoresEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + var rt = runtime(context); + if (rt.isPresent()) { + rt.get().players().values().forEach(player -> player.score(0)); + rt.get().teams().values().forEach(team -> team.score(0)); + debugMessage(context, TYPE_ID, "FIRED reset all scores"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/RespawnPlayerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/RespawnPlayerEffect.java new file mode 100644 index 0000000..e6f68fc --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/RespawnPlayerEffect.java @@ -0,0 +1,60 @@ +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 javax.annotation.Nonnull; + +public final class RespawnPlayerEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "RespawnPlayer"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(RespawnPlayerEffect.class, RespawnPlayerEffect::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, false), (effect, value) -> effect.destinationId = value, effect -> effect.destinationId) + .add() + .build(); + + private String playerId; + /** Overrides the player's stored spawn point for this respawn only. */ + private String destinationId; + + @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.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + String dest = (destinationId != null && !destinationId.isBlank()) ? destinationId : player.checkpointId(); + if ((dest == null || dest.isBlank()) && player.teamId() != null && !player.teamId().isBlank()) { + var services = services(); + if (services != null) { + dest = services.maps().teamSpawnDestination(context.getStore(), rt.get(), player.teamId()); + } + } + if (dest == null || dest.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' has no spawn point or team spawn set)"); + return; + } + var services = services(); + if (services != null) { + services.adapters().teleport(id, dest); + } + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' -> '" + dest + "'"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/RestoreInventoryEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/RestoreInventoryEffect.java new file mode 100644 index 0000000..18e9292 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/RestoreInventoryEffect.java @@ -0,0 +1,51 @@ +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 javax.annotation.Nonnull; + +public final class RestoreInventoryEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "RestoreInventory"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(RestoreInventoryEffect.class, RestoreInventoryEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId) + .add() + .build(); + + private String playerId; + + @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.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + if (player.savedInventory().isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' has no saved inventory)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + services.adapters().restoreInventory(id, player.savedInventory()); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' restored " + player.savedInventory().size() + " slots"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SaveInventoryEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SaveInventoryEffect.java new file mode 100644 index 0000000..cd1f25a --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SaveInventoryEffect.java @@ -0,0 +1,49 @@ +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 javax.annotation.Nonnull; + +public final class SaveInventoryEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SaveInventory"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SaveInventoryEffect.class, SaveInventoryEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId) + .add() + .build(); + + private String playerId; + + @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.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + var snapshot = services.adapters().saveInventory(id); + player.savedInventory().clear(); + player.savedInventory().putAll(snapshot); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' saved " + snapshot.size() + " slots"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SendMinigameMessageEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SendMinigameMessageEffect.java new file mode 100644 index 0000000..20a7e06 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SendMinigameMessageEffect.java @@ -0,0 +1,77 @@ +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 com.hypixel.hytale.server.core.Message; +import net.kewwbec.minigames.model.Enums.PlayerStatus; + +import javax.annotation.Nonnull; + +public final class SendMinigameMessageEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SendMinigameMessage"; + + public enum Target { PLAYER, TEAM, ALL } + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SendMinigameMessageEffect.class, SendMinigameMessageEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("MessageKey", Codec.STRING), (effect, value) -> effect.messageKey = value, effect -> effect.messageKey) + .add() + .append(new KeyedCodec<>("Target", new EnumCodec<>(Target.class), false), (effect, value) -> effect.target = value, effect -> effect.target) + .add() + .build(); + + private String messageKey; + private Target target = Target.PLAYER; + + @Override + public void execute(@Nonnull TriggerContext context) { + if (messageKey == null || messageKey.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no message key configured)"); + return; + } + + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + + Target resolvedTarget = target != null ? target : Target.PLAYER; + Message msg = Message.translation(messageKey); + + switch (resolvedTarget) { + case PLAYER -> { + String id = resolvePlayerId(context, null); + if (id != null) services.adapters().sendMessage(id, msg); + } + case TEAM -> { + String id = resolvePlayerId(context, null); + if (id == null) break; + var rt = runtime(context); + if (rt.isEmpty()) break; + var triggeringPlayer = rt.get().players().get(id); + if (triggeringPlayer == null) break; + String team = triggeringPlayer.teamId(); + if (team == null || team.isBlank()) break; + rt.get().players().forEach((pid, p) -> { + if (team.equals(p.teamId())) services.adapters().sendMessage(pid, msg); + }); + } + case ALL -> { + var rt = runtime(context); + if (rt.isEmpty()) break; + rt.get().players().forEach((pid, p) -> { + if (p.status() == PlayerStatus.ACTIVE || p.status() == PlayerStatus.SPECTATOR) { + services.adapters().sendMessage(pid, msg); + } + }); + } + } + + debugMessage(context, TYPE_ID, "FIRED key='" + messageKey + "' target=" + resolvedTarget); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetMinigamePhaseEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetMinigamePhaseEffect.java new file mode 100644 index 0000000..d435c62 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetMinigamePhaseEffect.java @@ -0,0 +1,42 @@ +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 { + public static final String TYPE_ID = "SetMinigamePhase"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetMinigamePhaseEffect.class, SetMinigamePhaseEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("Phase", new EnumCodec<>(MinigamePhase.class)), (effect, value) -> effect.phase = value, effect -> effect.phase) + .add() + .build(); + + private MinigamePhase phase = MinigamePhase.ACTIVE; + + @Override + public void execute(@Nonnull TriggerContext context) { + MinigamePhase target = phase != null ? phase : MinigamePhase.ACTIVE; + 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 { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetObjectiveStatusEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetObjectiveStatusEffect.java new file mode 100644 index 0000000..586d9a4 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetObjectiveStatusEffect.java @@ -0,0 +1,58 @@ +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.ObjectiveStatus; +import net.kewwbec.minigames.model.ObjectiveState; + +import javax.annotation.Nonnull; +import java.util.Map; + +public final class SetObjectiveStatusEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SetObjectiveStatus"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetObjectiveStatusEffect.class, SetObjectiveStatusEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("ObjectiveId", Codec.STRING), (effect, value) -> effect.objectiveId = value, effect -> effect.objectiveId) + .add() + .append(new KeyedCodec<>("Status", new EnumCodec<>(ObjectiveStatus.class)), (effect, value) -> effect.status = value, effect -> effect.status) + .add() + .build(); + + private String objectiveId; + private ObjectiveStatus status = ObjectiveStatus.ACTIVE; + + @Override + public void execute(@Nonnull TriggerContext context) { + if (objectiveId == null || objectiveId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no objective id configured)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + + ObjectiveStatus target = status != null ? status : ObjectiveStatus.ACTIVE; + var objective = rt.get().objectives().computeIfAbsent(objectiveId, + k -> new ObjectiveState(k, k, "generic")); + + ObjectiveStatus prev = objective.status(); + objective.status(target); + + if (target == ObjectiveStatus.COMPLETE && prev != ObjectiveStatus.COMPLETE) { + var services = services(); + if (services != null) { + services.runtime().dispatch(rt.get(), "on_objective_complete", + Map.of("objective_id", objectiveId)); + } + } + + debugMessage(context, TYPE_ID, "FIRED objective='" + objectiveId + "' status=" + prev + "->" + target); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java new file mode 100644 index 0000000..c90cba2 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java @@ -0,0 +1,64 @@ +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 SetPlayerStatusEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SetPlayerStatus"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetPlayerStatusEffect.class, SetPlayerStatusEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId) + .add() + .append(new KeyedCodec<>("Status", new EnumCodec<>(PlayerStatus.class)), (effect, value) -> effect.status = value, effect -> effect.status) + .add() + .build(); + + private String playerId; + private PlayerStatus status = PlayerStatus.ACTIVE; + + @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) { + PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE; + player.status(resolvedStatus); + if (resolvedStatus == PlayerStatus.SPECTATOR) { + rt.get().spectators().add(id); + } else { + rt.get().spectators().remove(id); + } + if (resolvedStatus == 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)); + } + } else { + rt.get().eliminatedPlayers().remove(id); + } + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' status=" + resolvedStatus); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + } + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetRoundEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetRoundEffect.java new file mode 100644 index 0000000..6f0b234 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetRoundEffect.java @@ -0,0 +1,57 @@ +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 java.util.Map; + +import javax.annotation.Nonnull; + +public final class SetRoundEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SetRound"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetRoundEffect.class, SetRoundEffect::new, MINIGAME_BASE_CODEC) + .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 ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.SET; + 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; + } + + int current = rt.get().roundNumber(); + int next = Math.max(1, ModifyPlayerScoreEffect.apply(current, operation, amount)); + rt.get().roundNumber(next); + + var services = services(); + if (services != null) { + services.signals().onRoundStart(rt.get(), next); + + int roundLen = rt.get().definition().getRoundLengthSeconds(); + if (roundLen > 0) { + rt.get().variables().put("$roundTimerTarget", next); + services.signals().startTimer(rt.get(), "$round", roundLen * 1000L); + } + + services.runtime().dispatch(rt.get(), "on_round_start", Map.of("round", next)); + } + + debugMessage(context, TYPE_ID, "FIRED round " + current + "->" + next); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetSpawnPointEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetSpawnPointEffect.java new file mode 100644 index 0000000..ad836d6 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetSpawnPointEffect.java @@ -0,0 +1,45 @@ +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 javax.annotation.Nonnull; + +public final class SetSpawnPointEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SetSpawnPoint"; + + @Nonnull + public static final BuilderCodec CODEC = + 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) + .add() + .build(); + + private String playerId; + private String destinationId; + + @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.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + player.checkpointId(destinationId); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' spawnPoint='" + destinationId + "'"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java new file mode 100644 index 0000000..16a2501 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java @@ -0,0 +1,130 @@ +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.component.AddReason; +import com.hypixel.hytale.component.Holder; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.modules.entity.item.ItemComponent; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class SpawnItemEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "SpawnItem"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SpawnItemEffect.class, SpawnItemEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId) + .add() + .append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount) + .add() + .append( + new KeyedCodec<>("RespawnDelaySeconds", Codec.INTEGER, false), + (effect, value) -> effect.respawnDelaySeconds = value, + effect -> effect.respawnDelaySeconds + ) + .add() + .append(new KeyedCodec<>("SpawnKey", Codec.STRING, false), (effect, value) -> effect.spawnKey = value, effect -> effect.spawnKey) + .add() + .append(new KeyedCodec<>("OffsetX", Codec.DOUBLE, false), (effect, value) -> effect.offsetX = value, effect -> effect.offsetX) + .add() + .append(new KeyedCodec<>("OffsetY", Codec.DOUBLE, false), (effect, value) -> effect.offsetY = value, effect -> effect.offsetY) + .add() + .append(new KeyedCodec<>("OffsetZ", Codec.DOUBLE, false), (effect, value) -> effect.offsetZ = value, effect -> effect.offsetZ) + .add() + .build(); + + private String itemId = ""; + private int amount = 1; + private int respawnDelaySeconds = 0; + private String spawnKey = ""; + private double offsetX = 0.0D; + private double offsetY = 0.0D; + private double offsetZ = 0.0D; + + private final transient Map> activeItems = new ConcurrentHashMap<>(); + private final transient Map nextSpawnTimes = new ConcurrentHashMap<>(); + + @Override + public void execute(@Nonnull TriggerContext context) { + if (itemId == null || itemId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED missing ItemId"); + return; + } + + String key = key(context); + Ref active = activeItems.get(key); + if (active != null && active.isValid()) { + return; + } + + if (active != null) { + activeItems.remove(key); + int delay = Math.max(0, respawnDelaySeconds); + if (delay > 0 && !nextSpawnTimes.containsKey(key)) { + nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay))); + return; + } + } + + Instant nextSpawn = nextSpawnTimes.get(key); + if (nextSpawn != null && Instant.now().isBefore(nextSpawn)) { + return; + } + + spawn(context, key); + } + + private void spawn(@Nonnull TriggerContext context, @Nonnull String key) { + ItemStack stack = new ItemStack(itemId, Math.max(1, amount)); + Vector3d position = new Vector3d(context.getVolume().getPosition()).add(offsetX, offsetY, offsetZ); + Holder holder = ItemComponent.generateItemDrop( + context.getStore(), + stack, + position, + Rotation3f.IDENTITY, + 0.0F, + 0.0F, + 0.0F + ); + if (holder == null) { + debugMessage(context, TYPE_ID, "SKIPPED invalid item='" + itemId + "'"); + return; + } + + Ref ref = context.getStore().addEntity(holder, AddReason.SPAWN); + if (ref == null) { + debugMessage(context, TYPE_ID, "SKIPPED failed to add item='" + itemId + "'"); + return; + } + + activeItems.put(key, ref); + nextSpawnTimes.remove(key); + runtime(context).ifPresent(runtime -> { + UUIDComponent uuid = context.getStore().getComponent(ref, UUIDComponent.getComponentType()); + if (uuid != null) { + runtime.temporaryEntities().add(uuid.getUuid().toString()); + } + }); + debugMessage(context, TYPE_ID, "FIRED spawned item='" + itemId + "' amount=" + Math.max(1, amount)); + } + + @Nonnull + private String key(@Nonnull TriggerContext context) { + String configured = spawnKey != null ? spawnKey.trim() : ""; + String volumeId = context.getVolume().getId(); + return volumeId + "|" + (!configured.isBlank() ? configured : itemId); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StartMinigameEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StartMinigameEffect.java new file mode 100644 index 0000000..06461a7 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StartMinigameEffect.java @@ -0,0 +1,35 @@ +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 javax.annotation.Nonnull; + +public final class StartMinigameEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StartMinigame"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartMinigameEffect.class, StartMinigameEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + String id = resolvedMinigameId(context); + if (services == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " id='" + id + "')"); + return; + } + var defOpt = services.minigames().definition(id); + if (defOpt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + return; + } + var candidate = services.maps().candidateFromVolume(context, id); + services.runtime().start(defOpt.get(), candidate); + debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "'"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java new file mode 100644 index 0000000..e90a977 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java @@ -0,0 +1,36 @@ +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 javax.annotation.Nonnull; + +public final class StartQueuedMinigameEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StartQueuedMinigame"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartQueuedMinigameEffect.class, StartQueuedMinigameEffect::new, MINIGAME_BASE_CODEC) + .build(); + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + String id = resolvedMinigameId(context); + if (services == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " id='" + id + "')"); + return; + } + + var defOpt = services.minigames().definition(id); + if (defOpt.isPresent()) { + var discovered = services.maps().discover(context, id); + services.queue().startQueued(defOpt.get(), discovered.candidates(), services.runtime()); + debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size()); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StartTimerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StartTimerEffect.java new file mode 100644 index 0000000..a1b7c4d --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StartTimerEffect.java @@ -0,0 +1,45 @@ +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 javax.annotation.Nonnull; + +public final class StartTimerEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StartTimer"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartTimerEffect.class, StartTimerEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TimerName", Codec.STRING), (effect, value) -> effect.timerName = value, effect -> effect.timerName) + .add() + .append(new KeyedCodec<>("DurationSeconds", Codec.INTEGER), (effect, value) -> effect.durationSeconds = value, effect -> effect.durationSeconds) + .add() + .build(); + + private String timerName; + private int durationSeconds = 60; + + @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; + } + if (timerName == null || timerName.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no timer name configured)"); + return; + } + long durationMs = Math.max(1, durationSeconds) * 1000L; + var services = services(); + if (services != null) { + services.signals().startTimer(rt.get(), timerName, durationMs); + } else { + rt.get().timers().put(timerName, System.currentTimeMillis() + durationMs); + } + debugMessage(context, TYPE_ID, "FIRED timer='" + timerName + "' duration=" + durationSeconds + "s"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StopTimerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StopTimerEffect.java new file mode 100644 index 0000000..138c2f8 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StopTimerEffect.java @@ -0,0 +1,40 @@ +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 javax.annotation.Nonnull; + +public final class StopTimerEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StopTimer"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StopTimerEffect.class, StopTimerEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("TimerName", Codec.STRING), (effect, value) -> effect.timerName = value, effect -> effect.timerName) + .add() + .build(); + + private String timerName; + + @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; + } + if (timerName == null || timerName.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no timer name configured)"); + return; + } + rt.get().timers().remove(timerName); + var services = services(); + if (services != null) { + services.signals().cancelTimer(rt.get().runtimeId(), timerName); + } + debugMessage(context, TYPE_ID, "FIRED timer='" + timerName + "' stopped"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/VoteForMapEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/VoteForMapEffect.java new file mode 100644 index 0000000..c91bf05 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/VoteForMapEffect.java @@ -0,0 +1,37 @@ +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 javax.annotation.Nonnull; + +public final class VoteForMapEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "VoteForMap"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(VoteForMapEffect.class, VoteForMapEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("MapId", Codec.STRING, false), (effect, value) -> effect.mapId = value, effect -> effect.mapId) + .add() + .build(); + + private String mapId; + + @Override + public void execute(@Nonnull TriggerContext context) { + var services = services(); + String playerId = resolvePlayerId(context, null); + String id = resolvedMinigameId(context); + String selectedMapId = mapId != null && !mapId.isBlank() + ? 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 + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "' map='" + selectedMapId + "')"); + } + } +} diff --git a/src/main/resources/Server/Languages/en-US/minigames.lang b/src/main/resources/Server/Languages/en-US/minigames.lang new file mode 100644 index 0000000..1e952a4 --- /dev/null +++ b/src/main/resources/Server/Languages/en-US/minigames.lang @@ -0,0 +1,140 @@ +command.status.empty = core-minigames is loaded with {commandCount} scaffolded admin commands. No minigames are loaded yet. +command.status.loaded = Loaded minigames: {minigames}. {commandCount} admin commands are scaffolded. +command.root.description = Admin entry point for core-minigames. + +command.spec.create.usage = /minigame create +command.spec.create.description = Create a local minigame definition. +command.spec.edit.usage = /minigame edit +command.spec.edit.description = Open the editor for a minigame. +command.spec.delete.usage = /minigame delete +command.spec.delete.description = Delete a local minigame definition. +command.spec.enable.usage = /minigame enable +command.spec.enable.description = Enable a minigame. +command.spec.disable.usage = /minigame disable +command.spec.disable.description = Disable a minigame. +command.spec.start.usage = /minigame start +command.spec.start.description = Start a local runtime. +command.spec.stop.usage = /minigame stop +command.spec.stop.description = End a local runtime. +command.spec.reset.usage = /minigame reset +command.spec.reset.description = Reset arena and runtime state. +command.spec.validate.usage = /minigame validate +command.spec.validate.description = Show creator-readable validation warnings. +command.spec.export.usage = /minigame export +command.spec.export.description = Write a JSON export. +command.spec.import.usage = /minigame import +command.spec.import.description = Import a JSON definition. +command.spec.list.usage = /minigame list +command.spec.list.description = List configured minigames. +command.spec.info.usage = /minigame info +command.spec.info.description = Show definition and runtime summary. +command.spec.maps.usage = /minigame maps +command.spec.maps.description = List trigger-volume maps discovered for a minigame. +command.spec.vote.usage = /minigame vote +command.spec.vote.description = Vote for a map in the minigame waiting session. +command.spec.join.usage = /minigame join +command.spec.join.description = Join a minigame queue. +command.spec.leave.usage = /minigame leave +command.spec.leave.description = Leave the current minigame. +command.spec.spectate.usage = /minigame spectate +command.spec.spectate.description = Join as a spectator. +command.spec.volume.create.usage = /minigame volume create +command.spec.volume.create.description = Create a minigame volume. +command.spec.volume.link.usage = /minigame volume link +command.spec.volume.link.description = Attach a volume to a minigame. +command.spec.volume.edit.usage = /minigame volume edit +command.spec.volume.edit.description = Edit volume config. +command.spec.volume.test.usage = /minigame volume test +command.spec.volume.test.description = Fire a test volume event. + +command.spec.info.arg.id = Minigame ID +command.spec.start.arg.id = Minigame ID +command.spec.stop.arg.id = Minigame ID +command.spec.stop.arg.reason = Reason for stopping +command.spec.stop.arg.reason.default = admin stop +command.spec.create.arg.id = Unique minigame identifier +command.spec.create.arg.name = Display name +command.spec.delete.arg.id = Minigame ID +command.spec.enable.arg.id = Minigame ID +command.spec.disable.arg.id = Minigame ID +command.spec.reset.arg.id = Minigame ID +command.spec.validate.arg.id = Minigame ID + +command.error.not_found = Minigame '{id}' was not found. +command.error.already_exists = A minigame with id '{id}' already exists. + +command.list.empty = No minigames are registered. +command.list.header = === Minigames ({count}) === +command.list.entry = {id} - {name} [enabled={enabled}, running={running}] + +command.info.header = [{id}] {name} | {description} | Enabled: {enabled} | Running: {running} | Type: {gameType} | World: {worldId} | Players: {minPlayers}-{maxPlayers} | Teams: {teamMode} | Win: {winCondition} | Round: {roundLength}s | Midgame Join: {allowJoinMidgame} | Spectators: {allowSpectators} | PvP: {allowPvp} + +command.start.error.disabled = Minigame '{id}' is disabled and cannot be started. +command.start.success = Minigame '{id}' has been started. + +command.stop.error.not_running = Minigame '{id}' is not currently running. +command.stop.success = Minigame '{id}' has been stopped. + +command.create.success = Minigame '{id}' ("{name}") created. + +command.delete.success = Minigame '{id}' deleted. + +command.enable.success = Minigame '{id}' enabled. + +command.disable.success = Minigame '{id}' disabled. + +command.reset.success = Minigame '{id}' runtime reset. + +command.validate.success = Minigame '{id}' passed all validation checks. +command.validate.issues = Minigame '{id}' has {count} validation issue(s): + +debug.condition = [Debug] {type}: {details} -> {result} +debug.effect = [Debug] Effect {type}: {details} + +template.checklist.root_area = Tag a Hytale trigger volume with MinigameId and MapId. +template.checklist.spawn_or_objective = Add spawn, scoring, or objective trigger volumes. +template.checklist.validate_before_enable = Validate before enabling. + +map.tag.minigame_id = MinigameId +map.tag.map_id = MapId +map.tag.variant_id = VariantId +map.tag.map_role = MapRole +map.role.main_arena = MainArena + +validation.minigame_missing = Minigame does not exist: {id} +validation.display_name_missing = Minigame has no display name. +validation.min_players_below_one = Minimum players must be at least 1. +validation.max_players_below_min = Maximum players cannot be below minimum players. +validation.round_length_not_positive = Round length must be positive. +validation.countdown_negative = Countdown cannot be negative. +validation.warning.no_maps_discovered = No trigger-volume maps were discovered for {id}. +validation.warning.map_missing_main_arena = Map {mapId} has no volume tagged MapRole=MainArena. +validation.warning.map_volume_missing_minigame = Volume {volumeId} has MapId but no MinigameId tag. +validation.data_root_missing = Local minigame data root is not configured. + +runtime.reason.definition_deleted = Definition deleted. +runtime.reason.plugin_shutdown = Plugin shutdown. +error.unknown_minigame = Unknown minigame: {id} +error.duplicate_volume_type = Duplicate volume type: {type} +error.event_chain_max_depth = Event chain exceeded max depth {maxDepth} at {eventId} +error.invalid_team_assignment = Invalid team assignment. + +example.dockside_derby.name = Dockside Derby +example.dockside_derby.description = Example of an interaction-driven score game. This happens to use fishing items, but the core rules are generic. +example.dockside_derby.volume.arena.name = Dockside Derby Arena +example.dockside_derby.volume.main_lake_interaction.name = Main Lake Interaction Zone +example.dockside_derby.volume.catch_score_rules.name = Catch Score Rules +example.dockside_derby.volume.valid_result_streak.name = Consecutive Valid Results +example.dockside_derby.rare_result = {player} found a rare result! + +event.on_round_start = Round {round} started. +event.on_phase_change = Game phase changed to {phase}. +event.on_player_eliminated = Player {player_id} was eliminated. +event.on_objective_complete = Objective {objective_id} complete. +event.on_timer_expired = Timer {timer_name} expired. +event.on_game_won = The game has been won. +event.on_round_end = Round {round} ended. +event.on_game_rounds_complete = All {final_round} rounds have been played. + +endscores.header = --- Final Scores --- +endscores.entry = {rank}. {player}: {score} diff --git a/src/main/resources/Server/Languages/en-US/server.lang b/src/main/resources/Server/Languages/en-US/server.lang new file mode 100644 index 0000000..d2fc668 --- /dev/null +++ b/src/main/resources/Server/Languages/en-US/server.lang @@ -0,0 +1,193 @@ +customUI.triggerVolumeEffectEditor.field.common.MinigameId = Minigame +customUI.triggerVolumeEffectEditor.field.common.MinigameId.tooltip = The minigame definition or runtime this condition or effect targets. +customUI.triggerVolumeEffectEditor.field.common.PlayerId = Player +customUI.triggerVolumeEffectEditor.field.common.PlayerId.tooltip = Optional player UUID. Leave empty to use the entity that triggered the volume. +customUI.triggerVolumeEffectEditor.field.common.TeamId = Team +customUI.triggerVolumeEffectEditor.field.common.TeamId.tooltip = The team id to read or modify. +customUI.triggerVolumeEffectEditor.field.common.CounterId = Counter +customUI.triggerVolumeEffectEditor.field.common.CounterId.tooltip = The runtime counter id to read or modify. +customUI.triggerVolumeEffectEditor.field.common.Phase = Phase +customUI.triggerVolumeEffectEditor.field.common.Phase.tooltip = The minigame phase to compare against or apply. +customUI.triggerVolumeEffectEditor.field.common.Status = Status +customUI.triggerVolumeEffectEditor.field.common.Status.tooltip = The player state to compare against or apply. +customUI.triggerVolumeEffectEditor.field.common.Operation = Operation +customUI.triggerVolumeEffectEditor.field.common.Operation.tooltip = How the current value should be changed. +customUI.triggerVolumeEffectEditor.field.common.Amount = Amount +customUI.triggerVolumeEffectEditor.field.common.Amount.tooltip = The value used by the selected operation. +customUI.triggerVolumeEffectEditor.field.common.Compare = Compare +customUI.triggerVolumeEffectEditor.field.common.Compare.tooltip = How the current value should be compared. +customUI.triggerVolumeEffectEditor.field.common.Value = Value +customUI.triggerVolumeEffectEditor.field.common.Value.tooltip = The value to compare against. +customUI.triggerVolumeEffectEditor.field.common.Round = Round +customUI.triggerVolumeEffectEditor.field.common.Round.tooltip = The round number to compare against. +customUI.triggerVolumeEffectEditor.field.common.Reason = Reason +customUI.triggerVolumeEffectEditor.field.common.Reason.tooltip = Runtime end reason used when the minigame is ended. +customUI.triggerVolumeEffectEditor.field.common.MapId = Map +customUI.triggerVolumeEffectEditor.field.common.MapId.tooltip = The map id to vote for. Leave empty to use the triggering volume's MapId tag. +customUI.triggerVolumeEffectEditor.field.common.ItemId = Item +customUI.triggerVolumeEffectEditor.field.common.ItemId.tooltip = The item asset id to spawn on the ground. +customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds = Respawn Delay +customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds.tooltip = Seconds to wait after the spawned item is picked up or despawns before spawning again. +customUI.triggerVolumeEffectEditor.field.common.SpawnKey = Spawn Key +customUI.triggerVolumeEffectEditor.field.common.SpawnKey.tooltip = Optional unique id for this spawn point when one volume has multiple item spawners. +customUI.triggerVolumeEffectEditor.field.common.OffsetX = Offset X +customUI.triggerVolumeEffectEditor.field.common.OffsetX.tooltip = Spawn position offset on the X axis. +customUI.triggerVolumeEffectEditor.field.common.OffsetY = Offset Y +customUI.triggerVolumeEffectEditor.field.common.OffsetY.tooltip = Spawn position offset on the Y axis. +customUI.triggerVolumeEffectEditor.field.common.OffsetZ = Offset Z +customUI.triggerVolumeEffectEditor.field.common.OffsetZ.tooltip = Spawn position offset on the Z axis. +customUI.triggerVolumeEffectEditor.field.common.TargetIds = Kill Targets +customUI.triggerVolumeEffectEditor.field.common.TargetIds.tooltip = Player, NPC role ids, or * for any supported kill target. +customUI.triggerVolumeEffectEditor.field.common.Points = Points +customUI.triggerVolumeEffectEditor.field.common.Points.tooltip = Score awarded for a matching kill. +customUI.triggerVolumeEffectEditor.field.common.AwardTarget = Award To +customUI.triggerVolumeEffectEditor.field.common.AwardTarget.tooltip = Whether kill points go to the player, their team, or both. + +customUI.triggerVolumeEffectEditor.field.StartMinigame.MinigameId.tooltip = The minigame to start when this effect fires. +customUI.triggerVolumeEffectEditor.field.EndMinigame.MinigameId.tooltip = The minigame to end when this effect fires. +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.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.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. +customUI.triggerVolumeEffectEditor.field.VoteForMap.MinigameId.tooltip = The minigame whose waiting-session vote should be updated. +customUI.triggerVolumeEffectEditor.field.VoteForMap.MapId.tooltip = The map to vote for. Leave empty to vote for the triggering volume's MapId tag. +customUI.triggerVolumeEffectEditor.field.StartQueuedMinigame.MinigameId.tooltip = The minigame waiting session to start using its configured map selection mode. +customUI.triggerVolumeEffectEditor.field.SpawnItem.MinigameId.tooltip = Optional minigame runtime to associate this spawned pickup with. +customUI.triggerVolumeEffectEditor.field.SpawnItem.ItemId.tooltip = The item asset id to spawn at this trigger volume. +customUI.triggerVolumeEffectEditor.field.SpawnItem.Amount.tooltip = Number of items in the spawned stack. +customUI.triggerVolumeEffectEditor.field.SpawnItem.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after the previous item is picked up or despawns. +customUI.triggerVolumeEffectEditor.field.SpawnItem.SpawnKey.tooltip = Optional unique id for this item spawner. +customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetX.tooltip = Spawn position offset on the X axis. +customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetY.tooltip = Spawn position offset on the Y axis. +customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetZ.tooltip = Spawn position offset on the Z axis. +customUI.triggerVolumeEffectEditor.field.AwardKillScore.MinigameId.tooltip = Optional minigame id this kill scoring rule applies to. +customUI.triggerVolumeEffectEditor.field.AwardKillScore.TargetIds.tooltip = Player, NPC role ids such as fox, or * for any supported kill target. +customUI.triggerVolumeEffectEditor.field.AwardKillScore.Points.tooltip = Points awarded to the killer when the victim matches this rule. +customUI.triggerVolumeEffectEditor.field.AwardKillScore.AwardTarget.tooltip = Award points to the killer, the killer's team, or both. + +customUI.triggerVolumeEffectEditor.field.MinigamePhase.Phase.tooltip = The minigame phase required for this condition to pass. +customUI.triggerVolumeEffectEditor.field.PlayerInMinigame.PlayerId.tooltip = Optional player UUID. Leave empty to check the triggering player. +customUI.triggerVolumeEffectEditor.field.PlayerStatus.Status.tooltip = The player status required for this condition to pass. +customUI.triggerVolumeEffectEditor.field.PlayerScore.Value.tooltip = Score value used by the comparison. +customUI.triggerVolumeEffectEditor.field.TeamScore.Value.tooltip = Team score value used by the comparison. +customUI.triggerVolumeEffectEditor.field.Counter.Value.tooltip = Counter value used by the comparison. +customUI.triggerVolumeEffectEditor.field.CurrentRound.Round.tooltip = The round number required for this condition to pass. +customUI.triggerVolumeEffectEditor.conditionType.CurrentRound = Current Round + +customUI.triggerVolumeEffectEditor.field.common.Phase.option.CREATED = Created +customUI.triggerVolumeEffectEditor.field.common.Phase.option.WAITING_FOR_PLAYERS = Waiting For Players +customUI.triggerVolumeEffectEditor.field.common.Phase.option.COUNTDOWN = Countdown +customUI.triggerVolumeEffectEditor.field.common.Phase.option.STARTING = Starting +customUI.triggerVolumeEffectEditor.field.common.Phase.option.ACTIVE = Active +customUI.triggerVolumeEffectEditor.field.common.Phase.option.OVERTIME = Overtime +customUI.triggerVolumeEffectEditor.field.common.Phase.option.SUDDEN_DEATH = Sudden Death +customUI.triggerVolumeEffectEditor.field.common.Phase.option.ENDING = Ending +customUI.triggerVolumeEffectEditor.field.common.Phase.option.RESETTING = Resetting +customUI.triggerVolumeEffectEditor.field.common.Phase.option.DISABLED = Disabled + +customUI.triggerVolumeEffectEditor.field.common.Status.option.QUEUED = Queued +customUI.triggerVolumeEffectEditor.field.common.Status.option.WAITING = Waiting +customUI.triggerVolumeEffectEditor.field.common.Status.option.ACTIVE = Active +customUI.triggerVolumeEffectEditor.field.common.Status.option.SPECTATOR = Spectator +customUI.triggerVolumeEffectEditor.field.common.Status.option.ELIMINATED = Eliminated +customUI.triggerVolumeEffectEditor.field.common.Status.option.LEFT = Left + +customUI.triggerVolumeEffectEditor.field.common.Operation.option.SET = Set +customUI.triggerVolumeEffectEditor.field.common.Operation.option.ADD = Add +customUI.triggerVolumeEffectEditor.field.common.Operation.option.SUBTRACT = Subtract + +customUI.triggerVolumeEffectEditor.field.common.Compare.option.EQUAL = Equal +customUI.triggerVolumeEffectEditor.field.common.Compare.option.NOT_EQUAL = Not Equal +customUI.triggerVolumeEffectEditor.field.common.Compare.option.GREATER_THAN = Greater Than +customUI.triggerVolumeEffectEditor.field.common.Compare.option.GREATER_THAN_OR_EQUAL = Greater Than Or Equal +customUI.triggerVolumeEffectEditor.field.common.Compare.option.LESS_THAN = Less Than +customUI.triggerVolumeEffectEditor.field.common.Compare.option.LESS_THAN_OR_EQUAL = Less Than Or Equal + +customUI.triggerVolumeEffectEditor.effectType.SetRound = Set Round +customUI.triggerVolumeEffectEditor.field.SetRound.Operation.tooltip = How the round number should be changed. +customUI.triggerVolumeEffectEditor.field.SetRound.Amount.tooltip = The round number value used by the selected operation. + +customUI.triggerVolumeEffectEditor.field.common.SignalRoundStart.tooltip = Fire this volume once when the specified round starts. +customUI.triggerVolumeEffectEditor.field.common.SignalRoundTick.tooltip = Fire this volume repeatedly while the specified round is active. +customUI.triggerVolumeEffectEditor.field.common.SignalPhaseTick.tooltip = Fire this volume repeatedly while the specified phase is active. +customUI.triggerVolumeEffectEditor.field.common.SignalTickDelay.tooltip = Seconds between repeats. 0 uses the minimum interval (50ms). + +customUI.triggerVolumeEffectEditor.field.common.TimerName = Timer Name +customUI.triggerVolumeEffectEditor.field.common.TimerName.tooltip = Unique name identifying this timer within the minigame runtime. +customUI.triggerVolumeEffectEditor.field.common.DurationSeconds = Duration (Seconds) +customUI.triggerVolumeEffectEditor.field.common.DurationSeconds.tooltip = How many seconds before the timer fires. +customUI.triggerVolumeEffectEditor.field.common.DestinationId = Destination +customUI.triggerVolumeEffectEditor.field.common.DestinationId.tooltip = Teleport destination. Accepts world coordinates (x,y,z), world name (world:id), or a stored checkpoint id. +customUI.triggerVolumeEffectEditor.field.common.MessageKey = Message Key +customUI.triggerVolumeEffectEditor.field.common.MessageKey.tooltip = Translation key of the message to send. Defined in the minigames.lang file. +customUI.triggerVolumeEffectEditor.field.common.Target = Target +customUI.triggerVolumeEffectEditor.field.common.Target.tooltip = Who receives the message: the triggering player, their whole team, or everyone in the game. +customUI.triggerVolumeEffectEditor.field.common.ObjectiveId = Objective +customUI.triggerVolumeEffectEditor.field.common.ObjectiveId.tooltip = Unique identifier for the objective within this minigame runtime. +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus = Objective Status +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus.tooltip = The objective status to compare against or apply. + +customUI.triggerVolumeEffectEditor.field.common.Target.option.PLAYER = Player +customUI.triggerVolumeEffectEditor.field.common.Target.option.TEAM = Team +customUI.triggerVolumeEffectEditor.field.common.Target.option.ALL = All +customUI.triggerVolumeEffectEditor.field.common.AwardTarget.option.PLAYER = Player +customUI.triggerVolumeEffectEditor.field.common.AwardTarget.option.TEAM = Team +customUI.triggerVolumeEffectEditor.field.common.AwardTarget.option.BOTH = Both + +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus.option.INACTIVE = Inactive +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus.option.ACTIVE = Active +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus.option.COMPLETE = Complete +customUI.triggerVolumeEffectEditor.field.common.ObjectiveStatus.option.FAILED = Failed + +customUI.triggerVolumeEffectEditor.field.common.SignalOnTimerExpire.tooltip = Fire this volume once when the named timer expires. + +customUI.triggerVolumeEffectEditor.effectType.StartTimer = Start Timer +customUI.triggerVolumeEffectEditor.effectType.StopTimer = Stop Timer +customUI.triggerVolumeEffectEditor.effectType.SetSpawnPoint = Set Spawn Point +customUI.triggerVolumeEffectEditor.effectType.RespawnPlayer = Respawn Player +customUI.triggerVolumeEffectEditor.effectType.SendMinigameMessage = Send Message +customUI.triggerVolumeEffectEditor.effectType.AssignTeam = Assign Team +customUI.triggerVolumeEffectEditor.effectType.SaveInventory = Save Inventory +customUI.triggerVolumeEffectEditor.effectType.RestoreInventory = Restore Inventory +customUI.triggerVolumeEffectEditor.effectType.ClearInventory = Clear Inventory +customUI.triggerVolumeEffectEditor.effectType.SetObjectiveStatus = Set Objective Status +customUI.triggerVolumeEffectEditor.effectType.ModifyObjectiveProgress = Modify Objective Progress +customUI.triggerVolumeEffectEditor.effectType.AwardKillScore = Award Kill Score + +customUI.triggerVolumeEffectEditor.conditionType.PlayerLives = Player Lives +customUI.triggerVolumeEffectEditor.conditionType.TeamCondition = Team +customUI.triggerVolumeEffectEditor.conditionType.TimerElapsed = Timer Elapsed +customUI.triggerVolumeEffectEditor.conditionType.ObjectiveStatus = Objective Status +customUI.triggerVolumeEffectEditor.conditionType.WinConditionMet = Win Condition Met + +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.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. +customUI.triggerVolumeEffectEditor.field.AssignTeam.TeamId.tooltip = Team to assign the player to. The team is created automatically if it does not exist. +customUI.triggerVolumeEffectEditor.field.SetObjectiveStatus.ObjectiveId.tooltip = The objective whose status will be changed. +customUI.triggerVolumeEffectEditor.field.SetObjectiveStatus.Status.tooltip = The status to apply. Setting to Complete fires an on_objective_complete event. +customUI.triggerVolumeEffectEditor.field.ModifyObjectiveProgress.ObjectiveId.tooltip = The objective whose progress will be modified. +customUI.triggerVolumeEffectEditor.field.ModifyObjectiveProgress.Amount.tooltip = Progress amount to set, add, or subtract. Auto-completes when progress reaches required amount. + +customUI.triggerVolumeEffectEditor.field.PlayerLives.Value.tooltip = Lives value used by the comparison. +customUI.triggerVolumeEffectEditor.field.TeamCondition.TeamId.tooltip = The team id the player must belong to. +customUI.triggerVolumeEffectEditor.field.TimerElapsed.TimerName.tooltip = Name of the timer to check. Passes if the timer has expired or was started and has since elapsed. +customUI.triggerVolumeEffectEditor.field.ObjectiveStatus.ObjectiveId.tooltip = The objective to check. +customUI.triggerVolumeEffectEditor.field.ObjectiveStatus.Status.tooltip = The required objective status. +customUI.triggerVolumeEffectEditor.field.WinConditionMet.TargetScore.tooltip = Score threshold used when the win condition is FirstToScore. Ignored for other win conditions. + +customUI.triggerVolumeEffectEditor.effectType.JoinMinigameQueue = Join Minigame Queue +customUI.triggerVolumeEffectEditor.effectType.LeaveMinigameQueue = Leave Minigame Queue +customUI.triggerVolumeEffectEditor.effectType.VoteForMap = Vote For Map +customUI.triggerVolumeEffectEditor.effectType.StartQueuedMinigame = Start Queued Minigame +customUI.triggerVolumeEffectEditor.effectType.SpawnItem = Spawn Item diff --git a/src/main/resources/Server/Minigames/Dockside_Derby.json.old b/src/main/resources/Server/Minigames/Dockside_Derby.json.old new file mode 100644 index 0000000..414c7d4 --- /dev/null +++ b/src/main/resources/Server/Minigames/Dockside_Derby.json.old @@ -0,0 +1,39 @@ +{ + "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 } ] + } + ] +} diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..76ef4ec --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,22 @@ +{ + "Group": "net.kewwbec", + "Name": "core-minigames", + "Version": "0.5.3", + "Description": "KweebecNetwork trigger-volume minigame framework for Hytale servers", + "Authors": [ + { + "Name": "KweebecNetwork" + } + ], + "Website": "https://kewwbec.net", + "ServerVersion": "^0.5.3", + "Dependencies": { + + }, + "OptionalDependencies": { + + }, + "DisabledByDefault": false, + "Main": "net.kewwbec.minigames.MinigameCorePlugin", + "IncludesAssetPack": true +} \ No newline at end of file diff --git a/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java b/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java new file mode 100644 index 0000000..6e922fb --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java @@ -0,0 +1,74 @@ +package net.kewwbec.minigames.service; + +import net.kewwbec.minigames.model.MinigameMapCandidate; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static net.kewwbec.minigames.model.Enums.MapSelectionMode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class MinigameQueueServiceTest { + @Test + void highestVoteWins() { + MinigameQueueService queue = new MinigameQueueService(); + var castle = candidate("King_Of_The_Hill", "Castle", ""); + var ruins = candidate("King_Of_The_Hill", "Ruins", ""); + + 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"); + + assertEquals(castle, queue.select("King_Of_The_Hill", MapSelectionMode.VOTE, List.of(castle, ruins))); + } + + @Test + void tieResolvesToAValidCandidate() { + MinigameQueueService queue = new MinigameQueueService(); + var castle = candidate("King_Of_The_Hill", "Castle", ""); + var ruins = candidate("King_Of_The_Hill", "Ruins", ""); + + queue.vote("King_Of_The_Hill", "player-a", "Ruins"); + queue.vote("King_Of_The_Hill", "player-b", "Castle"); + + assertTrue(List.of(castle, ruins).contains(queue.select("King_Of_The_Hill", MapSelectionMode.VOTE, List.of(castle, ruins)))); + } + + @Test + void randomModeIgnoresVotesWhenOnlyOneCandidateExists() { + MinigameQueueService queue = new MinigameQueueService(); + var castle = candidate("King_Of_The_Hill", "Castle", ""); + + queue.vote("King_Of_The_Hill", "player-a", "Ruins"); + + assertEquals(castle, queue.select("King_Of_The_Hill", MapSelectionMode.RANDOM, List.of(castle))); + } + + @Test + void leaveRemovesQueuedPlayerAndVote() { + MinigameQueueService queue = new MinigameQueueService(); + + queue.join("King_Of_The_Hill", "player-a"); + queue.vote("King_Of_The_Hill", "player-a", "Castle"); + queue.leave("King_Of_The_Hill", "player-a"); + + assertTrue(queue.players("King_Of_The_Hill").isEmpty()); + assertTrue(queue.votes("King_Of_The_Hill").isEmpty()); + } + + @Test + void emptyCandidatesFallbackToNoMapCandidate() { + MinigameQueueService queue = new MinigameQueueService(); + + var selected = queue.select("King_Of_The_Hill", MapSelectionMode.RANDOM, List.of()); + + assertEquals("King_Of_The_Hill", selected.minigameId()); + assertEquals("", selected.mapId()); + assertEquals("", selected.variantId()); + } + + private static MinigameMapCandidate candidate(String minigameId, String mapId, String variantId) { + return new MinigameMapCandidate(minigameId, mapId, variantId, 1, true); + } +} diff --git a/wiki/FAQ.md b/wiki/FAQ.md new file mode 100644 index 0000000..918a7af --- /dev/null +++ b/wiki/FAQ.md @@ -0,0 +1,45 @@ +# FAQ + +## Where did `MinigameAreaVolume` go? + +It was removed. The mod now uses normal Hytale trigger volumes for areas and interactions. Create a Hytale trigger volume, shape it around the arena, name or tag it for the minigame, and attach minigame trigger conditions/effects. + +## Why remove custom minigame volume types? + +They duplicated Hytale's trigger-volume system. The current approach is closer to Hytale's built-in style: use the engine's volume system and register only the minigame-specific conditions/effects that the engine did not already have. + +## Why is `MinigameId` a dropdown now? + +`MinigameCorePlugin` registers `MinigameDefinition` as a trigger-volume asset source and registers `MinigameId` fields against it. The trigger-volume editor can then show loaded minigames instead of requiring a typed string. + +## Why is my minigame not in the dropdown? + +Check: + +- The JSON is under `Server/Minigames/`. +- The filename matches the `Id`. +- The asset loaded without codec errors. +- The server was restarted or the asset was reloaded after adding the file. + +## Can I still start games with commands? + +Yes. + +```text +/minigame enable My_Game +/minigame start My_Game +``` + +Commands are useful for testing and admin control. In-world game flow should use Hytale trigger volumes with minigame trigger effects. + +## Can two minigames run at the same time? + +Yes. Active runtimes are keyed by runtime ID, and one minigame can have multiple active runtimes on different discovered maps or variants. + +## How do I award score from a volume? + +Attach `ModifyPlayerScore` or `ModifyTeamScore` to a normal Hytale trigger volume. Add `PlayerInMinigame` if the effect should only run for players already participating. + +## My condition has an optional `PlayerId`. Should I fill it? + +Usually leave it blank for player-caused trigger-volume events. The effect or condition will try to resolve the triggering player from the trigger context. Use an explicit `PlayerId` only for non-player-driven logic. diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md new file mode 100644 index 0000000..9653c7d --- /dev/null +++ b/wiki/Getting-Started.md @@ -0,0 +1,61 @@ +# Getting Started + +## 1. Build and run + +```bash +gradlew.bat shadowJar +gradlew.bat runServer +``` + +## 2. Create a minigame asset + +Create `src/main/resources/Server/Minigames/My_Game.json`: + +```json +{ + "Id": "My_Game", + "Name": "My Game", + "Description": "A quick test minigame.", + "Enabled": true, + "MinPlayers": 1, + "MaxPlayers": 4, + "TeamMode": "SOLO", + "WinCondition": "HIGHEST_SCORE", + "RoundLengthSeconds": 120, + "CountdownSeconds": 5 +} +``` + +The filename and `Id` should match. + +## 3. Confirm the asset loaded + +```text +/minigame list +/minigame info My_Game +/minigame validate My_Game +``` + +## 4. Add trigger-volume logic + +Use the Hytale trigger-volume editor: + +1. Create a normal trigger volume. +2. Add the `StartMinigame` effect. +3. Set `MinigameId` to `My_Game` from the dropdown. + +To make the volume award score: + +1. Add the `PlayerInMinigame` condition. +2. Add the `ModifyPlayerScore` effect. +3. Set `Operation = ADD` and `Amount = 1`. + +## 5. Start from commands when needed + +```text +/minigame enable My_Game +/minigame join My_Game +/minigame start My_Game +``` + +Command starts are useful for testing. Production interaction should normally come from Hytale trigger volumes. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..076c171 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,46 @@ +# core-minigames Wiki + +core-minigames is a Hytale-native minigame extension. It adds minigame definitions, runtime state, and minigame-specific trigger conditions/effects while reusing Hytale's asset store, trigger-volume system, and event bus. + +## What It Provides + +1. `MinigameDefinition` assets in `Server/Minigames/`. +2. A runtime service for active minigames, players, teams, scores, lives, counters, and phases. +3. Hytale `TriggerCondition` entries for checking minigame state. +4. Hytale `TriggerEffect` entries for mutating minigame state. +5. `MinigameEvent` dispatch through Hytale's event bus. + +## Important Refactor Note + +The old custom minigame volume layer is gone. There is no `MinigameAreaVolume`, `JoinQueueVolume`, or `ScoreVolume` class to place. Use normal Hytale trigger volumes and attach core-minigames trigger conditions/effects to them. + +## Pages + +| Page | Description | +|------|-------------| +| [Getting Started](Getting-Started.md) | Create a definition and wire a trigger volume | +| [How It Works](How-It-Works.md) | How assets, runtime, event bus, and trigger logic fit together | +| [FAQ](FAQ.md) | Common questions and troubleshooting | + +## Quick Reference + +```text +/minigame create My_Game "My Game" +/minigame enable My_Game +/minigame start My_Game +``` + +Minimal asset: + +```json +{ + "Id": "My_Game", + "Name": "My Game", + "Enabled": true, + "MinPlayers": 1, + "MaxPlayers": 8, + "TeamMode": "SOLO", + "WinCondition": "HIGHEST_SCORE", + "RoundLengthSeconds": 180 +} +``` diff --git a/wiki/How-It-Works.md b/wiki/How-It-Works.md new file mode 100644 index 0000000..34a483a --- /dev/null +++ b/wiki/How-It-Works.md @@ -0,0 +1,102 @@ +# How It Works + +## Assets + +Each minigame is a `MinigameDefinition` asset loaded from `Server/Minigames/`. Hytale's asset store owns loading and editor integration. + +## Runtime + +When a minigame starts, the runtime service creates a `MinigameRuntime` keyed by runtime ID. Runtime state tracks phase, players, teams, scores, lives, counters, objectives, spectators, eliminated players, map ID, and variant ID. + +## Events + +Runtime lifecycle events are emitted as `MinigameEvent` and dispatched through Hytale's event bus. + +Current emitted event IDs: + +- `on_game_start` +- `on_game_end` +- `on_arena_reset` +- `on_phase_change` +- `on_round_start` +- `on_round_end` +- `on_game_rounds_complete` +- `on_timer_expired` +- `on_player_eliminated` +- `on_objective_complete` +- `on_kill_score` + +## Trigger Conditions + +Minigame conditions are Hytale `TriggerCondition` types. They check runtime state and can be attached to normal Hytale trigger volumes or trigger effect assets. + +Current condition type IDs: + +- `MinigamePhase` +- `PlayerInMinigame` +- `PlayerStatus` +- `PlayerScore` +- `TeamScore` +- `Counter` +- `CurrentRound` +- `PlayerLives` +- `TeamCondition` +- `TimerElapsed` +- `ObjectiveStatus` +- `WinConditionMet` + +## Trigger Effects + +Minigame effects are Hytale `TriggerEffect` types. They mutate runtime state or call runtime lifecycle operations. + +Current effect type IDs: + +- `StartMinigame` +- `EndMinigame` +- `ResetMinigame` +- `SetMinigamePhase` +- `ResetScores` +- `ModifyPlayerScore` +- `ModifyTeamScore` +- `ModifyCounter` +- `ModifyPlayerLives` +- `SetPlayerStatus` +- `JoinMinigameQueue` +- `LeaveMinigameQueue` +- `VoteForMap` +- `StartQueuedMinigame` +- `SpawnItem` +- `SetRound` +- `StartTimer` +- `StopTimer` +- `SetSpawnPoint` +- `RespawnPlayer` +- `SendMinigameMessage` +- `AssignTeam` +- `SaveInventory` +- `RestoreInventory` +- `ClearInventory` +- `SetObjectiveStatus` +- `ModifyObjectiveProgress` +- `AwardKillScore` + +## Volumes + +Volumes are Hytale trigger volumes. core-minigames does not define separate volume classes. + +The normal flow is: + +```text +Hytale trigger volume fires + | + v +Minigame TriggerCondition entries evaluate + | + v +Minigame TriggerEffect entries execute + | + v +MinigameRuntime changes +``` + +Fields named `MinigameId` are registered as asset picker fields, so the editor can show loaded minigame assets as dropdown options. diff --git a/workflows/build.yml b/workflows/build.yml new file mode 100644 index 0000000..cfbee71 --- /dev/null +++ b/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build Plugin + +permissions: + contents: write + +on: + push: + branches: [ main, develop ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Java 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + + - name: Cache Gradle Dependencies + uses: actions/cache@v4 + with: + path: ~/.gradle + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant Execute Permission for Gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew shadowJar + + - name: Run Tests + run: ./gradlew test + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: plugin-jar + path: build/libs/*.jar + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Java 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + + - name: Grant Execute Permission for Gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew shadowJar + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: build/libs/*.jar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}