Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ subprojects {
archiveClassifier.set("")
archiveFileName.set("${rootProject.name}-${project.name}-${rootProject.version}.jar")

relocate("org.jspecify", "${project.group}.shaded.jspecify")
relocate("org.bouncycastle", "${project.group}.shaded.bouncycastle")
relocate("io.nats", "${project.group}.shaded.nats")
relocate("com.google", "${project.group}.shaded.google")

finalizedBy("publishShadowPublicationToMavenLocal")
}

Expand Down
8 changes: 8 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
group = "fun.ogtimes.skywars"
version = "1.0.0"

dependencies {
api("com.google.code.gson:gson:2.13.2")
api("com.google.guava:guava:33.5.0-jre")
api("io.nats:jnats:2.22.0")
}
39 changes: 39 additions & 0 deletions common/src/main/java/fun/ogtimes/skywars/common/SkyWarsCommon.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package fun.ogtimes.skywars.common;

import fun.ogtimes.skywars.common.keepalive.KeepAliveService;
import fun.ogtimes.skywars.common.nats.NatsHandler;
import io.nats.client.Connection;
import io.nats.client.Nats;
import io.nats.client.Options;

import java.io.IOException;
import java.time.Duration;

public class SkyWarsCommon {
private final NatsHandler natsHandler;
private final KeepAliveService keepAliveService;

public SkyWarsCommon(Options natsOptions) throws IOException, InterruptedException {
Connection connection = Nats.connect(natsOptions);
this.natsHandler = new NatsHandler(connection);
this.keepAliveService = new KeepAliveService(this.natsHandler, Duration.ofSeconds(5), Duration.ofSeconds(15));
}

public SkyWarsCommon(Connection connection) {
this.natsHandler = new NatsHandler(connection);
this.keepAliveService = new KeepAliveService(this.natsHandler, Duration.ofSeconds(5), Duration.ofSeconds(15));
}

public NatsHandler nats() {
return natsHandler;
}

public KeepAliveService keepAlive() {
return keepAliveService;
}

public void destroy() {
if (keepAliveService != null) keepAliveService.stop();
if (natsHandler != null) natsHandler.close();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package fun.ogtimes.skywars.common.instance;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class InstanceProperties {
private String id;
private String address;
private int port;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package fun.ogtimes.skywars.common.keepalive;

import fun.ogtimes.skywars.common.instance.InstanceProperties;

public interface KeepAliveListener {
void onInstanceAlive(InstanceProperties properties);
void onInstanceOffline(InstanceProperties properties);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package fun.ogtimes.skywars.common.keepalive;

import fun.ogtimes.skywars.common.instance.InstanceProperties;
import fun.ogtimes.skywars.common.nats.NatsHandler;
import fun.ogtimes.skywars.common.nats.annotation.IncomingPacketHandler;
import fun.ogtimes.skywars.common.nats.packet.PacketListener;
import fun.ogtimes.skywars.common.nats.packet.impl.KeepAlivePacket;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;

public class KeepAliveService {
private static final Logger LOGGER = Logger.getLogger(KeepAliveService.class.getName());

private final NatsHandler nats;
private final ScheduledExecutorService scheduler;
private final Map<String, Instant> lastSeen = new ConcurrentHashMap<>();
private final CopyOnWriteArraySet<KeepAliveListener> listeners = new CopyOnWriteArraySet<>();
private final Duration interval;
private final Duration timeout;
private final AtomicBoolean running = new AtomicBoolean(false);
private ScheduledFuture<?> future;
private final PacketListener packetListener;

public KeepAliveService(NatsHandler natsHandler, Duration interval, Duration timeout) {
this.nats = natsHandler;
this.interval = interval;
this.timeout = timeout;
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "skywars-keepalive"));

this.packetListener = new PacketListener() {
@IncomingPacketHandler
public void onKeepAlive(KeepAlivePacket packet) {
if (packet != null && packet.properties() != null) {
lastSeen.put(packet.properties().getId(), Instant.now());
listeners.forEach(l -> l.onInstanceAlive(packet.properties()));
}
}
};
}

public void start(InstanceProperties properties) {
if (running.compareAndSet(false, true)) {
try {
nats.registerListener(packetListener);
try {
nats.subscribe();
LOGGER.info("KeepAliveService: subscribed to NATS channels");
} catch (Exception ex) {
LOGGER.log(Level.WARNING, "KeepAliveService: failed to subscribe to NATS channels", ex);
}
} catch (Exception ex) {
LOGGER.log(Level.SEVERE, "Failed to register keepalive listener", ex);
}

LOGGER.info("KeepAliveService: starting scheduler (interval=" + interval.toMillis() + "ms, timeout=" + timeout.toMillis() + "ms)");
future = scheduler.scheduleAtFixedRate(() -> {
try {
nats.sendPacket(new KeepAlivePacket(properties));
LOGGER.fine("KeepAliveService: sent keepalive for " + properties.getId());
lastSeen.put(properties.getId(), Instant.now());

Instant cutoff = Instant.now().minus(timeout);
for (Map.Entry<String, Instant> e : lastSeen.entrySet()) {
if (e.getValue().isBefore(cutoff)) {
listeners.forEach(l -> l.onInstanceOffline(new InstanceProperties(e.getKey(), "", 0)));
lastSeen.remove(e.getKey());
}
}
} catch (Exception ex) {
LOGGER.log(Level.SEVERE, "KeepAlive send failed", ex);
}
}, 0, interval.toMillis(), TimeUnit.MILLISECONDS);
}
}

public void stop() {
if (running.compareAndSet(true, false)) {
if (future != null) future.cancel(true);
scheduler.shutdownNow();

try {
nats.unregisterListener(packetListener);
LOGGER.info("KeepAliveService: unregistered packet listener from NATS");
} catch (Exception ex) {
LOGGER.log(Level.WARNING, "KeepAliveService: failed to unregister packet listener", ex);
}

lastSeen.clear();
listeners.clear();

LOGGER.info("KeepAliveService: stopped");
}
}

public void sendNow(InstanceProperties properties) {
nats.sendPacket(new KeepAlivePacket(properties));
lastSeen.put(properties.getId(), Instant.now());
}

public void registerListener(KeepAliveListener listener) {
listeners.add(listener);
}

public void unregisterListener(KeepAliveListener listener) {
listeners.remove(listener);
}

public boolean isAlive(String instanceId) {
Instant i = lastSeen.get(instanceId);
return i != null && i.isAfter(Instant.now().minus(timeout));
}

public Set<String> getAliveInstances() {
return lastSeen.keySet();
}

/**
* Start only listening for incoming KeepAlivePacket without sending periodic keepalives.
* Useful for proxies that only need to observe instance keepalives.
*/
public void startListening() {
try {
nats.registerListener(packetListener);
try {
nats.subscribe();
LOGGER.info("KeepAliveService: subscribed to NATS channels (listen-only)");
} catch (Exception ex) {
LOGGER.log(Level.WARNING, "KeepAliveService: failed to subscribe to NATS channels (listen-only)", ex);
}
} catch (Exception ex) {
LOGGER.log(Level.SEVERE, "Failed to register keepalive listener (listen-only)", ex);
}
}

/**
* Stop listening for keepalive packets without affecting the send scheduler.
*/
public void stopListening() {
try {
nats.unregisterListener(packetListener);
LOGGER.info("KeepAliveService: unregistered keepalive listener (listen-only)");
} catch (Exception ex) {
LOGGER.log(Level.WARNING, "KeepAliveService: failed to unregister keepalive listener (listen-only)", ex);
}
}
}
Loading