Initial commit

This commit is contained in:
2026-04-10 01:11:20 +03:00
commit b9a7ccf4be
18 changed files with 712 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Gradle
.gradle/
build/
out/
classes/
# IDE
.idea/
*.iml
*.ipr
*.iws
.vscode/
# OS
.DS_Store
# Fabric / Loom
run/
remappedSrc/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
build.gradle Normal file
View File

@@ -0,0 +1,56 @@
plugins {
id 'net.fabricmc.fabric-loom-remap' version "${loom_version}"
id 'maven-publish'
}
version = project.mod_version
group = project.maven_group
base {
archivesName = 'respawn-backoff'
}
repositories {
}
loom {
splitEnvironmentSourceSets()
mods {
"respawn_backoff" {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings loom.officialMojangMappings()
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
}
processResources {
inputs.property "version", project.version
filesMatching("fabric.mod.json") {
expand "version": inputs.properties.version
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
jar {
from("LICENSE") {
rename { "${it}_${base.archivesName.get()}" }
}
}

12
gradle.properties Normal file
View File

@@ -0,0 +1,12 @@
org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
org.gradle.configuration-cache=false
minecraft_version=1.21.1
loader_version=0.18.5
loom_version=1.15-SNAPSHOT
mod_version=1.0.0
maven_group=net.respawnbackoff
fabric_api_version=0.116.10+1.21.1

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

111
gradlew vendored Executable file
View File

@@ -0,0 +1,111 @@
#!/bin/sh
#
# Copyright © 2015 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
#
##############################################################################
app_path=$0
while
APP_HOME=${app_path%"${app_path##*/}"}
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=$APP_HOME$link ;;
esac
done
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
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
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
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"
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."
fi
fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in
max*)
MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in
'' | soft) ;;
*)
ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
fi
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

12
settings.gradle Normal file
View File

@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'respawn-backoff'

View File

@@ -0,0 +1,66 @@
package net.respawnbackoff.client;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import net.respawnbackoff.CooldownSyncPayload;
public class RespawnBackoffClient implements ClientModInitializer {
private static volatile boolean overlayActive;
private static volatile long cooldownEndEpochMs;
@Override
public void onInitializeClient() {
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> clearForDisconnect());
ClientPlayNetworking.registerGlobalReceiver(CooldownSyncPayload.TYPE, (payload, context) -> {
context.client().execute(() -> {
overlayActive = payload.active();
cooldownEndEpochMs = payload.cooldownEndEpochMs();
});
});
HudRenderCallback.EVENT.register((guiGraphics, tickDelta) -> {
if (!overlayActive) {
return;
}
Minecraft client = Minecraft.getInstance();
if (client.player == null) {
return;
}
long remainingMs = Math.max(0L, cooldownEndEpochMs - System.currentTimeMillis());
renderOverlay(guiGraphics, client, remainingMs);
});
}
private static void renderOverlay(GuiGraphics graphics, Minecraft client, long remainingMs) {
int w = client.getWindow().getGuiScaledWidth();
int h = client.getWindow().getGuiScaledHeight();
graphics.fill(0, 0, w, h, 0xFF000000);
int totalSeconds = (int) (remainingMs / 1000L);
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
String time = String.format("%02d:%02d", minutes, seconds);
Component line = Component.translatable("respawn_backoff.hud.countdown", time);
int textWidth = client.font.width(line);
graphics.drawString(
client.font,
line,
(w - textWidth) / 2,
h / 2,
0xFFFFFF,
false
);
}
public static void clearForDisconnect() {
overlayActive = false;
cooldownEndEpochMs = 0L;
}
}

View File

