Fix animated cape recovery and add reload command
This commit is contained in:
1 parent
5f02d9cbc5
commit
2c8bb8493e
11 files changed
+396
-78
No files matched your search
@@ -57,6 +57,10 @@ subprojects {
|
||||
options.encoding = "UTF-8"
|
||||
options.release = javaVer
|
||||
}
|
||||
|
||||
tasks.withType(Test).configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
def runtimePlatforms = rootProject.enabled_platforms.split(",").collect { it.trim() }.findAll { !it.isEmpty() }
|
||||
|
||||
@@ -7,4 +7,5 @@ dependencies {
|
||||
// which Architectury remaps to the right annotation on each platform.
|
||||
modImplementation "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}"
|
||||
modApi "dev.architectury:architectury:${rootProject.architectury_api_version}"
|
||||
testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package xyz.nightsync.skinsg;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import xyz.nightsync.skinsg.client.CapeManager;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -13,6 +14,7 @@ import java.nio.file.Path;
|
||||
public final class SkinsgClient {
|
||||
|
||||
private static SkinsgConfig config;
|
||||
private static ClientPacketListener lastConnection;
|
||||
|
||||
private SkinsgClient() {}
|
||||
|
||||
@@ -30,6 +32,11 @@ public final class SkinsgClient {
|
||||
|
||||
/** Call every client tick from the loader event. */
|
||||
public static void tick() {
|
||||
ClientPacketListener connection = Minecraft.getInstance().getConnection();
|
||||
if (connection != lastConnection) {
|
||||
CapeManager.releaseAll();
|
||||
lastConnection = connection;
|
||||
}
|
||||
CapeManager.clientTick();
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,25 @@ public final class SkinsgConfig {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
public synchronized boolean reload() {
|
||||
if (file == null || !Files.isRegularFile(file)) return false;
|
||||
try {
|
||||
SkinsgConfig loaded = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), SkinsgConfig.class);
|
||||
if (loaded == null) return false;
|
||||
loaded.normalize();
|
||||
baseUrl = loaded.baseUrl;
|
||||
mode = loaded.mode;
|
||||
enabled = new LinkedHashSet<>(loaded.enabled);
|
||||
blocked = new LinkedHashSet<>(loaded.blocked);
|
||||
globalSpeed = loaded.globalSpeed;
|
||||
speed = new HashMap<>(loaded.speed);
|
||||
save();
|
||||
return true;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void normalize() {
|
||||
if (baseUrl == null || baseUrl.isBlank()) baseUrl = DEFAULT_BASE_URL;
|
||||
baseUrl = baseUrl.trim();
|
||||
|
||||
@@ -37,6 +37,9 @@ public final class CapeManager {
|
||||
private static final String MODID = "skinsg";
|
||||
private static final int MAX_CACHE = 96;
|
||||
private static final int MAX_BYTES = 12 * 1024 * 1024;
|
||||
private static final long RETRY_BASE_MS = 5_000L;
|
||||
private static final long RETRY_MAX_MS = 5 * 60_000L;
|
||||
private static final long STATIC_CAPE_RECHECK_MS = 15 * 60_000L;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("skinsg");
|
||||
|
||||
private static SkinsgConfig config;
|
||||
@@ -75,7 +78,9 @@ public final class CapeManager {
|
||||
releaseIfPresent(uuid);
|
||||
return null;
|
||||
}
|
||||
return CACHE.computeIfAbsent(uuid, CapeManager::startLoad).texture;
|
||||
Entry entry = CACHE.computeIfAbsent(uuid, Entry::new);
|
||||
scheduleLoad(entry);
|
||||
return entry.texture();
|
||||
}
|
||||
|
||||
public static void clientTick() {
|
||||
@@ -103,37 +108,94 @@ public final class CapeManager {
|
||||
if (e != null) e.release();
|
||||
}
|
||||
|
||||
private static Entry startLoad(UUID uuid) {
|
||||
Entry entry = new Entry(uuid);
|
||||
IO.submit(() -> {
|
||||
try {
|
||||
byte[] bytes = httpGet(config.baseUrl + "/api/minecraft/cape/" + uuid + "?gif");
|
||||
if (bytes == null || bytes.length < 6 || !isGif(bytes)) {
|
||||
entry.noAnimation = true;
|
||||
return;
|
||||
}
|
||||
buildTexture(entry, GifDecoder.decode(bytes, LIMITS));
|
||||
} catch (Exception | LinkageError t) {
|
||||
entry.noAnimation = true;
|
||||
LOGGER.error("[SkinsG/Cape] Failed to load animated cape for uuid={}", uuid, t);
|
||||
private static void scheduleLoad(Entry entry) {
|
||||
if (!entry.beginLoad(System.currentTimeMillis())) return;
|
||||
SkinsgConfig currentConfig = config;
|
||||
String baseUrl = currentConfig == null ? "" : currentConfig.baseUrl;
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
entry.defer(STATIC_CAPE_RECHECK_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
IO.submit(() -> load(entry, baseUrl));
|
||||
} catch (RuntimeException error) {
|
||||
retry(entry, "schedule", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void load(Entry entry, String baseUrl) {
|
||||
try {
|
||||
byte[] bytes = httpGet(baseUrl + "/api/minecraft/cape/" + entry.uuid + "?gif");
|
||||
if (bytes.length < 6 || !isGif(bytes)) {
|
||||
entry.defer(STATIC_CAPE_RECHECK_MS);
|
||||
return;
|
||||
}
|
||||
});
|
||||
return entry;
|
||||
buildTexture(entry, GifDecoder.decode(bytes, LIMITS));
|
||||
} catch (Exception | LinkageError error) {
|
||||
retry(entry, "load", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void retry(Entry entry, String stage, Throwable error) {
|
||||
long delay = entry.retry(System.currentTimeMillis());
|
||||
if (delay >= 0) {
|
||||
LOGGER.warn("[NightSkins/Cape] Animated cape {} failed for uuid={}; retrying in {} ms",
|
||||
stage, entry.uuid, delay, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void buildTexture(Entry entry, GifImage gif) {
|
||||
RenderSystem.recordRenderCall(() -> {
|
||||
try {
|
||||
DynamicTexture tex = new DynamicTexture(gif.width, gif.height, true);
|
||||
((AbstractTexture) tex).setFilter(false, false);
|
||||
try {
|
||||
RenderSystem.recordRenderCall(() -> {
|
||||
if (entry.isReleased()) return;
|
||||
DynamicTexture tex = null;
|
||||
ResourceLocation id = new ResourceLocation(MODID, "cape/" + entry.uuid + "/anim");
|
||||
Minecraft.getInstance().getTextureManager().register(id, tex);
|
||||
entry.attach(gif, tex, id);
|
||||
} catch (Exception | LinkageError t) {
|
||||
entry.noAnimation = true;
|
||||
LOGGER.error("[SkinsG/Cape] Failed to create animated cape texture for uuid={}", entry.uuid, t);
|
||||
boolean registered = false;
|
||||
try {
|
||||
tex = new DynamicTexture(gif.width, gif.height, true);
|
||||
((AbstractTexture) tex).setFilter(false, false);
|
||||
Minecraft.getInstance().getTextureManager().register(id, tex);
|
||||
registered = true;
|
||||
if (!entry.attach(gif, tex, id)) {
|
||||
releaseTexture(id, tex);
|
||||
}
|
||||
} catch (Exception | LinkageError error) {
|
||||
releaseTexture(registered ? id : null, tex);
|
||||
retry(entry, "texture creation", error);
|
||||
}
|
||||
});
|
||||
} catch (Exception | LinkageError error) {
|
||||
retry(entry, "render scheduling", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void releaseTexture(ResourceLocation id, DynamicTexture tex) {
|
||||
Runnable release = () -> {
|
||||
if (id != null) {
|
||||
try {
|
||||
Minecraft.getInstance().getTextureManager().release(id);
|
||||
return;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (tex != null) {
|
||||
try {
|
||||
tex.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (RenderSystem.isOnRenderThread()) release.run();
|
||||
else RenderSystem.recordRenderCall(release::run);
|
||||
} catch (Throwable ignored) {
|
||||
if (tex != null) {
|
||||
try {
|
||||
tex.close();
|
||||
} catch (Throwable ignoredClose) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isGif(byte[] b) {
|
||||
@@ -141,82 +203,171 @@ public final class CapeManager {
|
||||
}
|
||||
|
||||
private static byte[] httpGet(String url) throws IOException {
|
||||
HttpURLConnection c = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
c.setConnectTimeout(7000);
|
||||
c.setReadTimeout(10000);
|
||||
c.setRequestProperty("User-Agent", MODID + "/1.0");
|
||||
int status = c.getResponseCode();
|
||||
if (status != 200) throw new IOException("Cape endpoint returned HTTP " + status);
|
||||
int len = c.getContentLength();
|
||||
if (len > MAX_BYTES) return null;
|
||||
try (InputStream in = c.getInputStream()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(1024, Math.min(Math.max(len, 0), MAX_BYTES)));
|
||||
byte[] buf = new byte[8192];
|
||||
int r, total = 0;
|
||||
while ((r = in.read(buf)) != -1) {
|
||||
total += r;
|
||||
if (total > MAX_BYTES) return null;
|
||||
out.write(buf, 0, r);
|
||||
HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
connection.setConnectTimeout(7000);
|
||||
connection.setReadTimeout(10000);
|
||||
connection.setRequestProperty("User-Agent", MODID + "/1.0");
|
||||
try {
|
||||
int status = connection.getResponseCode();
|
||||
if (status != 200) throw new IOException("Cape endpoint returned HTTP " + status);
|
||||
int length = connection.getContentLength();
|
||||
if (length > MAX_BYTES) throw new IOException("Cape response exceeds " + MAX_BYTES + " bytes");
|
||||
try (InputStream in = connection.getInputStream()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||
Math.max(1024, Math.min(Math.max(length, 0), MAX_BYTES)));
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
int total = 0;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
total += read;
|
||||
if (total > MAX_BYTES) throw new IOException("Cape response exceeds " + MAX_BYTES + " bytes");
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
return out.toByteArray();
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Entry {
|
||||
final UUID uuid;
|
||||
volatile boolean noAnimation;
|
||||
|
||||
private GifImage gif;
|
||||
private DynamicTexture tex;
|
||||
private volatile ResourceLocation texture;
|
||||
private ResourceLocation texture;
|
||||
private int frame = -1;
|
||||
private long nextAt;
|
||||
private long nextLoadAt;
|
||||
private int failures;
|
||||
private boolean loading;
|
||||
private boolean released;
|
||||
|
||||
Entry(UUID uuid) { this.uuid = uuid; }
|
||||
|
||||
void attach(GifImage gif, DynamicTexture tex, ResourceLocation id) {
|
||||
synchronized ResourceLocation texture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
synchronized boolean beginLoad(long now) {
|
||||
if (released || loading || texture != null || now < nextLoadAt) return false;
|
||||
loading = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized void defer(long delay) {
|
||||
if (released) return;
|
||||
loading = false;
|
||||
failures = 0;
|
||||
nextLoadAt = System.currentTimeMillis() + delay;
|
||||
}
|
||||
|
||||
synchronized long retry(long now) {
|
||||
if (released) return -1L;
|
||||
loading = false;
|
||||
failures++;
|
||||
long delay = RetryBackoff.delayMillis(failures, RETRY_BASE_MS, RETRY_MAX_MS);
|
||||
nextLoadAt = now + delay;
|
||||
return delay;
|
||||
}
|
||||
|
||||
synchronized boolean attach(GifImage gif, DynamicTexture tex, ResourceLocation id) {
|
||||
if (released) return false;
|
||||
this.gif = gif;
|
||||
this.tex = tex;
|
||||
this.texture = id;
|
||||
this.frame = -1;
|
||||
this.nextAt = 0L;
|
||||
this.nextLoadAt = 0L;
|
||||
this.failures = 0;
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized boolean isReleased() {
|
||||
return released;
|
||||
}
|
||||
|
||||
void tick(long now, int speedPercent) {
|
||||
if (gif == null || tex == null) return;
|
||||
if (frame != -1 && now < nextAt) return;
|
||||
frame = (frame + 1) % gif.frameCount();
|
||||
|
||||
int[] src = gif.framesAbgr[frame];
|
||||
NativeImage px = tex.getPixels();
|
||||
if (px != null && px.getWidth() == gif.width && px.getHeight() == gif.height) {
|
||||
int i = 0;
|
||||
for (int y = 0; y < gif.height; y++) {
|
||||
for (int x = 0; x < gif.width; x++) {
|
||||
px.setPixelRGBA(x, y, src[i++]);
|
||||
}
|
||||
}
|
||||
RenderSystem.recordRenderCall(tex::upload);
|
||||
GifImage currentGif;
|
||||
DynamicTexture currentTex;
|
||||
int currentFrame;
|
||||
synchronized (this) {
|
||||
if (released || gif == null || tex == null) return;
|
||||
if (frame != -1 && now < nextAt) return;
|
||||
currentGif = gif;
|
||||
currentTex = tex;
|
||||
frame = (frame + 1) % currentGif.frameCount();
|
||||
currentFrame = frame;
|
||||
int base = currentGif.delaysMs[currentFrame];
|
||||
long duration = speedPercent > 0 ? (long) base * 100L / speedPercent : base;
|
||||
nextAt = now + Math.max(20L, duration);
|
||||
}
|
||||
|
||||
int base = gif.delaysMs[frame];
|
||||
long dur = speedPercent > 0 ? (long) base * 100L / speedPercent : base;
|
||||
nextAt = now + Math.max(20L, dur);
|
||||
try {
|
||||
int[] src = currentGif.framesAbgr[currentFrame];
|
||||
NativeImage pixels = currentTex.getPixels();
|
||||
if (pixels == null || pixels.getWidth() != currentGif.width || pixels.getHeight() != currentGif.height) {
|
||||
throw new IllegalStateException("Animated cape texture is no longer allocated");
|
||||
}
|
||||
int i = 0;
|
||||
for (int y = 0; y < currentGif.height; y++) {
|
||||
for (int x = 0; x < currentGif.width; x++) {
|
||||
pixels.setPixelRGBA(x, y, src[i++]);
|
||||
}
|
||||
}
|
||||
if (RenderSystem.isOnRenderThread()) {
|
||||
currentTex.upload();
|
||||
} else {
|
||||
RenderSystem.recordRenderCall(() -> upload(currentTex));
|
||||
}
|
||||
} catch (Exception | LinkageError error) {
|
||||
playbackFailed(currentTex, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void upload(DynamicTexture expectedTexture) {
|
||||
try {
|
||||
expectedTexture.upload();
|
||||
} catch (Exception | LinkageError error) {
|
||||
playbackFailed(expectedTexture, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void playbackFailed(DynamicTexture expectedTexture, Throwable error) {
|
||||
ResourceLocation staleId;
|
||||
synchronized (this) {
|
||||
if (released || tex != expectedTexture) return;
|
||||
staleId = texture;
|
||||
texture = null;
|
||||
tex = null;
|
||||
gif = null;
|
||||
frame = -1;
|
||||
loading = false;
|
||||
failures++;
|
||||
long delay = RetryBackoff.delayMillis(failures, RETRY_BASE_MS, RETRY_MAX_MS);
|
||||
nextLoadAt = System.currentTimeMillis() + delay;
|
||||
}
|
||||
releaseTexture(staleId, expectedTexture);
|
||||
LOGGER.warn("[NightSkins/Cape] Animated cape playback failed for uuid={}; texture will be rebuilt",
|
||||
uuid, error);
|
||||
}
|
||||
|
||||
void release() {
|
||||
ResourceLocation id = texture;
|
||||
if (id != null) {
|
||||
RenderSystem.recordRenderCall(() -> {
|
||||
try {
|
||||
Minecraft.getInstance().getTextureManager().release(id);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
});
|
||||
ResourceLocation staleId;
|
||||
DynamicTexture staleTexture;
|
||||
synchronized (this) {
|
||||
if (released) return;
|
||||
released = true;
|
||||
loading = false;
|
||||
staleId = texture;
|
||||
staleTexture = tex;
|
||||
texture = null;
|
||||
tex = null;
|
||||
gif = null;
|
||||
frame = -1;
|
||||
}
|
||||
texture = null;
|
||||
tex = null;
|
||||
gif = null;
|
||||
releaseTexture(staleId, staleTexture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package xyz.nightsync.skinsg.client;
|
||||
|
||||
final class RetryBackoff {
|
||||
|
||||
private RetryBackoff() {}
|
||||
|
||||
static long delayMillis(int failureCount, long baseDelay, long maxDelay) {
|
||||
if (baseDelay <= 0 || maxDelay < baseDelay) {
|
||||
throw new IllegalArgumentException("Invalid retry delay bounds");
|
||||
}
|
||||
int exponent = Math.max(0, Math.min(30, failureCount - 1));
|
||||
if (baseDelay > (maxDelay >> exponent)) return maxDelay;
|
||||
return Math.min(maxDelay, baseDelay << exponent);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ public final class SkinsgCommands {
|
||||
return b.buildFuture();
|
||||
};
|
||||
|
||||
return SkinsgCommands.<S>literal("skinsg")
|
||||
return SkinsgCommands.<S>literal("nightskins")
|
||||
.then(SkinsgCommands.<S>literal("reload")
|
||||
.executes(c -> reload(fb, cfg, c)))
|
||||
.then(SkinsgCommands.<S>literal("playgif")
|
||||
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); })
|
||||
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
|
||||
@@ -79,6 +81,12 @@ public final class SkinsgCommands {
|
||||
return ok(fb, c, "GIF capes: on for " + target);
|
||||
}
|
||||
|
||||
private static <S> int reload(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
|
||||
if (!cfg.reload()) return err(fb, c, "could not reload config/skinsg.json");
|
||||
CapeManager.releaseAll();
|
||||
return ok(fb, c, "configuration and animated cape cache reloaded");
|
||||
}
|
||||
|
||||
private static <S> int stopgif(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
|
||||
String target = StringArgumentType.getString(c, "target");
|
||||
if (ALL.equals(target)) { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); }
|
||||
@@ -111,12 +119,12 @@ public final class SkinsgCommands {
|
||||
}
|
||||
|
||||
private static <S> int ok(Feedback<S> fb, CommandContext<S> c, String text) {
|
||||
fb.success(c.getSource(), "[skinsg] " + text);
|
||||
fb.success(c.getSource(), "[NightSkins] " + text);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static <S> int err(Feedback<S> fb, CommandContext<S> c, String text) {
|
||||
fb.failure(c.getSource(), "[skinsg] " + text);
|
||||
fb.failure(c.getSource(), "[NightSkins] " + text);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package xyz.nightsync.skinsg;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SkinsgConfigTest {
|
||||
|
||||
@TempDir
|
||||
Path configDir;
|
||||
|
||||
@Test
|
||||
void reloadNormalizesBlankBaseUrl() throws Exception {
|
||||
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||
Files.writeString(configDir.resolve("skinsg.json"), """
|
||||
{
|
||||
"baseUrl": "",
|
||||
"mode": "ALL",
|
||||
"globalSpeed": 100
|
||||
}
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(config.reload());
|
||||
assertEquals(SkinsgConfig.DEFAULT_BASE_URL, config.baseUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedReloadKeepsLastKnownGoodConfiguration() throws Exception {
|
||||
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||
config.baseUrl = "https://example.invalid";
|
||||
Files.writeString(configDir.resolve("skinsg.json"), "{broken", StandardCharsets.UTF_8);
|
||||
|
||||
assertFalse(config.reload());
|
||||
assertEquals("https://example.invalid", config.baseUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package xyz.nightsync.skinsg.client;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class RetryBackoffTest {
|
||||
|
||||
@Test
|
||||
void growsExponentiallyAndCapsAtMaximum() {
|
||||
assertEquals(5_000L, RetryBackoff.delayMillis(1, 5_000L, 300_000L));
|
||||
assertEquals(10_000L, RetryBackoff.delayMillis(2, 5_000L, 300_000L));
|
||||
assertEquals(160_000L, RetryBackoff.delayMillis(6, 5_000L, 300_000L));
|
||||
assertEquals(300_000L, RetryBackoff.delayMillis(7, 5_000L, 300_000L));
|
||||
assertEquals(300_000L, RetryBackoff.delayMillis(Integer.MAX_VALUE, 5_000L, 300_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsNonPositiveFailureCountAsFirstFailure() {
|
||||
assertEquals(5_000L, RetryBackoff.delayMillis(0, 5_000L, 300_000L));
|
||||
assertEquals(5_000L, RetryBackoff.delayMillis(-10, 5_000L, 300_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidBounds() {
|
||||
assertThrows(IllegalArgumentException.class, () -> RetryBackoff.delayMillis(1, 0L, 300_000L));
|
||||
assertThrows(IllegalArgumentException.class, () -> RetryBackoff.delayMillis(1, 5_000L, 4_999L));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package xyz.nightsync.skinsg.commands;
|
||||
|
||||
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import xyz.nightsync.skinsg.SkinsgConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class SkinsgCommandsTest {
|
||||
|
||||
@TempDir
|
||||
Path configDir;
|
||||
|
||||
@Test
|
||||
void exposesNightSkinsRootAndReloadCommand() {
|
||||
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||
SkinsgCommands.Feedback<Object> feedback = new SkinsgCommands.Feedback<>() {
|
||||
@Override
|
||||
public void success(Object source, String message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failure(Object source, String message) {
|
||||
}
|
||||
};
|
||||
|
||||
LiteralCommandNode<Object> root = SkinsgCommands.build(config, feedback).build();
|
||||
|
||||
assertEquals("nightskins", root.getLiteral());
|
||||
assertNotNull(root.getChild("reload"));
|
||||
assertNotNull(root.getChild("playgif"));
|
||||
assertNotNull(root.getChild("stopgif"));
|
||||
assertNotNull(root.getChild("self"));
|
||||
assertNotNull(root.getChild("speed"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ org.gradle.parallel=true
|
||||
# --- Mod ---
|
||||
mod_id=skinsg
|
||||
mod_name=HD Skin Support (Gravit)
|
||||
mod_version=0.1.3
|
||||
mod_version=0.1.4
|
||||
mod_group_id=xyz.nightsync.skinsg
|
||||
mod_license=All Rights Reserved
|
||||
mod_author=Nghtly
|
||||
|
||||
Reference in new issue
Block a user