commit 83a0392ee9227a0c2295cf5c0a54ffc0bdb071f7 Author: Michael Burgess Date: Sun Aug 9 09:53:55 2026 -0400 Initial Stargate plugin: Paper gate plugin + Velocity/Bungee cross-server bridges diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c83a1f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +.idea/ +*.iml +out/ +.vscode/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..00c3052 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Stargate + +A network-aware Stargate portal plugin for PaperMC, with sign-based dialing and +cross-server travel over Velocity or BungeeCord. + +## Modules + +| Module | What it is | +|---|---| +| `stargate-common` | Shared models + JDBC storage (SQLite / MySQL) used only by the Paper plugin | +| `stargate-paper` | The actual gate plugin — install this on every backend server | +| `stargate-velocity` | Proxy companion for Velocity — install on the proxy only if using cross-server gates | +| `stargate-bungee` | Same, for BungeeCord/Waterfall | + +Build everything with `./gradlew build`. Jars land in each module's `build/libs/`. + +## Building a gate + +1. Build a ring out of any block in `gate.frame-materials` (default: obsidian, gold + block, birch/oak planks — matches an obsidian+gold arch design). Leave the middle + hollow. +2. Embed a few blocks of `gate.chevron-unlit-material` (default black stained glass) + into the ring — these light up to `gate.chevron-lit-material` (default glowstone) + as the gate dials. +3. Place a sign on the outside of the frame with: + - Line 1: `[Stargate]` + - Line 2: network name (blank = default network) + - Line 3: gate name (blank = auto-generated) + - Line 4: `hidden` to keep it out of the cycle list, otherwise blank + +The plugin flood-fills the frame from the block behind the sign, confirms it's a +fully enclosed ring, and registers the gate. If nothing happens, the ring isn't +sealed or none of its blocks match `frame-materials`. + +## Using a gate + +- **Right-click** the sign: cycles the destination shown on line 3 among the other + gates on the same network. +- **Left-click** the sign: dials the shown destination — chevrons light in sequence, + then the interior fills with `gate.iris-material` (default water). Walk into it to + teleport. It auto-closes after `dialing.open-seconds`. + +## Multi-world + +Gates are addressed by network name, not world — a gate on any loaded world can dial +any other gate on the same network regardless of world, exactly like same-server +cross-world travel in the original Stargate mod. + +## Cross-server (Velocity / Bungee) + +1. Set `storage.type: mysql` in every backend server's `config.yml` and point them at + the **same** database — this is how servers see each other's gates. +2. Give each backend a unique `server-id` in `config.yml` that matches its name in + the proxy config (`velocity.toml` / `config.yml` servers list). +3. Set `cross-server.enabled: true` on every backend. +4. Drop `stargate-velocity` (or `stargate-bungee`) into the proxy's plugin folder. + +When a player dials a gate hosted on another backend, the Paper plugin asks the +proxy (over the `stargate:teleport` plugin channel) to connect the player to that +server; once they land, the proxy forwards a delivery message so the destination +server's Stargate instance teleports them to the gate's exit point. + +## Permissions + +- `stargate.use` (default: true) — dial/cycle gates +- `stargate.create` (default: op) — build new gates +- `stargate.destroy` (default: op) — break your own gates +- `stargate.admin` (default: op) — reload, break/manage any gate + +## Commands + +- `/sg list [network]` +- `/sg destroy` (look at a gate's sign) +- `/sg reload` diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ad8164a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("java") +} + +allprojects { + group = "dev.skywalker3200.stargate" + version = "1.0.0" + + repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://oss.sonatype.org/content/repositories/snapshots/") + maven("https://repo.opencollab.dev/main/") // Velocity/Bungee mirrors sometimes needed + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") + } +} + +subprojects { + apply(plugin = "java") + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + + tasks.withType { + options.encoding = "UTF-8" + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 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..9355b41 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..d95bf61 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/HEAD/platforms/jvm/plugins-application/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + 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 + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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..640d686 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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=. +@rem This is normally unused +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=-Dfile.encoding=UTF-8 "-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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cbb8d75 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,6 @@ +rootProject.name = "stargate" + +include(":stargate-common") +include(":stargate-paper") +include(":stargate-velocity") +include(":stargate-bungee") diff --git a/stargate-bungee/build.gradle.kts b/stargate-bungee/build.gradle.kts new file mode 100644 index 0000000..c5ee528 --- /dev/null +++ b/stargate-bungee/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("com.gradleup.shadow") version "8.3.5" +} + +dependencies { + implementation(project(":stargate-common")) + compileOnly("net.md-5:bungeecord-api:1.21-R0.4") +} + +tasks.shadowJar { + archiveClassifier.set("") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} + +tasks.processResources { + filesMatching("bungee.yml") { + expand("version" to project.version) + } +} diff --git a/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java b/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java new file mode 100644 index 0000000..a990d79 --- /dev/null +++ b/stargate-bungee/src/main/java/dev/skywalker3200/stargate/bungee/StargateBungeePlugin.java @@ -0,0 +1,80 @@ +package dev.skywalker3200.stargate.bungee; + +import dev.skywalker3200.stargate.common.network.StargateChannel; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.connection.Server; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.event.ServerConnectedEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** BungeeCord counterpart of the Velocity bridge: same wire protocol, same job. */ +public class StargateBungeePlugin extends Plugin implements Listener { + + private final Map pendingGateByPlayer = new ConcurrentHashMap<>(); + + @Override + public void onEnable() { + getProxy().registerChannel(StargateChannel.CHANNEL); + getProxy().getPluginManager().registerListener(this, this); + getLogger().info("Stargate Bungee bridge ready on channel " + StargateChannel.CHANNEL); + } + + @EventHandler + public void onPluginMessage(PluginMessageEvent event) { + if (!event.getTag().equals(StargateChannel.CHANNEL)) return; + event.setCancelled(true); + + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(event.getData())); + byte op = in.readByte(); + if (op != StargateChannel.OP_TELEPORT_REQUEST) return; + + UUID playerId = UUID.fromString(in.readUTF()); + String targetServer = in.readUTF(); + String gateId = in.readUTF(); + + ProxiedPlayer player = ProxyServer.getInstance().getPlayer(playerId); + ServerInfo target = ProxyServer.getInstance().getServerInfo(targetServer); + if (player == null || target == null) { + getLogger().warning("Stargate teleport request for unknown player/server (" + playerId + "/" + targetServer + ")"); + return; + } + + pendingGateByPlayer.put(playerId, gateId); + player.connect(target); + } catch (Exception e) { + getLogger().warning("Failed to process stargate plugin message: " + e.getMessage()); + } + } + + @EventHandler + public void onServerConnected(ServerConnectedEvent event) { + ProxiedPlayer player = event.getPlayer(); + String gateId = pendingGateByPlayer.remove(player.getUniqueId()); + if (gateId == null) return; + + Server server = event.getServer(); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(StargateChannel.OP_TELEPORT_DELIVER); + out.writeUTF(player.getUniqueId().toString()); + out.writeUTF(gateId); + server.sendData(StargateChannel.CHANNEL, bytes.toByteArray()); + } catch (Exception e) { + getLogger().warning("Failed to deliver stargate teleport: " + e.getMessage()); + } + } +} diff --git a/stargate-bungee/src/main/resources/bungee.yml b/stargate-bungee/src/main/resources/bungee.yml new file mode 100644 index 0000000..72abc84 --- /dev/null +++ b/stargate-bungee/src/main/resources/bungee.yml @@ -0,0 +1,5 @@ +name: StargateBungee +main: dev.skywalker3200.stargate.bungee.StargateBungeePlugin +version: '${version}' +author: skywalker3200 +description: Relays Stargate cross-server teleport requests for BungeeCord. diff --git a/stargate-common/build.gradle.kts b/stargate-common/build.gradle.kts new file mode 100644 index 0000000..8dea307 --- /dev/null +++ b/stargate-common/build.gradle.kts @@ -0,0 +1,8 @@ +dependencies { + compileOnly("com.zaxxer:HikariCP:5.1.0") + implementation("com.zaxxer:HikariCP:5.1.0") + compileOnly("org.xerial:sqlite-jdbc:3.46.1.3") + implementation("org.xerial:sqlite-jdbc:3.46.1.3") + compileOnly("com.mysql:mysql-connector-j:8.4.0") + implementation("com.mysql:mysql-connector-j:8.4.0") +} diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java new file mode 100644 index 0000000..67cc1c5 --- /dev/null +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/model/Gate.java @@ -0,0 +1,92 @@ +package dev.skywalker3200.stargate.common.model; + +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; + +/** + * A single stargate: its physical location, the sign that controls it, and the network + * it belongs to. Not tied to Bukkit types so it can be shared with the storage layer only. + */ +public class Gate { + + public enum Flag { + PUBLIC, // visible to everyone when cycling destinations + HIDDEN, // only reachable by exact name, not shown while cycling + FIXED // destination cannot be changed by right-click; always dials the configured target + } + + private final UUID id; + private String name; + private String network; + private String serverId; + private String world; + private int exitX; + private int exitY; + private int exitZ; + private float exitYaw; + private int signX; + private int signY; + private int signZ; + private String signWorld; + private String facing; + private UUID owner; + private final Set flags; + private String fixedDestination; + + public Gate(UUID id, String name, String network, String serverId, String world, + int exitX, int exitY, int exitZ, float exitYaw, + int signX, int signY, int signZ, String signWorld, String facing, + UUID owner, Set flags, String fixedDestination) { + this.id = id; + this.name = name; + this.network = network; + this.serverId = serverId; + this.world = world; + this.exitX = exitX; + this.exitY = exitY; + this.exitZ = exitZ; + this.exitYaw = exitYaw; + this.signX = signX; + this.signY = signY; + this.signZ = signZ; + this.signWorld = signWorld; + this.facing = facing; + this.owner = owner; + this.flags = flags == null ? EnumSet.noneOf(Flag.class) : flags; + this.fixedDestination = fixedDestination; + } + + public UUID getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getNetwork() { return network; } + public void setNetwork(String network) { this.network = network; } + public String getServerId() { return serverId; } + public void setServerId(String serverId) { this.serverId = serverId; } + public String getWorld() { return world; } + public void setWorld(String world) { this.world = world; } + public int getExitX() { return exitX; } + public int getExitY() { return exitY; } + public int getExitZ() { return exitZ; } + public float getExitYaw() { return exitYaw; } + public void setExit(int x, int y, int z, float yaw) { this.exitX = x; this.exitY = y; this.exitZ = z; this.exitYaw = yaw; } + public int getSignX() { return signX; } + public int getSignY() { return signY; } + public int getSignZ() { return signZ; } + public String getSignWorld() { return signWorld; } + public String getFacing() { return facing; } + public UUID getOwner() { return owner; } + public void setOwner(UUID owner) { this.owner = owner; } + public Set getFlags() { return flags; } + public boolean isPublic() { return flags.contains(Flag.PUBLIC); } + public boolean isHidden() { return flags.contains(Flag.HIDDEN); } + public boolean isFixed() { return flags.contains(Flag.FIXED); } + public String getFixedDestination() { return fixedDestination; } + public void setFixedDestination(String fixedDestination) { this.fixedDestination = fixedDestination; } + + /** Fully-qualified identity used for cross-server lookups: server/network/name */ + public String qualifiedName() { + return serverId + "/" + network + "/" + name; + } +} diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/network/StargateChannel.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/network/StargateChannel.java new file mode 100644 index 0000000..5b2a834 --- /dev/null +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/network/StargateChannel.java @@ -0,0 +1,18 @@ +package dev.skywalker3200.stargate.common.network; + +/** + * Shared plugin-messaging channel identifiers used between the Paper plugin and the + * Bungee/Velocity proxy companions to move a player to the backend server that hosts + * their destination gate. + */ +public final class StargateChannel { + + private StargateChannel() {} + + /** Modern namespaced channel (Paper/Velocity require this format). */ + public static final String CHANNEL = "stargate:teleport"; + + /** Sub-channel byte sent first in the payload. */ + public static final byte OP_TELEPORT_REQUEST = 1; // backend -> proxy: move this player to , remember pending warp + public static final byte OP_TELEPORT_DELIVER = 2; // proxy -> new backend: this player just arrived, warp them to +} diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/GateStorage.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/GateStorage.java new file mode 100644 index 0000000..f42d6f0 --- /dev/null +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/GateStorage.java @@ -0,0 +1,26 @@ +package dev.skywalker3200.stargate.common.storage; + +import dev.skywalker3200.stargate.common.model.Gate; + +import java.util.List; +import java.util.UUID; + +/** + * Persistence for gates. Implementations back onto SQLite (single server) or MySQL + * (shared across a Bungee/Velocity network so every backend server sees the same gates). + */ +public interface GateStorage { + + void init() throws Exception; + + void close(); + + void saveGate(Gate gate); + + void deleteGate(UUID id); + + List loadAll(); + + /** Reload gates belonging to other servers (call periodically when networked). */ + List loadAllForNetwork(String network); +} diff --git a/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java new file mode 100644 index 0000000..110372d --- /dev/null +++ b/stargate-common/src/main/java/dev/skywalker3200/stargate/common/storage/SqlGateStorage.java @@ -0,0 +1,209 @@ +package dev.skywalker3200.stargate.common.storage; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.skywalker3200.stargate.common.model.Gate; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * JDBC-backed implementation of {@link GateStorage}. Works for both SQLite (file-based, + * one server) and MySQL (shared, used when networking multiple backend servers together + * behind Bungee/Velocity so they all see the same gate table). + */ +public class SqlGateStorage implements GateStorage { + + public enum Driver { SQLITE, MYSQL } + + private final Driver driver; + private final String jdbcUrl; + private final String user; + private final String pass; + private final String tablePrefix; + private final Logger logger; + private HikariDataSource dataSource; + + public SqlGateStorage(Driver driver, String jdbcUrl, String user, String pass, String tablePrefix, Logger logger) { + this.driver = driver; + this.jdbcUrl = jdbcUrl; + this.user = user; + this.pass = pass; + this.tablePrefix = tablePrefix == null ? "stargate_" : tablePrefix; + this.logger = logger; + } + + @Override + public void init() throws Exception { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(jdbcUrl); + if (driver == Driver.MYSQL) { + config.setUsername(user); + config.setPassword(pass); + config.setMaximumPoolSize(8); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + } else { + config.setMaximumPoolSize(1); // SQLite is single-writer + } + this.dataSource = new HikariDataSource(config); + + try (Connection c = dataSource.getConnection(); Statement s = c.createStatement()) { + s.executeUpdate("CREATE TABLE IF NOT EXISTS " + tablePrefix + "gates (" + + "id VARCHAR(36) PRIMARY KEY," + + "name VARCHAR(64) NOT NULL," + + "network VARCHAR(64) NOT NULL," + + "server_id VARCHAR(64) NOT NULL," + + "world VARCHAR(64) NOT NULL," + + "exit_x INTEGER NOT NULL," + + "exit_y INTEGER NOT NULL," + + "exit_z INTEGER NOT NULL," + + "exit_yaw REAL NOT NULL," + + "sign_x INTEGER NOT NULL," + + "sign_y INTEGER NOT NULL," + + "sign_z INTEGER NOT NULL," + + "sign_world VARCHAR(64) NOT NULL," + + "facing VARCHAR(16)," + + "owner VARCHAR(36)," + + "flags VARCHAR(128)," + + "fixed_destination VARCHAR(64)" + + ")"); + s.executeUpdate("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "network ON " + tablePrefix + "gates(network)"); + } + logger.info("[Stargate] Storage initialised (" + driver + ")"); + } + + @Override + public void close() { + if (dataSource != null) dataSource.close(); + } + + @Override + public void saveGate(Gate gate) { + String sql = "REPLACE INTO " + tablePrefix + "gates " + + "(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + if (driver == Driver.MYSQL) { + sql = "INSERT INTO " + tablePrefix + "gates " + + "(id,name,network,server_id,world,exit_x,exit_y,exit_z,exit_yaw,sign_x,sign_y,sign_z,sign_world,facing,owner,flags,fixed_destination) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " + + "name=VALUES(name),network=VALUES(network),server_id=VALUES(server_id),world=VALUES(world)," + + "exit_x=VALUES(exit_x),exit_y=VALUES(exit_y),exit_z=VALUES(exit_z),exit_yaw=VALUES(exit_yaw)," + + "sign_x=VALUES(sign_x),sign_y=VALUES(sign_y),sign_z=VALUES(sign_z),sign_world=VALUES(sign_world)," + + "facing=VALUES(facing),owner=VALUES(owner),flags=VALUES(flags),fixed_destination=VALUES(fixed_destination)"; + } + try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, gate.getId().toString()); + ps.setString(2, gate.getName()); + ps.setString(3, gate.getNetwork()); + ps.setString(4, gate.getServerId()); + ps.setString(5, gate.getWorld()); + ps.setInt(6, gate.getExitX()); + ps.setInt(7, gate.getExitY()); + ps.setInt(8, gate.getExitZ()); + ps.setFloat(9, gate.getExitYaw()); + ps.setInt(10, gate.getSignX()); + ps.setInt(11, gate.getSignY()); + ps.setInt(12, gate.getSignZ()); + ps.setString(13, gate.getSignWorld()); + ps.setString(14, gate.getFacing()); + ps.setString(15, gate.getOwner() == null ? null : gate.getOwner().toString()); + ps.setString(16, serializeFlags(gate.getFlags())); + ps.setString(17, gate.getFixedDestination()); + ps.executeUpdate(); + } catch (Exception e) { + logger.severe("[Stargate] Failed to save gate " + gate.getName() + ": " + e.getMessage()); + } + } + + @Override + public void deleteGate(UUID id) { + try (Connection c = dataSource.getConnection(); + PreparedStatement ps = c.prepareStatement("DELETE FROM " + tablePrefix + "gates WHERE id = ?")) { + ps.setString(1, id.toString()); + ps.executeUpdate(); + } catch (Exception e) { + logger.severe("[Stargate] Failed to delete gate " + id + ": " + e.getMessage()); + } + } + + @Override + public List loadAll() { + List gates = new ArrayList<>(); + try (Connection c = dataSource.getConnection(); + Statement s = c.createStatement(); + ResultSet rs = s.executeQuery("SELECT * FROM " + tablePrefix + "gates")) { + while (rs.next()) { + gates.add(fromRow(rs)); + } + } catch (Exception e) { + logger.severe("[Stargate] Failed to load gates: " + e.getMessage()); + } + return gates; + } + + @Override + public List loadAllForNetwork(String network) { + List gates = new ArrayList<>(); + try (Connection c = dataSource.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT * FROM " + tablePrefix + "gates WHERE network = ?")) { + ps.setString(1, network); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + gates.add(fromRow(rs)); + } + } + } catch (Exception e) { + logger.severe("[Stargate] Failed to load network " + network + ": " + e.getMessage()); + } + return gates; + } + + private Gate fromRow(ResultSet rs) throws Exception { + String ownerStr = rs.getString("owner"); + String fixedDest = rs.getString("fixed_destination"); + return new Gate( + UUID.fromString(rs.getString("id")), + rs.getString("name"), + rs.getString("network"), + rs.getString("server_id"), + rs.getString("world"), + rs.getInt("exit_x"), rs.getInt("exit_y"), rs.getInt("exit_z"), rs.getFloat("exit_yaw"), + rs.getInt("sign_x"), rs.getInt("sign_y"), rs.getInt("sign_z"), rs.getString("sign_world"), + rs.getString("facing"), + ownerStr == null ? null : UUID.fromString(ownerStr), + deserializeFlags(rs.getString("flags")), + fixedDest + ); + } + + private String serializeFlags(Set flags) { + StringBuilder sb = new StringBuilder(); + for (Gate.Flag f : flags) { + if (sb.length() > 0) sb.append(','); + sb.append(f.name()); + } + return sb.toString(); + } + + private Set deserializeFlags(String s) { + Set flags = EnumSet.noneOf(Gate.Flag.class); + if (s == null || s.isEmpty()) return flags; + for (String part : s.split(",")) { + try { + flags.add(Gate.Flag.valueOf(part.trim())); + } catch (IllegalArgumentException ignored) { + } + } + return flags; + } +} diff --git a/stargate-paper/build.gradle.kts b/stargate-paper/build.gradle.kts new file mode 100644 index 0000000..3018442 --- /dev/null +++ b/stargate-paper/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("com.gradleup.shadow") version "8.3.5" +} + +dependencies { + implementation(project(":stargate-common")) + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + implementation("com.zaxxer:HikariCP:5.1.0") + implementation("org.xerial:sqlite-jdbc:3.46.1.3") + implementation("com.mysql:mysql-connector-j:8.4.0") +} + +tasks.shadowJar { + archiveClassifier.set("") + relocate("com.zaxxer.hikari", "dev.skywalker3200.stargate.libs.hikari") + relocate("org.sqlite", "dev.skywalker3200.stargate.libs.sqlite") + relocate("com.mysql", "dev.skywalker3200.stargate.libs.mysql") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to project.version) + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java new file mode 100644 index 0000000..a90bada --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/StargatePlugin.java @@ -0,0 +1,96 @@ +package dev.skywalker3200.stargate.paper; + +import dev.skywalker3200.stargate.common.storage.GateStorage; +import dev.skywalker3200.stargate.common.storage.SqlGateStorage; +import dev.skywalker3200.stargate.paper.command.StargateCommand; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.listener.SignInteractListener; +import dev.skywalker3200.stargate.paper.listener.SignCreateListener; +import dev.skywalker3200.stargate.paper.listener.StructureProtectListener; +import dev.skywalker3200.stargate.paper.network.CrossServerBridge; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; + +public class StargatePlugin extends JavaPlugin { + + private GateStorage storage; + private GateManager gateManager; + private CrossServerBridge crossServerBridge; + private String serverId; + + @Override + public void onEnable() { + saveDefaultConfig(); + this.serverId = getConfig().getString("server-id", "server1"); + + try { + this.storage = buildStorage(); + this.storage.init(); + } catch (Exception e) { + getLogger().severe("Failed to initialise storage, disabling Stargate: " + e.getMessage()); + e.printStackTrace(); + getServer().getPluginManager().disablePlugin(this); + return; + } + + this.gateManager = new GateManager(this, storage); + this.gateManager.loadAll(); + + boolean crossServer = getConfig().getBoolean("cross-server.enabled", false); + this.crossServerBridge = new CrossServerBridge(this, gateManager); + if (crossServer) { + this.crossServerBridge.register(); + } + + getServer().getPluginManager().registerEvents(new SignCreateListener(this, gateManager), this); + getServer().getPluginManager().registerEvents(new SignInteractListener(this, gateManager, crossServerBridge), this); + getServer().getPluginManager().registerEvents(new StructureProtectListener(gateManager), this); + getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateTeleportListener(this, gateManager, crossServerBridge), this); + + StargateCommand command = new StargateCommand(this, gateManager); + getCommand("stargate").setExecutor(command); + getCommand("stargate").setTabCompleter(command); + + getLogger().info("Stargate enabled. server-id=" + serverId + " cross-server=" + crossServer); + } + + @Override + public void onDisable() { + if (gateManager != null) { + gateManager.closeAllGates(); + } + if (storage != null) { + storage.close(); + } + } + + private GateStorage buildStorage() { + String type = getConfig().getString("storage.type", "sqlite").toLowerCase(); + if (type.equals("mysql")) { + String host = getConfig().getString("storage.mysql.host", "localhost"); + int port = getConfig().getInt("storage.mysql.port", 3306); + String db = getConfig().getString("storage.mysql.database", "stargate"); + String user = getConfig().getString("storage.mysql.username", "stargate"); + String pass = getConfig().getString("storage.mysql.password", ""); + String prefix = getConfig().getString("storage.mysql.table-prefix", "stargate_"); + String url = "jdbc:mysql://" + host + ":" + port + "/" + db + "?useSSL=false&autoReconnect=true"; + return new SqlGateStorage(SqlGateStorage.Driver.MYSQL, url, user, pass, prefix, getLogger()); + } + File dataFile = new File(getDataFolder(), "gates.db"); + String url = "jdbc:sqlite:" + dataFile.getAbsolutePath(); + return new SqlGateStorage(SqlGateStorage.Driver.SQLITE, url, null, null, "stargate_", getLogger()); + } + + public String getServerId() { + return serverId; + } + + public GateManager getGateManager() { + return gateManager; + } + + public CrossServerBridge getCrossServerBridge() { + return crossServerBridge; + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java new file mode 100644 index 0000000..37cc0d0 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/command/StargateCommand.java @@ -0,0 +1,91 @@ +package dev.skywalker3200.stargate.paper.command; + +import dev.skywalker3200.stargate.paper.StargatePlugin; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.block.Block; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; + +public class StargateCommand implements CommandExecutor, TabCompleter { + + private final StargatePlugin plugin; + private final GateManager gateManager; + + public StargateCommand(StargatePlugin plugin, GateManager gateManager) { + this.plugin = plugin; + this.gateManager = gateManager; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0) { + sender.sendMessage(Component.text("Usage: /sg ", NamedTextColor.YELLOW)); + return true; + } + + switch (args[0].toLowerCase()) { + case "reload" -> { + if (!sender.hasPermission("stargate.admin")) { + sender.sendMessage(Component.text("No permission.", NamedTextColor.RED)); + return true; + } + plugin.reloadConfig(); + gateManager.reloadConfig(); + gateManager.loadAll(); + sender.sendMessage(Component.text("Stargate reloaded.", NamedTextColor.GREEN)); + } + case "list" -> { + String network = args.length > 1 ? args[1] : gateManager.getConfig().defaultNetwork; + List gates = gateManager.getNetwork(network); + sender.sendMessage(Component.text("Network '" + network + "' (" + gates.size() + " gate(s)):", NamedTextColor.AQUA)); + for (RuntimeGate rg : gates) { + sender.sendMessage(Component.text(" - " + rg.getGate().getName() + " @ " + rg.getGate().getServerId() + + "/" + rg.getGate().getWorld(), NamedTextColor.GRAY)); + } + } + case "destroy" -> { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Players only.", NamedTextColor.RED)); + return true; + } + Block target = player.getTargetBlockExact(6); + RuntimeGate rg = target == null ? null : gateManager.getBySign(target); + if (rg == null) { + player.sendMessage(Component.text("Look at a stargate sign to destroy it.", NamedTextColor.RED)); + return true; + } + boolean allowed = player.hasPermission("stargate.admin") + || (rg.getGate().getOwner() != null && rg.getGate().getOwner().equals(player.getUniqueId()) && player.hasPermission("stargate.destroy")); + if (!allowed) { + player.sendMessage(Component.text("You can't destroy this stargate.", NamedTextColor.RED)); + return true; + } + gateManager.destroyGate(rg); + player.sendMessage(Component.text("Destroyed '" + rg.getGate().getName() + "'.", NamedTextColor.YELLOW)); + } + default -> sender.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED)); + } + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + return List.of("list", "destroy", "reload").stream() + .filter(s -> s.startsWith(args[0].toLowerCase())) + .collect(Collectors.toList()); + } + return new ArrayList<>(); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java new file mode 100644 index 0000000..0f17ced --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateConfig.java @@ -0,0 +1,56 @@ +package dev.skywalker3200.stargate.paper.gate; + +import org.bukkit.Material; +import org.bukkit.configuration.file.FileConfiguration; + +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; + +/** Typed view over the `gate:` / `dialing:` / `network:` sections of config.yml. */ +public class GateConfig { + + public final Set frameMaterials; + public final Material chevronUnlit; + public final Material chevronLit; + public final Material irisMaterial; + public final int maxFrameBlocks; + public final int maxIrisBlocks; + public final int minFrameBlocks; + public final int openSeconds; + public final int chevronTickDelay; + public final boolean playSounds; + public final String defaultNetwork; + + public GateConfig(FileConfiguration cfg, Logger logger) { + Set materials = new HashSet<>(); + for (String s : cfg.getStringList("gate.frame-materials")) { + Material m = Material.matchMaterial(s); + if (m != null) materials.add(m); + else logger.warning("[Stargate] Unknown frame material in config: " + s); + } + if (materials.isEmpty()) materials.add(Material.OBSIDIAN); + this.frameMaterials = materials; + + this.chevronUnlit = matOr(cfg.getString("gate.chevron-unlit-material"), Material.BLACK_STAINED_GLASS, logger); + this.chevronLit = matOr(cfg.getString("gate.chevron-lit-material"), Material.GLOWSTONE, logger); + this.irisMaterial = matOr(cfg.getString("gate.iris-material"), Material.WATER, logger); + this.maxFrameBlocks = cfg.getInt("gate.max-frame-blocks", 300); + this.maxIrisBlocks = cfg.getInt("gate.max-iris-blocks", 200); + this.minFrameBlocks = cfg.getInt("gate.min-frame-blocks", 8); + this.openSeconds = cfg.getInt("dialing.open-seconds", 10); + this.chevronTickDelay = cfg.getInt("dialing.chevron-tick-delay", 4); + this.playSounds = cfg.getBoolean("dialing.play-sounds", true); + this.defaultNetwork = cfg.getString("network.default-network", "main"); + } + + private Material matOr(String s, Material fallback, Logger logger) { + if (s == null) return fallback; + Material m = Material.matchMaterial(s); + if (m == null) { + logger.warning("[Stargate] Unknown material in config: " + s + ", using " + fallback); + return fallback; + } + return m; + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java new file mode 100644 index 0000000..49cb0a9 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java @@ -0,0 +1,272 @@ +package dev.skywalker3200.stargate.paper.gate; + +import dev.skywalker3200.stargate.common.model.Gate; +import dev.skywalker3200.stargate.common.storage.GateStorage; +import dev.skywalker3200.stargate.paper.StargatePlugin; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.Levelled; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** Owns every gate known to this server: creation, lookup, dialing/animation, and teardown. */ +public class GateManager { + + private final StargatePlugin plugin; + private final GateStorage storage; + private GateConfig config; + private GateStructureScanner scanner; + + private final Map gatesById = new HashMap<>(); + private final Map gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate + + public GateManager(StargatePlugin plugin, GateStorage storage) { + this.plugin = plugin; + this.storage = storage; + reloadConfig(); + } + + public void reloadConfig() { + this.config = new GateConfig(plugin.getConfig(), plugin.getLogger()); + this.scanner = new GateStructureScanner(config.frameMaterials, config.chevronUnlit, config.chevronLit, + config.maxFrameBlocks, config.maxIrisBlocks, config.minFrameBlocks); + } + + public GateConfig getConfig() { return config; } + + public void loadAll() { + gatesById.clear(); + gatesBySignBlock.clear(); + for (Gate gate : storage.loadAll()) { + GateStructure structure = null; + if (gate.getServerId().equals(plugin.getServerId())) { + World world = Bukkit.getWorld(gate.getSignWorld()); + if (world != null) { + Block signBlock = world.getBlockAt(gate.getSignX(), gate.getSignY(), gate.getSignZ()); + Block attached = attachedFrameBlock(signBlock); + if (attached != null) { + structure = scanner.scan(attached); + } + if (structure == null) { + plugin.getLogger().warning("[Stargate] Could not re-scan structure for gate '" + gate.getName() + "' - it may have been damaged."); + } + } + } + RuntimeGate rg = new RuntimeGate(gate, structure); + gatesById.put(gate.getId(), rg); + if (gate.getServerId().equals(plugin.getServerId())) { + gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); + } + } + plugin.getLogger().info("[Stargate] Loaded " + gatesById.size() + " gate(s), " + gatesBySignBlock.size() + " local."); + } + + /** Given the sign block, finds the neighbouring block that belongs to the frame (the wall it's mounted on, or the block below for a sign post). */ + public Block attachedFrameBlock(Block signBlock) { + org.bukkit.block.BlockState state = signBlock.getState(); + if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) { + return signBlock.getRelative(wallSign.getFacing().getOppositeFace()); + } + // sign post or other: just probe all neighbours, scanner will validate + return signBlock.getRelative(org.bukkit.block.BlockFace.DOWN); + } + + private String signKey(String world, int x, int y, int z) { + return world + "," + x + "," + y + "," + z; + } + + public RuntimeGate getBySign(Block signBlock) { + return gatesBySignBlock.get(signKey(signBlock.getWorld().getName(), signBlock.getX(), signBlock.getY(), signBlock.getZ())); + } + + public RuntimeGate getById(UUID id) { return gatesById.get(id); } + + public List getNetwork(String network) { + return gatesById.values().stream() + .filter(g -> g.getGate().getNetwork().equalsIgnoreCase(network)) + .collect(Collectors.toList()); + } + + public List all() { return new ArrayList<>(gatesById.values()); } + + /** Other dialable gates in the same network, excluding this one and hidden ones (owner can still see their own hidden gates). */ + public List destinationsFor(RuntimeGate from) { + return getNetwork(from.getGate().getNetwork()).stream() + .filter(g -> !g.getGate().getId().equals(from.getGate().getId())) + .filter(g -> !g.getGate().isHidden()) + .sorted((a, b) -> a.getGate().getName().compareToIgnoreCase(b.getGate().getName())) + .collect(Collectors.toList()); + } + + public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet flags) { + Block attached = attachedFrameBlock(signBlock); + if (attached == null) return null; + GateStructure structure = scanner.scan(attached); + if (structure == null) return null; + + Location exit = computeExitLocation(structure, signBlock); + Gate gate = new Gate(UUID.randomUUID(), name, network, plugin.getServerId(), + exit.getWorld().getName(), exit.getBlockX(), exit.getBlockY(), exit.getBlockZ(), exit.getYaw(), + signBlock.getX(), signBlock.getY(), signBlock.getZ(), signBlock.getWorld().getName(), + signBlockFacing(signBlock), owner, flags, null); + + RuntimeGate rg = new RuntimeGate(gate, structure); + gatesById.put(gate.getId(), rg); + gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); + storage.saveGate(gate); + return rg; + } + + public void destroyGate(RuntimeGate rg) { + closeGate(rg); + gatesById.remove(rg.getGate().getId()); + gatesBySignBlock.remove(signKey(rg.getGate().getSignWorld(), rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ())); + storage.deleteGate(rg.getGate().getId()); + } + + public void saveGate(RuntimeGate rg) { + storage.saveGate(rg.getGate()); + } + + private Location computeExitLocation(GateStructure structure, Block signBlock) { + List iris = structure.getIris(); + World world = signBlock.getWorld(); + if (iris.isEmpty()) { + return signBlock.getLocation().add(0, 0, 0); + } + long sumX = 0, sumZ = 0; + int minY = Integer.MAX_VALUE; + for (Block b : iris) { + sumX += b.getX(); + sumZ += b.getZ(); + minY = Math.min(minY, b.getY()); + } + double avgX = (double) sumX / iris.size() + 0.5; + double avgZ = (double) sumZ / iris.size() + 0.5; + float yaw = 0f; + org.bukkit.block.BlockState state = signBlock.getState(); + if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) { + // face away from the wall the sign is mounted on, into the room the sign faces + yaw = faceToYaw(wallSign.getFacing()); + } + return new Location(world, avgX, minY + 1, avgZ, yaw, 0f); + } + + private float faceToYaw(org.bukkit.block.BlockFace face) { + return switch (face) { + case NORTH -> 180f; + case SOUTH -> 0f; + case EAST -> -90f; + case WEST -> 90f; + default -> 0f; + }; + } + + private String signBlockFacing(Block signBlock) { + org.bukkit.block.BlockState state = signBlock.getState(); + if (state.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) { + return wallSign.getFacing().name(); + } + return "SELF"; + } + + // ---- Dialing ---- + + /** Starts the chevron-lighting animation, then opens the gate and connects it to the destination. */ + public void dial(RuntimeGate from, RuntimeGate to, Player initiator) { + if (from.isOpen()) closeGate(from); + if (to.isOpen()) closeGate(to); + + List chevrons = from.getStructure() != null ? from.getStructure().getChevrons() : List.of(); + int delay = Math.max(1, config.chevronTickDelay); + + Runnable finish = () -> { + openGate(from, to); + openGate(to, from); + }; + + if (chevrons.isEmpty()) { + finish.run(); + return; + } + + final int[] i = {0}; + final org.bukkit.scheduler.BukkitTask[] taskHolder = new org.bukkit.scheduler.BukkitTask[1]; + taskHolder[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + if (i[0] >= chevrons.size()) { + taskHolder[0].cancel(); + finish.run(); + return; + } + Block chevron = chevrons.get(i[0]); + chevron.setType(config.chevronLit); + if (config.playSounds) { + chevron.getWorld().playSound(chevron.getLocation(), Sound.BLOCK_STONE_STEP, 1f, 1.4f); + } + i[0]++; + }, 0L, delay); + from.setDialTask(taskHolder[0]); + } + + public void openGate(RuntimeGate gate, RuntimeGate connectedTo) { + gate.setOpen(true); + gate.setConnectedTo(connectedTo); + if (gate.getStructure() != null) { + for (Block b : gate.getStructure().getIris()) { + b.setType(config.irisMaterial); + if (b.getBlockData() instanceof Levelled lvl) { + lvl.setLevel(0); + b.setBlockData(lvl); + } + } + for (Block c : gate.getStructure().getChevrons()) { + c.setType(config.chevronLit); + } + } + if (config.playSounds) { + World w = Bukkit.getWorld(gate.getGate().getWorld()); + if (w != null) { + w.playSound(new Location(w, gate.getGate().getExitX(), gate.getGate().getExitY(), gate.getGate().getExitZ()), + Sound.ENTITY_GENERIC_EXPLODE, 0.5f, 1.8f); + } + } + if (gate.getCloseTask() != null) gate.getCloseTask().cancel(); + var task = Bukkit.getScheduler().runTaskLater(plugin, () -> closeGate(gate), config.openSeconds * 20L); + gate.setCloseTask(task); + } + + public void closeGate(RuntimeGate gate) { + if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); } + if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); } + gate.setOpen(false); + RuntimeGate other = gate.getConnectedTo(); + gate.setConnectedTo(null); + if (gate.getStructure() != null) { + for (Block b : gate.getStructure().getIris()) { + b.setType(org.bukkit.Material.AIR); + } + for (Block c : gate.getStructure().getChevrons()) { + c.setType(config.chevronUnlit); + } + } + if (other != null && other.isOpen()) { + closeGate(other); + } + } + + public void closeAllGates() { + for (RuntimeGate rg : new ArrayList<>(gatesById.values())) { + if (rg.isOpen()) closeGate(rg); + } + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructure.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructure.java new file mode 100644 index 0000000..61ba634 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructure.java @@ -0,0 +1,22 @@ +package dev.skywalker3200.stargate.paper.gate; + +import org.bukkit.block.Block; + +import java.util.List; + +/** The physical blocks that make up a scanned gate: frame ring, chevrons, and interior iris. */ +public class GateStructure { + private final List frame; + private final List chevrons; + private final List iris; + + public GateStructure(List frame, List chevrons, List iris) { + this.frame = frame; + this.chevrons = chevrons; + this.iris = iris; + } + + public List getFrame() { return frame; } + public List getChevrons() { return chevrons; } + public List getIris() { return iris; } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java new file mode 100644 index 0000000..5c86d1a --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateStructureScanner.java @@ -0,0 +1,143 @@ +package dev.skywalker3200.stargate.paper.gate; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Flood-fill scanner that discovers a gate's physical structure starting from the block + * a control sign is attached to: the frame ring, the chevron blocks embedded in it, and + * the enclosed interior ("iris") that gets filled with water while the gate is open. + */ +public class GateStructureScanner { + + private static final BlockFace[] NEIGHBORS = { + BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST + }; + + private final Set frameMaterials; + private final Material chevronUnlit; + private final Material chevronLit; + private final int maxFrameBlocks; + private final int maxIrisBlocks; + private final int minFrameBlocks; + + public GateStructureScanner(Set frameMaterials, Material chevronUnlit, Material chevronLit, + int maxFrameBlocks, int maxIrisBlocks, int minFrameBlocks) { + this.frameMaterials = frameMaterials; + this.chevronUnlit = chevronUnlit; + this.chevronLit = chevronLit; + this.maxFrameBlocks = maxFrameBlocks; + this.maxIrisBlocks = maxIrisBlocks; + this.minFrameBlocks = minFrameBlocks; + } + + private boolean isFrameMaterial(Material m) { + return frameMaterials.contains(m) || m == chevronUnlit || m == chevronLit; + } + + private boolean isChevronMaterial(Material m) { + return m == chevronUnlit || m == chevronLit; + } + + /** + * Scans outward from the given seed block (the block the sign is attached to). + * Returns null if no valid enclosed structure is found. + */ + public GateStructure scan(Block seed) { + if (!isFrameMaterial(seed.getType())) { + // seed itself may be the wall block behind a sign that's part of a bigger build; + // try its direct neighbors for the actual frame block. + for (BlockFace face : NEIGHBORS) { + Block b = seed.getRelative(face); + if (isFrameMaterial(b.getType())) { + seed = b; + break; + } + } + } + if (!isFrameMaterial(seed.getType())) return null; + + Set frameKeys = new HashSet<>(); + List frameBlocks = new ArrayList<>(); + List chevronBlocks = new ArrayList<>(); + Deque queue = new ArrayDeque<>(); + queue.add(seed); + frameKeys.add(key(seed)); + + while (!queue.isEmpty()) { + Block cur = queue.poll(); + frameBlocks.add(cur); + if (isChevronMaterial(cur.getType())) chevronBlocks.add(cur); + if (frameBlocks.size() > maxFrameBlocks) return null; + + for (BlockFace face : NEIGHBORS) { + Block next = cur.getRelative(face); + long k = key(next); + if (frameKeys.contains(k)) continue; + if (isFrameMaterial(next.getType())) { + frameKeys.add(k); + queue.add(next); + } + } + } + + if (frameBlocks.size() < minFrameBlocks) return null; + + // Find an interior seed: a non-frame block adjacent to a frame block, that is not + // solid (roughly the middle of the ring). We try several candidates and flood-fill + // each; the first one that stays enclosed within maxIrisBlocks wins. + Set triedSeeds = new HashSet<>(); + for (Block frameBlock : frameBlocks) { + for (BlockFace face : NEIGHBORS) { + Block candidate = frameBlock.getRelative(face); + long ck = key(candidate); + if (frameKeys.contains(ck) || triedSeeds.contains(ck)) continue; + triedSeeds.add(ck); + if (candidate.getType().isSolid()) continue; + + List iris = floodInterior(candidate, frameKeys); + if (iris != null && !iris.isEmpty()) { + return new GateStructure(frameBlocks, chevronBlocks, iris); + } + } + } + return null; + } + + /** Flood-fills non-frame blocks starting at seed; fails (returns null) if it escapes the frame boundary. */ + private List floodInterior(Block seed, Set frameKeys) { + Set visited = new HashSet<>(); + List interior = new ArrayList<>(); + Deque queue = new ArrayDeque<>(); + queue.add(seed); + visited.add(key(seed)); + + while (!queue.isEmpty()) { + Block cur = queue.poll(); + interior.add(cur); + if (interior.size() > maxIrisBlocks) return null; + + for (BlockFace face : NEIGHBORS) { + Block next = cur.getRelative(face); + long k = key(next); + if (visited.contains(k) || frameKeys.contains(k)) continue; + if (next.getType().isSolid()) return null; // hit solid, non-frame block: not a clean ring + visited.add(k); + queue.add(next); + } + } + return interior; + } + + private long key(Block b) { + return (((long) b.getX() & 0x3FFFFFF) << 38) | (((long) (b.getY() + 512) & 0xFFF) << 26) | ((long) b.getZ() & 0x3FFFFFF); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java new file mode 100644 index 0000000..05c4a51 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java @@ -0,0 +1,35 @@ +package dev.skywalker3200.stargate.paper.gate; + +import dev.skywalker3200.stargate.common.model.Gate; +import org.bukkit.scheduler.BukkitTask; + +/** Runtime state for a gate on this server: the persisted model, its scanned blocks, and dial state. */ +public class RuntimeGate { + + private final Gate gate; + private GateStructure structure; // null if structure could not be (re)scanned + private boolean open = false; + private RuntimeGate connectedTo = null; + private BukkitTask closeTask; + private BukkitTask dialTask; + private int cycleIndex = 0; + + public RuntimeGate(Gate gate, GateStructure structure) { + this.gate = gate; + this.structure = structure; + } + + public Gate getGate() { return gate; } + public GateStructure getStructure() { return structure; } + public void setStructure(GateStructure structure) { this.structure = structure; } + public boolean isOpen() { return open; } + public void setOpen(boolean open) { this.open = open; } + public RuntimeGate getConnectedTo() { return connectedTo; } + public void setConnectedTo(RuntimeGate connectedTo) { this.connectedTo = connectedTo; } + public BukkitTask getCloseTask() { return closeTask; } + public void setCloseTask(BukkitTask closeTask) { this.closeTask = closeTask; } + public BukkitTask getDialTask() { return dialTask; } + public void setDialTask(BukkitTask dialTask) { this.dialTask = dialTask; } + public int getCycleIndex() { return cycleIndex; } + public void setCycleIndex(int cycleIndex) { this.cycleIndex = cycleIndex; } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java new file mode 100644 index 0000000..10f3429 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/GateTeleportListener.java @@ -0,0 +1,66 @@ +package dev.skywalker3200.stargate.paper.listener; + +import dev.skywalker3200.stargate.paper.StargatePlugin; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import dev.skywalker3200.stargate.paper.network.CrossServerBridge; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; + +import java.util.List; + +/** Walking into an open gate's water plane teleports the player to the connected gate. */ +public class GateTeleportListener implements Listener { + + private final StargatePlugin plugin; + private final GateManager gateManager; + private final CrossServerBridge crossServerBridge; + + public GateTeleportListener(StargatePlugin plugin, GateManager gateManager, CrossServerBridge crossServerBridge) { + this.plugin = plugin; + this.gateManager = gateManager; + this.crossServerBridge = crossServerBridge; + } + + @EventHandler(ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + Location to = event.getTo(); + if (to == null) return; + if (event.getFrom().getBlockX() == to.getBlockX() && event.getFrom().getBlockY() == to.getBlockY() + && event.getFrom().getBlockZ() == to.getBlockZ()) return; + + Block standing = to.getBlock(); + List gates = gateManager.all(); + for (RuntimeGate rg : gates) { + if (!rg.isOpen() || rg.getStructure() == null || rg.getConnectedTo() == null) continue; + for (Block iris : rg.getStructure().getIris()) { + if (iris.getX() == standing.getX() && iris.getY() == standing.getY() && iris.getZ() == standing.getZ() + && iris.getWorld().equals(standing.getWorld())) { + teleport(event.getPlayer(), rg); + return; + } + } + } + } + + private void teleport(org.bukkit.entity.Player player, RuntimeGate entered) { + RuntimeGate dest = entered.getConnectedTo(); + if (dest == null) return; + + boolean remote = !dest.getGate().getServerId().equals(plugin.getServerId()); + if (remote) { + crossServerBridge.sendPlayerThroughGate(player, dest.getGate()); + return; + } + + var world = org.bukkit.Bukkit.getWorld(dest.getGate().getWorld()); + if (world == null) return; + Location exit = new Location(world, dest.getGate().getExitX() + 0.5, dest.getGate().getExitY(), + dest.getGate().getExitZ() + 0.5, dest.getGate().getExitYaw(), 0f); + player.teleport(exit); + player.playSound(exit, org.bukkit.Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignCreateListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignCreateListener.java new file mode 100644 index 0000000..25b627e --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignCreateListener.java @@ -0,0 +1,73 @@ +package dev.skywalker3200.stargate.paper.listener; + +import dev.skywalker3200.stargate.common.model.Gate; +import dev.skywalker3200.stargate.paper.StargatePlugin; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import dev.skywalker3200.stargate.paper.util.SignRenderer; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.SignChangeEvent; + +import java.util.EnumSet; +import java.util.UUID; + +/** Handles turning a freshly-placed sign reading "[Stargate]" into a registered gate. */ +public class SignCreateListener implements Listener { + + private static final String HEADER = "[stargate]"; + + private final StargatePlugin plugin; + private final GateManager gateManager; + + public SignCreateListener(StargatePlugin plugin, GateManager gateManager) { + this.plugin = plugin; + this.gateManager = gateManager; + } + + @EventHandler(ignoreCancelled = true) + public void onSignChange(SignChangeEvent event) { + String line0 = stripped(event.line(0)); + if (line0 == null || !line0.equalsIgnoreCase(HEADER)) return; + + var player = event.getPlayer(); + if (!player.hasPermission("stargate.create")) { + player.sendMessage(Component.text("You don't have permission to create stargates.", NamedTextColor.RED)); + resetLine(event); + return; + } + + String network = stripped(event.line(1)); + if (network == null || network.isBlank()) network = gateManager.getConfig().defaultNetwork; + + String name = stripped(event.line(2)); + if (name == null || name.isBlank()) name = "Gate-" + Integer.toHexString((int) (Math.random() * 0xFFFF)); + + EnumSet flags = EnumSet.of(Gate.Flag.PUBLIC); + String flagLine = stripped(event.line(3)); + if (flagLine != null) { + if (flagLine.equalsIgnoreCase("hidden")) { flags.clear(); flags.add(Gate.Flag.HIDDEN); } + } + + RuntimeGate rg = gateManager.createGate(event.getBlock(), network, name, player.getUniqueId(), flags); + if (rg == null) { + player.sendMessage(Component.text("No valid gate structure found. Build the frame first, then place the sign.", NamedTextColor.RED)); + resetLine(event); + return; + } + + player.sendMessage(Component.text("Stargate '" + name + "' created on network '" + network + "'.", NamedTextColor.AQUA)); + SignRenderer.render(event, rg, gateManager); + } + + private String stripped(Component c) { + if (c == null) return null; + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(c); + } + + private void resetLine(SignChangeEvent event) { + event.line(0, Component.text("")); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java new file mode 100644 index 0000000..2c2327d --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java @@ -0,0 +1,100 @@ +package dev.skywalker3200.stargate.paper.listener; + +import dev.skywalker3200.stargate.paper.StargatePlugin; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import dev.skywalker3200.stargate.paper.network.CrossServerBridge; +import dev.skywalker3200.stargate.paper.util.SignRenderer; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.block.Sign; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; + +import java.util.List; + +/** Right-click a gate's sign to cycle its destination, left-click to dial it. */ +public class SignInteractListener implements Listener { + + private final StargatePlugin plugin; + private final GateManager gateManager; + private final CrossServerBridge crossServerBridge; + + public SignInteractListener(StargatePlugin plugin, GateManager gateManager, CrossServerBridge crossServerBridge) { + this.plugin = plugin; + this.gateManager = gateManager; + this.crossServerBridge = crossServerBridge; + } + + @EventHandler(ignoreCancelled = true) + public void onInteract(PlayerInteractEvent event) { + Block block = event.getClickedBlock(); + if (block == null || !(block.getState() instanceof Sign)) return; + RuntimeGate rg = gateManager.getBySign(block); + if (rg == null) return; + + var player = event.getPlayer(); + if (!player.hasPermission("stargate.use")) return; + event.setCancelled(true); + + if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { + List destinations = gateManager.destinationsFor(rg); + if (destinations.isEmpty()) { + player.sendMessage(Component.text("No other gates on network '" + rg.getGate().getNetwork() + "'.", NamedTextColor.RED)); + return; + } + if (rg.getGate().isFixed()) { + player.sendMessage(Component.text("This gate's destination is fixed.", NamedTextColor.RED)); + return; + } + rg.setCycleIndex(rg.getCycleIndex() + 1); + SignRenderer.render(rg, gateManager); + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1f, 1f); + } else if (event.getAction() == Action.LEFT_CLICK_BLOCK) { + dial(rg, player); + } + } + + private void dial(RuntimeGate from, org.bukkit.entity.Player player) { + if (from.isOpen()) { + player.sendMessage(Component.text("This gate is already active.", NamedTextColor.RED)); + return; + } + List destinations = gateManager.destinationsFor(from); + RuntimeGate to; + if (from.getGate().isFixed()) { + to = destinations.stream() + .filter(g -> g.getGate().getName().equalsIgnoreCase(from.getGate().getFixedDestination())) + .findFirst().orElse(null); + } else { + if (destinations.isEmpty()) { + player.sendMessage(Component.text("No destinations available.", NamedTextColor.RED)); + return; + } + int idx = ((from.getCycleIndex() % destinations.size()) + destinations.size()) % destinations.size(); + to = destinations.get(idx); + } + if (to == null) { + player.sendMessage(Component.text("Destination gate not found.", NamedTextColor.RED)); + return; + } + if (to.isOpen()) { + player.sendMessage(Component.text("Destination gate is busy.", NamedTextColor.RED)); + return; + } + + boolean remote = !to.getGate().getServerId().equals(plugin.getServerId()); + if (remote && !crossServerBridge.isEnabled()) { + player.sendMessage(Component.text("Cross-server dialing is disabled on this server.", NamedTextColor.RED)); + return; + } + + player.sendMessage(Component.text("Dialing " + to.getGate().getName() + "...", NamedTextColor.AQUA)); + gateManager.dial(from, to, player); + SignRenderer.render(from, gateManager); + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/StructureProtectListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/StructureProtectListener.java new file mode 100644 index 0000000..1a704e8 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/StructureProtectListener.java @@ -0,0 +1,62 @@ +package dev.skywalker3200.stargate.paper.listener; + +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; + +import java.util.List; +import java.util.UUID; + +/** Breaking a gate's sign, frame, or chevron blocks destroys its registration (owner/admin only). */ +public class StructureProtectListener implements Listener { + + private final GateManager gateManager; + + public StructureProtectListener(GateManager gateManager) { + this.gateManager = gateManager; + } + + @EventHandler(ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + Block block = event.getBlock(); + RuntimeGate rg = gateManager.getBySign(block); + boolean isSign = rg != null; + + if (!isSign) { + rg = findByFrameBlock(block); + } + if (rg == null) return; + + UUID owner = rg.getGate().getOwner(); + var player = event.getPlayer(); + boolean allowed = player.hasPermission("stargate.admin") + || (owner != null && owner.equals(player.getUniqueId()) && player.hasPermission("stargate.destroy")); + if (!allowed) { + event.setCancelled(true); + player.sendMessage(Component.text("You can't break this stargate.", NamedTextColor.RED)); + return; + } + + gateManager.destroyGate(rg); + player.sendMessage(Component.text("Stargate '" + rg.getGate().getName() + "' destroyed.", NamedTextColor.YELLOW)); + } + + private RuntimeGate findByFrameBlock(Block block) { + for (RuntimeGate rg : gateManager.all()) { + if (rg.getStructure() == null) continue; + List frame = rg.getStructure().getFrame(); + for (Block b : frame) { + if (b.getX() == block.getX() && b.getY() == block.getY() && b.getZ() == block.getZ() + && b.getWorld().equals(block.getWorld())) { + return rg; + } + } + } + return null; + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java new file mode 100644 index 0000000..c739026 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java @@ -0,0 +1,87 @@ +package dev.skywalker3200.stargate.paper.network; + +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import dev.skywalker3200.stargate.common.model.Gate; +import dev.skywalker3200.stargate.common.network.StargateChannel; +import dev.skywalker3200.stargate.paper.StargatePlugin; +import dev.skywalker3200.stargate.paper.gate.GateManager; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.util.UUID; + +/** + * Talks to the stargate-velocity / stargate-bungee companion plugin over plugin messaging so a + * player dialing a gate hosted on another backend server actually gets moved there and then + * warped to the right spot once they land. + */ +public class CrossServerBridge implements PluginMessageListener { + + private final StargatePlugin plugin; + private final GateManager gateManager; + private boolean enabled = false; + + public CrossServerBridge(StargatePlugin plugin, GateManager gateManager) { + this.plugin = plugin; + this.gateManager = gateManager; + } + + public void register() { + this.enabled = true; + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, StargateChannel.CHANNEL); + Bukkit.getMessenger().registerIncomingPluginChannel(plugin, StargateChannel.CHANNEL, this); + plugin.getLogger().info("[Stargate] Cross-server bridge registered on channel " + StargateChannel.CHANNEL); + } + + public boolean isEnabled() { + return enabled; + } + + /** Asks the proxy to move this player to the backend server hosting {@code destGate}. */ + public void sendPlayerThroughGate(Player player, Gate destGate) { + if (!enabled) return; + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeByte(StargateChannel.OP_TELEPORT_REQUEST); + out.writeUTF(player.getUniqueId().toString()); + out.writeUTF(destGate.getServerId()); + out.writeUTF(destGate.getId().toString()); + player.sendPluginMessage(plugin, StargateChannel.CHANNEL, out.toByteArray()); + } + + @Override + public void onPluginMessageReceived(String channel, Player receivingPlayer, byte[] message) { + if (!channel.equals(StargateChannel.CHANNEL)) return; + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(message)); + byte op = in.readByte(); + if (op != StargateChannel.OP_TELEPORT_DELIVER) return; + + UUID playerId = UUID.fromString(in.readUTF()); + UUID gateId = UUID.fromString(in.readUTF()); + + var rg = gateManager.getById(gateId); + if (rg == null) { + plugin.getLogger().warning("[Stargate] Received teleport-deliver for unknown gate " + gateId); + return; + } + Gate gate = rg.getGate(); + World world = Bukkit.getWorld(gate.getWorld()); + if (world == null) return; + + Bukkit.getScheduler().runTask(plugin, () -> { + Player p = Bukkit.getPlayer(playerId); + if (p == null) return; + Location exit = new Location(world, gate.getExitX() + 0.5, gate.getExitY(), gate.getExitZ() + 0.5, gate.getExitYaw(), 0f); + p.teleport(exit); + }); + } catch (Exception e) { + plugin.getLogger().warning("[Stargate] Failed to handle cross-server teleport message: " + e.getMessage()); + } + } +} diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/util/SignRenderer.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/util/SignRenderer.java new file mode 100644 index 0000000..eb73111 --- /dev/null +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/util/SignRenderer.java @@ -0,0 +1,46 @@ +package dev.skywalker3200.stargate.paper.util; + +import dev.skywalker3200.stargate.paper.gate.GateManager; +import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.block.Block; +import org.bukkit.block.Sign; +import org.bukkit.block.sign.Side; +import org.bukkit.event.block.SignChangeEvent; + +import java.util.List; + +/** Renders a gate's current state (name, network, selected destination) onto its control sign. */ +public final class SignRenderer { + + private SignRenderer() {} + + public static void render(SignChangeEvent event, RuntimeGate rg, GateManager gateManager) { + event.line(0, Component.text(rg.getGate().getName(), NamedTextColor.DARK_AQUA)); + event.line(1, Component.text(rg.getGate().getNetwork(), NamedTextColor.GRAY)); + event.line(2, destinationLine(rg, gateManager)); + event.line(3, rg.isOpen() ? Component.text("[connected]", NamedTextColor.GREEN) : Component.text("")); + } + + public static void render(RuntimeGate rg, GateManager gateManager) { + Block b = org.bukkit.Bukkit.getWorld(rg.getGate().getSignWorld()) + .getBlockAt(rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ()); + if (!(b.getState() instanceof Sign sign)) return; + sign.getSide(Side.FRONT).line(0, Component.text(rg.getGate().getName(), NamedTextColor.DARK_AQUA)); + sign.getSide(Side.FRONT).line(1, Component.text(rg.getGate().getNetwork(), NamedTextColor.GRAY)); + sign.getSide(Side.FRONT).line(2, destinationLine(rg, gateManager)); + sign.getSide(Side.FRONT).line(3, rg.isOpen() ? Component.text("[connected]", NamedTextColor.GREEN) : Component.text("")); + sign.update(true, false); + } + + private static Component destinationLine(RuntimeGate rg, GateManager gateManager) { + List destinations = gateManager.destinationsFor(rg); + if (destinations.isEmpty()) { + return Component.text("no destinations", NamedTextColor.DARK_GRAY); + } + int idx = ((rg.getCycleIndex() % destinations.size()) + destinations.size()) % destinations.size(); + RuntimeGate dest = destinations.get(idx); + return Component.text("> " + dest.getGate().getName() + " <", NamedTextColor.GOLD); + } +} diff --git a/stargate-paper/src/main/resources/config.yml b/stargate-paper/src/main/resources/config.yml new file mode 100644 index 0000000..02eaa91 --- /dev/null +++ b/stargate-paper/src/main/resources/config.yml @@ -0,0 +1,55 @@ +# Stargate configuration + +# Unique id for THIS server. Used to tag gates created here and to route cross-server +# dials through the proxy. Must be unique across your network and match the server's +# name as configured in the Velocity/Bungee proxy config. +server-id: "server1" + +storage: + # sqlite -> single server, file-based, zero setup + # mysql -> required if you want gates to be visible/dialable across multiple + # backend servers sharing this same database + type: sqlite + mysql: + host: localhost + port: 3306 + database: stargate + username: stargate + password: "" + table-prefix: "stargate_" + +# Enables sending players to another backend server when they dial a gate whose +# server-id differs from this one. Requires the stargate-velocity or stargate-bungee +# companion plugin installed on the proxy, and storage.type: mysql so all servers +# share gate data. +cross-server: + enabled: false + +gate: + # Any of these materials count as the decorative frame ring. Mix and match to match + # your build (the default matches an obsidian/gold-block/birch-planks arch). + frame-materials: + - OBSIDIAN + - GOLD_BLOCK + - BIRCH_PLANKS + - OAK_PLANKS + # Chevron blocks embedded in the frame. Build them as chevron-unlit-material; the + # plugin swaps them to chevron-lit-material as the gate dials/opens, and swaps them + # back when the gate closes/deactivates. + chevron-unlit-material: BLACK_STAINED_GLASS + chevron-lit-material: GLOWSTONE + # Material poured into the interior (the "event horizon") while the gate is open. + iris-material: WATER + max-frame-blocks: 300 + max-iris-blocks: 200 + min-frame-blocks: 8 + +dialing: + # Seconds the gate stays open (iris filled) before auto-closing if nobody walks through. + open-seconds: 10 + # Delay in ticks between lighting each chevron during the dial animation. + chevron-tick-delay: 4 + play-sounds: true + +network: + default-network: "main" diff --git a/stargate-paper/src/main/resources/plugin.yml b/stargate-paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..4411810 --- /dev/null +++ b/stargate-paper/src/main/resources/plugin.yml @@ -0,0 +1,27 @@ +name: Stargate +version: '${version}' +main: dev.skywalker3200.stargate.paper.StargatePlugin +api-version: '1.21' +author: skywalker3200 +description: Network-aware Stargate portals with cross-server dialing. +folia-supported: false + +commands: + stargate: + description: Manage Stargate networks and gates. + aliases: [sg] + usage: /sg ... + +permissions: + stargate.use: + description: Allows dialing and using stargates. + default: true + stargate.create: + description: Allows creating new stargates. + default: op + stargate.destroy: + description: Allows destroying stargates you do not own. + default: op + stargate.admin: + description: Allows reload, network admin, and bypassing ownership checks. + default: op diff --git a/stargate-velocity/build.gradle.kts b/stargate-velocity/build.gradle.kts new file mode 100644 index 0000000..a01eeb8 --- /dev/null +++ b/stargate-velocity/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.gradleup.shadow") version "8.3.5" +} + +dependencies { + implementation(project(":stargate-common")) + compileOnly("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") + annotationProcessor("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") +} + +tasks.shadowJar { + archiveClassifier.set("") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java b/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java new file mode 100644 index 0000000..00c0aee --- /dev/null +++ b/stargate-velocity/src/main/java/dev/skywalker3200/stargate/velocity/StargateVelocityPlugin.java @@ -0,0 +1,110 @@ +package dev.skywalker3200.stargate.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import dev.skywalker3200.stargate.common.network.StargateChannel; +import org.slf4j.Logger; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Relays stargate cross-server teleport requests: when a backend server asks to move a + * player to another backend, this plugin connects them there, then forwards a "deliver" + * message to the new backend once they land so the Paper plugin can warp them to the gate. + */ +@Plugin(id = "stargate-velocity", name = "Stargate Velocity Bridge", version = "1.0.0", authors = {"skywalker3200"}) +public class StargateVelocityPlugin { + + private final ProxyServer server; + private final Logger logger; + private final ChannelIdentifier channel = MinecraftChannelIdentifier.from(StargateChannel.CHANNEL); + + // playerId -> (targetServerName, gateId) pending until they finish connecting + private final Map pending = new ConcurrentHashMap<>(); + + private record PendingWarp(String gateId) {} + + @Inject + public StargateVelocityPlugin(ProxyServer server, Logger logger) { + this.server = server; + this.logger = logger; + } + + @Subscribe + public void onInit(ProxyInitializeEvent event) { + server.getChannelRegistrar().register(channel); + logger.info("Stargate Velocity bridge ready on channel {}", StargateChannel.CHANNEL); + } + + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!event.getIdentifier().equals(channel)) return; + event.setResult(PluginMessageEvent.ForwardResult.handled()); + + byte[] data = event.getData(); + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(data)); + byte op = in.readByte(); + if (op != StargateChannel.OP_TELEPORT_REQUEST) return; + + UUID playerId = UUID.fromString(in.readUTF()); + String targetServer = in.readUTF(); + String gateId = in.readUTF(); + + Optional playerOpt = server.getPlayer(playerId); + Optional targetOpt = server.getServer(targetServer); + if (playerOpt.isEmpty() || targetOpt.isEmpty()) { + logger.warn("Stargate teleport request for unknown player/server ({}/{})", playerId, targetServer); + return; + } + + pending.put(playerId, new PendingWarp(gateId)); + playerOpt.get().createConnectionRequest(targetOpt.get()).connectWithIndication().thenAccept(success -> { + if (Boolean.TRUE.equals(success)) { + deliver(playerId, targetOpt.get()); + } else { + pending.remove(playerId); + } + }); + } catch (Exception e) { + logger.warn("Failed to process stargate plugin message", e); + } + } + + private void deliver(UUID playerId, RegisteredServer server) { + PendingWarp warp = pending.remove(playerId); + if (warp == null) return; + Optional connection = server.getPlayersConnected().stream() + .filter(p -> p.getUniqueId().equals(playerId)) + .findFirst() + .flatMap(Player::getCurrentServer); + if (connection.isEmpty()) return; + + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(StargateChannel.OP_TELEPORT_DELIVER); + out.writeUTF(playerId.toString()); + out.writeUTF(warp.gateId()); + connection.get().sendPluginMessage(channel, bytes.toByteArray()); + } catch (Exception e) { + logger.warn("Failed to deliver stargate teleport", e); + } + } +}