commit 8eb6e563c5e264779c1c0b0aa3d5d0167262fd32 Author: Dakroach Date: Sun Jun 7 14:34:30 2026 -0700 First commit 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 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4d1957b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Nov 25 02:23:10 MST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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 }}