Initial Stargate plugin: Paper gate plugin + Velocity/Bungee cross-server bridges

This commit is contained in:
Michael Burgess
2026-08-09 09:53:55 -04:00
commit 83a0392ee9
34 changed files with 2317 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
.idea/
*.iml
out/
.vscode/
*.log
+74
View File
@@ -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`
+30
View File
@@ -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<JavaCompile> {
options.encoding = "UTF-8"
}
}
Binary file not shown.
+7
View File
@@ -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
Vendored
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
rootProject.name = "stargate"
include(":stargate-common")
include(":stargate-paper")
include(":stargate-velocity")
include(":stargate-bungee")
+22
View File
@@ -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)
}
}
@@ -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<UUID, String> 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());
}
}
}
@@ -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.
+8
View File
@@ -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")
}
@@ -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<Flag> 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<Flag> 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<Flag> 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;
}
}
@@ -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 <server>, remember pending warp
public static final byte OP_TELEPORT_DELIVER = 2; // proxy -> new backend: this player just arrived, warp them to <gate id>
}
@@ -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<Gate> loadAll();
/** Reload gates belonging to other servers (call periodically when networked). */
List<Gate> loadAllForNetwork(String network);
}
@@ -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<Gate> loadAll() {
List<Gate> 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<Gate> loadAllForNetwork(String network) {
List<Gate> 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<Gate.Flag> 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<Gate.Flag> deserializeFlags(String s) {
Set<Gate.Flag> 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;
}
}
+28
View File
@@ -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)
}
}
@@ -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;
}
}
@@ -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 <list|destroy|reload>", 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<RuntimeGate> 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<String> 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<>();
}
}
@@ -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<Material> 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<Material> 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;
}
}
@@ -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<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> 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<RuntimeGate> getNetwork(String network) {
return gatesById.values().stream()
.filter(g -> g.getGate().getNetwork().equalsIgnoreCase(network))
.collect(Collectors.toList());
}
public List<RuntimeGate> 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<RuntimeGate> 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<Gate.Flag> 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<Block> 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<Block> 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);
}
}
}
@@ -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<Block> frame;
private final List<Block> chevrons;
private final List<Block> iris;
public GateStructure(List<Block> frame, List<Block> chevrons, List<Block> iris) {
this.frame = frame;
this.chevrons = chevrons;
this.iris = iris;
}
public List<Block> getFrame() { return frame; }
public List<Block> getChevrons() { return chevrons; }
public List<Block> getIris() { return iris; }
}
@@ -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<Material> frameMaterials;
private final Material chevronUnlit;
private final Material chevronLit;
private final int maxFrameBlocks;
private final int maxIrisBlocks;
private final int minFrameBlocks;
public GateStructureScanner(Set<Material> 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<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>();
List<Block> chevronBlocks = new ArrayList<>();
Deque<Block> 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<Long> 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<Block> 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<Block> floodInterior(Block seed, Set<Long> frameKeys) {
Set<Long> visited = new HashSet<>();
List<Block> interior = new ArrayList<>();
Deque<Block> 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);
}
}
@@ -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; }
}
@@ -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<RuntimeGate> 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);
}
}
@@ -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<Gate.Flag> 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(""));
}
}
@@ -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<RuntimeGate> 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<RuntimeGate> 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);
}
}
@@ -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<Block> 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;
}
}
@@ -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());
}
}
}
@@ -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<RuntimeGate> 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);
}
}
@@ -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"
@@ -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 <create|destroy|network|reload|list> ...
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
+17
View File
@@ -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)
}
@@ -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<UUID, PendingWarp> 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<Player> playerOpt = server.getPlayer(playerId);
Optional<RegisteredServer> 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<ServerConnection> 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);
}
}
}