@@ -0,0 +1,25 @@
package net.respawnbackoff;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
public record CooldownSyncPayload(boolean active, long cooldownEndEpochMs) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<CooldownSyncPayload> TYPE = new CustomPacketPayload.Type<>(
ResourceLocation.fromNamespaceAndPath(RespawnBackoffMod.MOD_ID, "cooldown_sync")
);
public static final StreamCodec<RegistryFriendlyByteBuf, CooldownSyncPayload> STREAM_CODEC = StreamCodec.of(
(RegistryFriendlyByteBuf buf, CooldownSyncPayload payload) -> {
buf.writeBoolean(payload.active());
buf.writeLong(payload.cooldownEndEpochMs());
},
buf -> new CooldownSyncPayload(buf.readBoolean(), buf.readLong())
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -0,0 +1,110 @@
package net.respawnbackoff;
import java.util.Collection;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandBuildContext;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
public final class RespawnBackoffCommands {
private RespawnBackoffCommands() {
}
public static void register() {
CommandRegistrationCallback.EVENT.register(RespawnBackoffCommands::registerLiteral);
}
private static void registerLiteral(
CommandDispatcher<CommandSourceStack> dispatcher,
CommandBuildContext registryAccess,
Commands.CommandSelection environment
) {
dispatcher.register(
Commands.literal("respawnbackoff")
.requires(source -> source.hasPermission(2))
.then(
Commands.literal("reset")
.executes(ctx -> resetBackoff(ctx.getSource().getPlayerOrException(), ctx.getSource()))
.then(
Commands.argument("targets", EntityArgument.players())
.executes(ctx -> resetBackoff(EntityArgument.getPlayers(ctx, "targets"), ctx.getSource()))
)
)
.then(
Commands.literal("skip")
.executes(ctx -> skipWait(ctx.getSource().getPlayerOrException(), ctx.getSource()))
.then(
Commands.argument("targets", EntityArgument.players())
.executes(ctx -> skipWait(EntityArgument.getPlayers(ctx, "targets"), ctx.getSource()))
)
)
);
}
private static int resetBackoff(ServerPlayer target, CommandSourceStack source) throws CommandSyntaxException {
RespawnBackoffMod.resetBackoffChain(target);
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.reset.single", target.getDisplayName()),
true
);
return 1;
}
private static int resetBackoff(Collection<ServerPlayer> targets, CommandSourceStack source) {
int n = 0;
for (ServerPlayer target : targets) {
RespawnBackoffMod.resetBackoffChain(target);
n++;
}
if (n == 1) {
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.reset.single", targets.iterator().next().getDisplayName()),
true
);
} else {
int count = n;
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.reset.multiple", count),
true
);
}
return n;
}
private static int skipWait(ServerPlayer target, CommandSourceStack source) throws CommandSyntaxException {
RespawnBackoffMod.skipCooldown(target);
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.skip.single", target.getDisplayName()),
true
);
return 1;
}
private static int skipWait(Collection<ServerPlayer> targets, CommandSourceStack source) {
int n = 0;
for (ServerPlayer target : targets) {
RespawnBackoffMod.skipCooldown(target);
n++;
}
if (n == 1) {
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.skip.single", targets.iterator().next().getDisplayName()),
true
);
} else {
int count = n;
source.sendSuccess(
() -> Component.translatable("respawn_backoff.command.skip.multiple", count),
true
);
}
return n;
}
}

View File

@@ -0,0 +1,24 @@
package net.respawnbackoff;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
public record RespawnBackoffData(
int exponent,
long lastEpochDay,
long pendingDurationMs,
long cooldownEndEpochMs
) {
public static final RespawnBackoffData DEFAULT = new RespawnBackoffData(0, 0L, 0L, 0L);
public static final Codec<RespawnBackoffData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("exponent").forGetter(RespawnBackoffData::exponent),
Codec.LONG.fieldOf("last_epoch_day").forGetter(RespawnBackoffData::lastEpochDay),
Codec.LONG.fieldOf("pending_duration_ms").forGetter(RespawnBackoffData::pendingDurationMs),
Codec.LONG.fieldOf("cooldown_end_ms").forGetter(RespawnBackoffData::cooldownEndEpochMs)
).apply(instance, RespawnBackoffData::new));
public boolean hasActiveCooldown(long nowMs) {
return cooldownEndEpochMs > nowMs;
}
}

View File

@@ -0,0 +1,166 @@
package net.respawnbackoff;
import java.time.LocalDate;
import java.time.ZoneOffset;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.attachment.v1.AttachmentRegistry;
import net.fabricmc.fabric.api.attachment.v1.AttachmentType;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.GameType;
public class RespawnBackoffMod implements ModInitializer {
public static final String MOD_ID = "respawn_backoff";
public static final AttachmentType<RespawnBackoffData> RESPAWN_BACKOFF = AttachmentRegistry.create(
ResourceLocation.fromNamespaceAndPath(MOD_ID, "state"),
builder -> builder
.persistent(RespawnBackoffData.CODEC)
.copyOnDeath()
.initializer(() -> RespawnBackoffData.DEFAULT)
);
@Override
public void onInitialize() {
PayloadTypeRegistry.playS2C().register(CooldownSyncPayload.TYPE, CooldownSyncPayload.STREAM_CODEC);
RespawnBackoffCommands.register();
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (!(entity instanceof ServerPlayer player)) {
return;
}
onPlayerDeath(player);
});
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> {
if (!alive) {
onAfterDeathRespawn(newPlayer);
}
});
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
ServerPlayer player = handler.player;
long now = System.currentTimeMillis();
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
if (data.hasActiveCooldown(now)) {
if (!player.isSpectator()) {
player.setGameMode(GameType.SPECTATOR);
}
RespawnBackoffNetworking.sendCooldown(player, true, data.cooldownEndEpochMs());
} else {
RespawnBackoffNetworking.sendCooldown(player, false, 0L);
}
});
ServerTickEvents.END_SERVER_TICK.register(server -> {
long now = System.currentTimeMillis();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
tickPlayer(player, now);
}
});
}
private static long currentUtcEpochDay() {
return LocalDate.now(ZoneOffset.UTC).toEpochDay();
}
static void onPlayerDeath(ServerPlayer player) {
long today = currentUtcEpochDay();
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
int exponent = data.exponent();
long lastDay = data.lastEpochDay();
if (lastDay != today) {
exponent = 0;
}
int cappedExp = Math.min(exponent, 6);
long waitMinutes = Math.min(1L << cappedExp, 64L);
long pendingMs = waitMinutes * 60_000L;
int nextExponent = Math.min(exponent + 1, 6);
RespawnBackoffData next = new RespawnBackoffData(nextExponent, today, pendingMs, 0L);
player.setAttached(RESPAWN_BACKOFF, next);
}
private static void onAfterDeathRespawn(ServerPlayer player) {
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
long pending = data.pendingDurationMs();
if (pending <= 0L) {
return;
}
long end = System.currentTimeMillis() + pending;
RespawnBackoffData next = new RespawnBackoffData(
data.exponent(),
data.lastEpochDay(),
0L,
end
);
player.setAttached(RESPAWN_BACKOFF, next);
player.setGameMode(GameType.SPECTATOR);
RespawnBackoffNetworking.sendCooldown(player, true, end);
}
private static void tickPlayer(ServerPlayer player, long nowMs) {
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
if (!data.hasActiveCooldown(nowMs)) {
return;
}
if (nowMs < data.cooldownEndEpochMs()) {
return;
}
clearCooldownAndRestore(player, data);
}
/**
* Clears active respawn wait and pending pre-respawn duration; restores survival if needed.
* Does not change the death-count chain (exponent / last day).
*/
public static void skipCooldown(ServerPlayer player) {
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
clearCooldownAndRestore(player, data);
}
private static void clearCooldownAndRestore(ServerPlayer player, RespawnBackoffData data) {
RespawnBackoffData cleared = new RespawnBackoffData(
data.exponent(),
data.lastEpochDay(),
0L,
0L
);
player.setAttached(RESPAWN_BACKOFF, cleared);
if (player.isSpectator()) {
player.setGameMode(GameType.SURVIVAL);
}
RespawnBackoffNetworking.sendCooldown(player, false, 0L);
}
/**
* Next death uses the minimum wait (1 minute): exponent 0 and today's UTC day recorded.
* Does not end an active on-screen countdown; use {@link #skipCooldown} for that.
*/
public static void resetBackoffChain(ServerPlayer player) {
long today = currentUtcEpochDay();
RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT);
RespawnBackoffData next = new RespawnBackoffData(
0,
today,
0L,
data.cooldownEndEpochMs()
);
player.setAttached(RESPAWN_BACKOFF, next);
}
public static boolean isPenaltyActive(ServerPlayer player, long nowMs) {
return player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT).hasActiveCooldown(nowMs);
}
}

View File

@@ -0,0 +1,13 @@
package net.respawnbackoff;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.level.ServerPlayer;
public final class RespawnBackoffNetworking {
private RespawnBackoffNetworking() {
}
public static void sendCooldown(ServerPlayer player, boolean active, long cooldownEndEpochMs) {
ServerPlayNetworking.send(player, new CooldownSyncPayload(active, cooldownEndEpochMs));
}
}

View File

@@ -0,0 +1,25 @@
package net.respawnbackoff.mixin;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.GameType;
import net.respawnbackoff.RespawnBackoffMod;
@Mixin(ServerPlayer.class)
public class ServerPlayerGameModeMixin {
@Inject(method = "setGameMode", at = @At("HEAD"), cancellable = true)
private void respawn_backoff$enforceSpectatorDuringPenalty(GameType gameType, CallbackInfoReturnable<Boolean> cir) {
if (gameType == GameType.SPECTATOR) {
return;
}
ServerPlayer self = (ServerPlayer) (Object) this;
long now = System.currentTimeMillis();
if (RespawnBackoffMod.isPenaltyActive(self, now)) {
cir.setReturnValue(false);
}
}
}

View File

@@ -0,0 +1,7 @@
{
"respawn_backoff.hud.countdown": "Respawn in %s",
"respawn_backoff.command.reset.single": "Reset respawn backoff for %s to the minimum (next death: 1 minute).",
"respawn_backoff.command.reset.multiple": "Reset respawn backoff to the minimum for %s players.",
"respawn_backoff.command.skip.single": "Skipped respawn wait for %s.",
"respawn_backoff.command.skip.multiple": "Skipped respawn wait for %s players."
}

View File

@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"id": "respawn_backoff",
"version": "${version}",
"name": "Respawn Backoff",
"description": "Doubling respawn delay with spectator blackout until the timer ends.",
"authors": [],
"license": "MIT",
"environment": "*",
"entrypoints": {
"main": [
"net.respawnbackoff.RespawnBackoffMod"
],
"client": [
"net.respawnbackoff.client.RespawnBackoffClient"
]
},
"mixins": [
"respawn_backoff.mixins.json"
],
"depends": {
"fabricloader": ">=0.18.5",
"minecraft": "~1.21.1",
"java": ">=21",
"fabric-api": "*"
}
}

View File

@@ -0,0 +1,11 @@
{
"required": true,
"package": "net.respawnbackoff.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": [
"ServerPlayerGameModeMixin"
],
"injectors": {
"defaultRequire": 1
}
}