aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/world
diff options
context:
space:
mode:
authorAnhgelus Morhtuuzh <william@herges.fr>2026-03-19 14:32:46 +0100
committerAnhgelus Morhtuuzh <william@herges.fr>2026-03-19 14:32:46 +0100
commit095aa12d0cf8170014f59d1e1646311989aaca58 (patch)
tree3af7b4e08aaae82a5bdcf6fbd0bb14b519d1cb27 /src/main/java/world
Copy Molehunt and rename
Diffstat (limited to 'src/main/java/world')
-rw-r--r--src/main/java/world/anhgelus/floodhunt/Floodhunt.java225
-rw-r--r--src/main/java/world/anhgelus/floodhunt/config/Config.java140
-rw-r--r--src/main/java/world/anhgelus/floodhunt/config/ConfigPayload.java25
-rw-r--r--src/main/java/world/anhgelus/floodhunt/config/SimpleConfig.java253
-rw-r--r--src/main/java/world/anhgelus/floodhunt/game/Game.java204
-rw-r--r--src/main/java/world/anhgelus/floodhunt/game/GamePayload.java23
-rw-r--r--src/main/java/world/anhgelus/floodhunt/mixin/NoJoinLeaveMessage.java18
-rw-r--r--src/main/java/world/anhgelus/floodhunt/mixin/NoMsgCommand.java17
-rw-r--r--src/main/java/world/anhgelus/floodhunt/mixin/NoPortals.java19
-rw-r--r--src/main/java/world/anhgelus/floodhunt/mixin/WorldTimerAccess.java45
-rw-r--r--src/main/java/world/anhgelus/floodhunt/timer/TickTask.java72
-rw-r--r--src/main/java/world/anhgelus/floodhunt/timer/TimerAccess.java65
-rw-r--r--src/main/java/world/anhgelus/floodhunt/utils/TimeUtils.java52
13 files changed, 1158 insertions, 0 deletions
diff --git a/src/main/java/world/anhgelus/floodhunt/Floodhunt.java b/src/main/java/world/anhgelus/floodhunt/Floodhunt.java
new file mode 100644
index 0000000..95aa95c
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/Floodhunt.java
@@ -0,0 +1,225 @@
+package world.anhgelus.floodhunt;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
+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.ServerLifecycleEvents;
+import net.fabricmc.fabric.api.gamerule.v1.GameRuleBuilder;
+import net.fabricmc.fabric.api.gamerule.v1.GameRuleEvents;
+import net.fabricmc.fabric.api.message.v1.ServerMessageEvents;
+import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+import net.minecraft.network.packet.s2c.play.OverlayMessageS2CPacket;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.command.CommandManager;
+import net.minecraft.server.command.ServerCommandSource;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import net.minecraft.util.Identifier;
+import net.minecraft.world.GameMode;
+import net.minecraft.world.rule.GameRule;
+import net.minecraft.world.rule.GameRuleCategory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import world.anhgelus.floodhunt.config.Config;
+import world.anhgelus.floodhunt.config.ConfigPayload;
+import world.anhgelus.floodhunt.config.SimpleConfig;
+import world.anhgelus.floodhunt.game.Game;
+import world.anhgelus.floodhunt.game.GamePayload;
+
+import java.util.HashMap;
+import java.util.UUID;
+
+import static net.minecraft.server.command.CommandManager.literal;
+
+
+public class Floodhunt implements ModInitializer {
+
+ public static final String MOD_ID = "floodhunt";
+ public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
+ public static final SimpleConfig CONFIG_FILE = Config.configFile(MOD_ID);
+ public static final GameRule<Integer> GAME_DURATION = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("game_duration", 90))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "game_duration_minutes"));
+ public static final GameRule<Integer> MOLE_PERCENTAGE = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("mole_percentage", 25))
+ .range(0, 100)
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "mole_percentage"));
+ public static final GameRule<Integer> MOLE_COUNT = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("mole_count", -1))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "mole_count"));
+ public static final GameRule<Boolean> SHOW_NAMETAGS = GameRuleBuilder
+ .forBoolean(CONFIG_FILE.getOrDefault("show_nametags", false))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "show_nametags"));
+ public static final GameRule<Boolean> SHOW_TAB = GameRuleBuilder
+ .forBoolean(CONFIG_FILE.getOrDefault("show_tab", false))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "show_tab"));
+ public static final GameRule<Boolean> SHOW_SKINS = GameRuleBuilder
+ .forBoolean(CONFIG_FILE.getOrDefault("show_skins", false))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "show_skins"));
+ public static final GameRule<Integer> INITIAL_WORLD_SIZE = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("initial_world_size", 600))
+ .minValue(0)
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "initial_world_size"));
+ public static final GameRule<Integer> FINAL_WORLD_SIZE = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("final_world_size", 100))
+ .minValue(0)
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "final_world_size"));
+ public static final GameRule<Integer> MOVING_STARTING_TIME_OFFSET = GameRuleBuilder
+ .forInteger(CONFIG_FILE.getOrDefault("border_moving_starting_time_offset", 30))
+ .minValue(0)
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "border_moving_starting_time_offset_minutes"));
+ public static final GameRule<Boolean> ENABLE_PORTALS = GameRuleBuilder
+ .forBoolean(CONFIG_FILE.getOrDefault("enable_portals", false))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "enable_portals"));
+ public static final GameRule<Boolean> FOOD_ON_START = GameRuleBuilder
+ .forBoolean(CONFIG_FILE.getOrDefault("food_on_start", true))
+ .category(GameRuleCategory.MISC)
+ .buildAndRegister(Identifier.of(MOD_ID, "food_on_start"));
+ public static Config CONFIG;
+ public static HashMap<UUID, Boolean> timerVisibility = new HashMap<>();
+
+ static {
+ GameRuleEvents.changeCallback(SHOW_NAMETAGS).register(Floodhunt::sendConfigPayload);
+ GameRuleEvents.changeCallback(SHOW_TAB).register(Floodhunt::sendConfigPayload);
+ GameRuleEvents.changeCallback(SHOW_SKINS).register(Floodhunt::sendConfigPayload);
+ }
+
+ public Game game;
+
+ private static <T> void sendConfigPayload(T v, MinecraftServer server) {
+ if (CONFIG == null) return;
+ CONFIG.sendConfigPayload();
+ }
+
+ @Override
+ public void onInitialize() {
+ LOGGER.info("Initializing Floodhunt");
+
+ final var command = literal("floodhunt");
+ command.then(literal("start")
+ .requires(CommandManager.requirePermissionLevel(CommandManager.GAMEMASTERS_CHECK))
+ .executes(context -> {
+ game = new Game(context.getSource().getServer());
+ game.start();
+ return Command.SINGLE_SUCCESS;
+ }));
+ command.then(literal("timer").requires(ServerCommandSource::isExecutedByPlayer).then(
+ literal("show").executes(context -> {
+ var player = context.getSource().getPlayer();
+ assert player != null;
+
+ timerVisibility.put(player.getUuid(), true);
+ context.getSource().sendFeedback(() -> Text.translatable("commands.floodhunt.timer.show"), false);
+
+ if (game == null || !game.started()) {
+ player.networkHandler.sendPacket(new OverlayMessageS2CPacket(
+ Text.translatable("commands.floodhunt.error.game_not_started").formatted(Formatting.RED)
+ ));
+ } else {
+ player.networkHandler.sendPacket(new OverlayMessageS2CPacket(Text.of(game.getRemainingText())));
+ }
+
+ return Command.SINGLE_SUCCESS;
+ })
+ ).then(
+ literal("hide").executes(context -> {
+ var player = context.getSource().getPlayer();
+ assert player != null;
+
+ timerVisibility.put(player.getUuid(), false);
+ context.getSource().sendFeedback(() -> Text.translatable("commands.floodhunt.timer.hide"), false);
+ return Command.SINGLE_SUCCESS;
+ })
+ ));
+ command.then(literal("role")
+ .requires(ServerCommandSource::isExecutedByPlayer)
+ .executes(context -> {
+ if (game == null || !game.started()) {
+ throw (new SimpleCommandExceptionType(Text.translatable("commands.floodhunt.error.game_not_started"))).create();
+ }
+
+ final var source = context.getSource();
+ final var player = source.getPlayer();
+ assert player != null;
+
+ if (game.isMole(player)) {
+ source.sendFeedback(
+ () -> Text.translatable("commands.floodhunt.role.mole")
+ .append("\n\n")
+ .append(Text.translatable("commands.floodhunt.role.mole.list", game.getMolesAsString())),
+ false);
+ } else if (player.isSpectator()) {
+ source.sendFeedback(
+ () -> Text.translatable("commands.floodhunt.role.survivor.mole_count", game.getMolesCount()),
+ false);
+ } else {
+ source.sendFeedback(
+ () -> Text.translatable("commands.floodhunt.role.survivor")
+ .append("\n\n")
+ .append(Text.translatable("commands.floodhunt.role.survivor.mole_count", game.getMolesCount())),
+ false);
+ }
+
+ return Command.SINGLE_SUCCESS;
+ }));
+ command.then(literal("stop")
+ .requires(CommandManager.requirePermissionLevel(CommandManager.GAMEMASTERS_CHECK))
+ .executes(context -> {
+ if (game == null || !game.started()) {
+ throw (new SimpleCommandExceptionType(Text.translatable("commands.floodhunt.error.game_not_started"))).create();
+ }
+
+ game.stop();
+
+ return Command.SINGLE_SUCCESS;
+ }));
+
+ ServerLifecycleEvents.SERVER_STARTED.register(server -> CONFIG = new Config(server));
+
+ CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(command));
+
+ ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> false);
+
+ ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
+ if (!(entity instanceof ServerPlayerEntity) || game == null) return;
+ if (!game.started()) return;
+ if (game.wonByMoles()) game.end();
+ });
+
+ ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> {
+ if (game == null) return;
+ if (!game.started()) return;
+ newPlayer.changeGameMode(GameMode.SPECTATOR);
+ });
+
+ ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
+ ServerPlayNetworking.send(
+ handler.player,
+ new ConfigPayload(CONFIG.nametagsEnabled(), CONFIG.skinsEnabled(), CONFIG.tabEnabled())
+ );
+ ServerPlayNetworking.send(
+ handler.player,
+ new GamePayload(game != null && game.started())
+ );
+ });
+
+ PayloadTypeRegistry.playS2C().register(ConfigPayload.ID, ConfigPayload.CODEC);
+ PayloadTypeRegistry.playS2C().register(GamePayload.ID, GamePayload.CODEC);
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/config/Config.java b/src/main/java/world/anhgelus/floodhunt/config/Config.java
new file mode 100644
index 0000000..4ec87fb
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/config/Config.java
@@ -0,0 +1,140 @@
+package world.anhgelus.floodhunt.config;
+
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+import net.minecraft.server.MinecraftServer;
+import world.anhgelus.floodhunt.Floodhunt;
+
+public class Config {
+
+ private final MinecraftServer server;
+
+ public Config(MinecraftServer server) {
+ this.server = server;
+
+ sendConfigPayload(nametagsEnabled(), skinsEnabled(), tabEnabled());
+ }
+
+ public static SimpleConfig configFile(String fileName) {
+ return SimpleConfig.of(fileName).provider(Config::defaultConfig).request();
+ }
+
+ private static String defaultConfig(String s) {
+ return """
+ # Floodhunt mod configuration file
+ # To regenerate the default configuration, delete, move or rename this file.
+
+ # Game settings
+
+ # The duration of a floodhunt game, in minutes.
+ # Default: 90 minutes (1 hour 30 minutes).
+ game_duration = 90
+
+ # Mole percentage.
+ # For example, a mole percentage of 25% will get 1 mole every 4 players.
+ # Default: 25 %.
+ mole_percentage = 25
+
+ # Mole count (absolute).
+ # This setting will overwrite the mole_percentage setting.
+ # If set below 0, this setting is disabled.
+ # Default: -1.
+ mole_count = -1
+
+ # Give food on start
+ # Default: true
+ food_on_start = true
+
+
+ # Client-side settings (applies to all players)
+
+ # Show nametags
+ # Default: false
+ show_nametags = false
+
+ # Show skins
+ # Default: false
+ show_skins = false
+
+ # Show tab
+ # Default: false
+ show_tab = false
+
+
+ # World border settings
+
+ # Initial world size (in blocks).
+ # Default: 600 blocks.
+ initial_world_size = 600
+
+ # Final world size (in blocks).
+ # Default: 100 blocks.
+ final_world_size = 100
+
+ # Moving starting time offset (in minutes)
+ # The time before starting to move the world borders.
+ # If this value is greater than the game duration, borders will never move.
+ # Default: 30 minutes.
+ border_moving_starting_time_offset = 30
+
+ # Other
+
+ # Enable portals (nether, end, end gateway).
+ # Default: false.
+ enable_portals = false
+ """;
+ }
+
+ public void sendConfigPayload() {
+ final var payload = new ConfigPayload(nametagsEnabled(), skinsEnabled(), tabEnabled());
+ server.getPlayerManager().getPlayerList().forEach(p -> ServerPlayNetworking.send(p, payload));
+ }
+
+ public void sendConfigPayload(boolean showNametags, boolean showSkins, boolean showTab) {
+ final var payload = new ConfigPayload(showNametags, showSkins, showTab);
+ server.getPlayerManager().getPlayerList().forEach(p -> ServerPlayNetworking.send(p, payload));
+ }
+
+ public int getGameDuration() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.GAME_DURATION);
+ }
+
+ public int getMolePercentage() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.MOLE_PERCENTAGE);
+ }
+
+ public int getMoleCount() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.MOLE_COUNT);
+ }
+
+ public boolean nametagsEnabled() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.SHOW_NAMETAGS);
+ }
+
+ public boolean skinsEnabled() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.SHOW_SKINS);
+ }
+
+ public boolean tabEnabled() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.SHOW_TAB);
+ }
+
+ public int getInitialWorldSize() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.INITIAL_WORLD_SIZE);
+ }
+
+ public int getFinalWorldSize() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.FINAL_WORLD_SIZE);
+ }
+
+ public int getBorderShrinkingStartingTimeOffset() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.MOVING_STARTING_TIME_OFFSET);
+ }
+
+ public boolean portalsEnabled() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.ENABLE_PORTALS);
+ }
+
+ public boolean foodOnStart() {
+ return server.getOverworld().getGameRules().getValue(Floodhunt.FOOD_ON_START);
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/config/ConfigPayload.java b/src/main/java/world/anhgelus/floodhunt/config/ConfigPayload.java
new file mode 100644
index 0000000..2b6eb88
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/config/ConfigPayload.java
@@ -0,0 +1,25 @@
+package world.anhgelus.floodhunt.config;
+
+import net.minecraft.network.RegistryByteBuf;
+import net.minecraft.network.codec.PacketCodec;
+import net.minecraft.network.codec.PacketCodecs;
+import net.minecraft.network.packet.CustomPayload;
+import net.minecraft.util.Identifier;
+import world.anhgelus.floodhunt.Floodhunt;
+
+public record ConfigPayload(boolean showNametags, boolean showSkins, boolean showTab) implements CustomPayload {
+ public static final Identifier CONFIG_PACKET_ID = Identifier.of(Floodhunt.MOD_ID, "config");
+
+ public static final CustomPayload.Id<ConfigPayload> ID = new CustomPayload.Id<>(CONFIG_PACKET_ID);
+ public static final PacketCodec<RegistryByteBuf, ConfigPayload> CODEC = PacketCodec.tuple(
+ PacketCodecs.BOOLEAN, ConfigPayload::showNametags,
+ PacketCodecs.BOOLEAN, ConfigPayload::showSkins,
+ PacketCodecs.BOOLEAN, ConfigPayload::showTab,
+ ConfigPayload::new
+ );
+
+ @Override
+ public Id<? extends CustomPayload> getId() {
+ return ID;
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/config/SimpleConfig.java b/src/main/java/world/anhgelus/floodhunt/config/SimpleConfig.java
new file mode 100644
index 0000000..ed5f3ec
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/config/SimpleConfig.java
@@ -0,0 +1,253 @@
+package world.anhgelus.floodhunt.config;
+
+/*
+ * Copyright (c) 2021 magistermaks
+ * Slightly modified by Léo-21 and Anhgelus Morhtuuzh
+ *
+ * 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.
+ */
+
+import net.fabricmc.loader.api.FabricLoader;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Scanner;
+
+public class SimpleConfig {
+
+ private static final Logger LOGGER = LogManager.getLogger("SimpleConfig");
+ private final HashMap<String, String> config = new HashMap<>();
+ private final ConfigRequest request;
+ private boolean broken = false;
+
+ private SimpleConfig(ConfigRequest request) {
+ this.request = request;
+ String identifier = "Config '" + request.filename + "'";
+
+ if (!request.file.exists()) {
+ LOGGER.info("{} is missing, generating default one...", identifier);
+
+ try {
+ createConfig();
+ } catch (IOException e) {
+ LOGGER.error("{} failed to generate!", identifier);
+ LOGGER.trace(e);
+ broken = true;
+ }
+ }
+
+ if (!broken) {
+ try {
+ loadConfig();
+ } catch (Exception e) {
+ LOGGER.error("{} failed to load!", identifier);
+ LOGGER.trace(e);
+ broken = true;
+ }
+ }
+
+ }
+
+ /**
+ * Creates new config request object, ideally `namespace`
+ * should be the name of the mod id of the requesting mod
+ *
+ * @param filename - name of the config file
+ * @return new config request object
+ */
+ public static ConfigRequest of(String filename) {
+ Path path = FabricLoader.getInstance().getConfigDir();
+ return new ConfigRequest(path.resolve(filename + ".properties").toFile(), filename);
+ }
+
+ private void createConfig() throws IOException {
+
+ // try creating missing files
+ request.file.getParentFile().mkdirs();
+ Files.createFile(request.file.toPath());
+
+ // write default config data
+ PrintWriter writer = new PrintWriter(request.file, StandardCharsets.UTF_8);
+ writer.write(request.getConfig());
+ writer.close();
+
+ }
+
+ private void loadConfig() throws IOException {
+ Scanner reader = new Scanner(request.file);
+ for (int line = 1; reader.hasNextLine(); line++) {
+ parseConfigEntry(reader.nextLine(), line);
+ }
+ }
+
+ private void parseConfigEntry(String entry, int line) {
+ if (!entry.isEmpty() && !entry.startsWith("#")) {
+ String[] parts = entry.split("=", 2);
+ if (parts.length == 2) {
+ config.put(parts[0].stripTrailing(), parts[1].strip());
+ } else {
+ throw new RuntimeException("Syntax error in config file on line " + line + "!");
+ }
+ }
+ }
+
+ /**
+ * Queries a value from config, returns `null` if the
+ * key does not exist.
+ *
+ * @return value corresponding to the given key
+ * @see SimpleConfig#getOrDefault
+ */
+ @Deprecated
+ public String get(String key) {
+ return config.get(key);
+ }
+
+ /**
+ * Returns string value from config corresponding to the given
+ * key, or the default string if the key is missing.
+ *
+ * @return value corresponding to the given key, or the default value
+ */
+ public String getOrDefault(String key, String def) {
+ String val = get(key);
+ return val == null ? def : val;
+ }
+
+ /**
+ * Returns integer value from config corresponding to the given
+ * key, or the default integer if the key is missing or invalid.
+ *
+ * @return value corresponding to the given key, or the default value
+ */
+ public int getOrDefault(String key, int def) {
+ try {
+ return Integer.parseInt(get(key));
+ } catch (Exception e) {
+ return def;
+ }
+ }
+
+ /**
+ * Returns boolean value from config corresponding to the given
+ * key, or the default boolean if the key is missing.
+ *
+ * @return value corresponding to the given key, or the default value
+ */
+ public boolean getOrDefault(String key, boolean def) {
+ String val = get(key);
+ if (val != null) {
+ return val.equalsIgnoreCase("true");
+ }
+
+ return def;
+ }
+
+ /**
+ * Returns double value from config corresponding to the given
+ * key, or the default string if the key is missing or invalid.
+ *
+ * @return value corresponding to the given key, or the default value
+ */
+ public double getOrDefault(String key, double def) {
+ try {
+ return Double.parseDouble(get(key));
+ } catch (Exception e) {
+ return def;
+ }
+ }
+
+ /**
+ * If any error occurred during loading or reading from the config
+ * a 'broken' flag is set, indicating that the config's state
+ * is undefined and should be discarded using `delete()`
+ *
+ * @return the 'broken' flag of the configuration
+ */
+ public boolean isBroken() {
+ return broken;
+ }
+
+ /**
+ * deletes the config file from the filesystem
+ *
+ * @return true if the operation was successful
+ */
+ public boolean delete() {
+ LOGGER.warn("Config '{}' was removed from existence! Restart the game to regenerate it.", request.filename);
+ return request.file.delete();
+ }
+
+ public interface DefaultConfig {
+ static String empty(String namespace) {
+ return "";
+ }
+
+ String get(String namespace);
+ }
+
+ public static class ConfigRequest {
+
+ private final File file;
+ private final String filename;
+ private DefaultConfig provider;
+
+ private ConfigRequest(File file, String filename) {
+ this.file = file;
+ this.filename = filename;
+ this.provider = DefaultConfig::empty;
+ }
+
+ /**
+ * Sets the default config provider, used to generate the
+ * config if it's missing.
+ *
+ * @param provider default config provider
+ * @return current config request object
+ * @see DefaultConfig
+ */
+ public ConfigRequest provider(DefaultConfig provider) {
+ this.provider = provider;
+ return this;
+ }
+
+ /**
+ * Loads the config from the filesystem.
+ *
+ * @return config object
+ * @see SimpleConfig
+ */
+ public SimpleConfig request() {
+ return new SimpleConfig(this);
+ }
+
+ private String getConfig() {
+ return provider.get(filename) + "\n";
+ }
+
+ }
+
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/game/Game.java b/src/main/java/world/anhgelus/floodhunt/game/Game.java
new file mode 100644
index 0000000..b395a06
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/game/Game.java
@@ -0,0 +1,204 @@
+package world.anhgelus.floodhunt.game;
+
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.network.packet.s2c.play.OverlayMessageS2CPacket;
+import net.minecraft.network.packet.s2c.play.SubtitleS2CPacket;
+import net.minecraft.network.packet.s2c.play.TitleFadeS2CPacket;
+import net.minecraft.network.packet.s2c.play.TitleS2CPacket;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.Text;
+import net.minecraft.world.GameMode;
+import net.minecraft.world.rule.GameRules;
+import world.anhgelus.floodhunt.Floodhunt;
+import world.anhgelus.floodhunt.timer.TickTask;
+import world.anhgelus.floodhunt.timer.TimerAccess;
+import world.anhgelus.floodhunt.utils.TimeUtils;
+
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class Game {
+
+ public final int defaultTime = Floodhunt.CONFIG.getGameDuration() * 60;
+ private final MinecraftServer server;
+ private final List<UUID> moles = new ArrayList<>();
+ private final TitleFadeS2CPacket timing = new TitleFadeS2CPacket(20, 40, 20);
+ private int remaining = defaultTime;
+ private boolean started = false;
+
+ public Game(MinecraftServer server) {
+ this.server = server;
+ }
+
+ public void start() {
+ final int n = Floodhunt.CONFIG.getMoleCount() < 0
+ ? Math.floorDiv(server.getCurrentPlayerCount(), Math.floorDiv(100, Floodhunt.CONFIG.getMolePercentage()))
+ : Floodhunt.CONFIG.getMoleCount();
+
+ final var playerManager = server.getPlayerManager();
+
+ final var players = new ArrayList<>(playerManager.getPlayerList());
+ for (int i = 0; i < n && !players.isEmpty(); i++) {
+ final var r = ThreadLocalRandom.current().nextInt(0, players.size());
+ final var mole = players.get(r);
+ if (mole == null) throw new IllegalStateException("Mole is null!");
+ moles.add(mole.getUuid());
+ players.remove(r);
+ }
+
+ final var gamerules = server.getOverworld().getGameRules();
+ // immutable gamerules
+ gamerules.setValue(GameRules.SHOW_DEATH_MESSAGES, false, server);
+ gamerules.setValue(GameRules.ANNOUNCE_ADVANCEMENTS, false, server);
+ // gamerules for the start
+ gamerules.setValue(GameRules.DO_IMMEDIATE_RESPAWN, true, server);
+
+ final var timer = TimerAccess.getTimerFromOverworld(server);
+
+ final var worldBorder = server.getOverworld().getWorldBorder();
+ worldBorder.setSize(Floodhunt.CONFIG.getInitialWorldSize());
+ if (Floodhunt.CONFIG.getBorderShrinkingStartingTimeOffset() < Floodhunt.CONFIG.getGameDuration()) {
+ timer.dds_runTask(new TickTask(() -> worldBorder.interpolateSize(
+ Floodhunt.CONFIG.getInitialWorldSize(),
+ Floodhunt.CONFIG.getFinalWorldSize(),
+ (Floodhunt.CONFIG.getGameDuration() - Floodhunt.CONFIG.getBorderShrinkingStartingTimeOffset()) * 60 * 20L,
+ 0L
+ ), Floodhunt.CONFIG.getBorderShrinkingStartingTimeOffset() * 60 * 20L));
+ }
+
+ final var title = new TitleS2CPacket(Text.translatable("floodhunt.game.start.suspense"));
+ playerManager.getPlayerList().forEach(p -> {
+ p.getInventory().clear();
+ p.kill(p.getEntityWorld());
+ p.networkHandler.sendPacket(timing);
+ p.networkHandler.sendPacket(title);
+ p.changeGameMode(GameMode.SURVIVAL);
+ if (Floodhunt.CONFIG.foodOnStart()) p.giveItemStack(new ItemStack(Items.COOKED_BEEF, 64));
+ });
+
+ server.setDefaultGameMode(GameMode.SPECTATOR);
+
+ timer.dds_runTask(new TickTask(() -> {
+ playerManager.getPlayerList().forEach(p -> {
+ p.networkHandler.sendPacket(timing);
+ if (moles.contains(p.getUuid())) {
+ p.networkHandler.sendPacket(new TitleS2CPacket(Text.translatable("floodhunt.game.start.mole.title")));
+ p.networkHandler.sendPacket(new SubtitleS2CPacket(Text.translatable("floodhunt.game.start.mole.subtitle")));
+ } else {
+ p.networkHandler.sendPacket(new TitleS2CPacket(Text.translatable("floodhunt.game.start.survivor.title")));
+ p.networkHandler.sendPacket(new SubtitleS2CPacket(Text.translatable("floodhunt.game.start.survivor.subtitle")));
+ }
+ // reset health and food level
+ p.setHealth(p.getMaxHealth());
+ p.getHungerManager().setFoodLevel(20);
+ p.getHungerManager().setSaturationLevel(5.0f);
+ });
+ // reset gamerules after the start
+ gamerules.setValue(GameRules.DO_IMMEDIATE_RESPAWN, false, server);
+ // reset time and weather
+ server.getOverworld().setTimeOfDay(0);
+ server.getOverworld().resetWeather();
+ changeState(true);
+ timer.dds_runTask(new TickTask(() -> {
+ remaining--;
+ playerManager.getPlayerList().forEach(player -> {
+ if (Floodhunt.timerVisibility.getOrDefault(player.getUuid(), true)) {
+ player.networkHandler.sendPacket(new OverlayMessageS2CPacket(Text.of(getRemainingText())));
+ }
+ });
+ playerManager.sendToAll(timing);
+ if (remaining == 0) end();
+ }, 5 * 20, 20));
+ }, 4 * 20));
+ }
+
+ public void stop() {
+ server.getPlayerManager().broadcast(Text.translatable("commands.floodhunt.stop.success"), false);
+ end();
+ }
+
+ public void end() {
+ final var timer = TimerAccess.getTimerFromOverworld(server);
+ timer.dds_cancel();
+
+ final var worldBorder = server.getOverworld().getWorldBorder();
+ // Stops the border shrinking.
+ worldBorder.setSize(worldBorder.getSize());
+
+ changeState(false);
+ final var pm = server.getPlayerManager();
+ final var winnerSuspense = new TitleS2CPacket(Text.translatable("floodhunt.game.end.suspense.title"));
+ pm.getPlayerList().forEach(p -> {
+ p.networkHandler.sendPacket(timing);
+ p.networkHandler.sendPacket(winnerSuspense);
+ p.changeGameMode(GameMode.CREATIVE);
+ });
+ timer.dds_runTask(new TickTask(() -> {
+ TitleS2CPacket winner;
+ if (wonByMoles()) {
+ winner = new TitleS2CPacket(Text.translatable("floodhunt.game.end.winners.moles.title"));
+ } else {
+ winner = new TitleS2CPacket(Text.translatable("floodhunt.game.end.winners.survivors.title"));
+ }
+ pm.sendToAll(new SubtitleS2CPacket(Text.translatable("floodhunt.game.end.winners.subtitle", getMolesAsString())));
+ pm.sendToAll(winner);
+ pm.sendToAll(timing);
+ moles.clear();
+ }, 4 * 20));
+ }
+
+ public Text getRemainingText() {
+ return Text.of("§c" + TimeUtils.generateShortString(remaining));
+ }
+
+ private Stream<ServerPlayerEntity> getMoles() {
+ return moles.stream()
+ .map(uuid -> server.getPlayerManager().getPlayer(uuid))
+ .filter(Objects::nonNull)
+ .filter(p -> !p.isSpectator() && !p.isCreative());
+ }
+
+ public int getMolesCount() {
+ return getMoles().toArray().length;
+ }
+
+ public String getMolesAsString() {
+ return getMoles().map(PlayerEntity::getDisplayName)
+ .filter(Objects::nonNull)
+ .map(Object::toString)
+ .collect(Collectors.joining(", "));
+ }
+
+ public boolean isMole(ServerPlayerEntity player) {
+ return moles.contains(player.getUuid());
+ }
+
+ public boolean wonByMoles() {
+ final var moles = getMoles().map(PlayerEntity::getUuid).toList();
+ return !moles.isEmpty() && new HashSet<>(moles).containsAll(
+ server.getPlayerManager()
+ .getPlayerList()
+ .stream()
+ .filter(p -> !p.isSpectator() && !p.isCreative())
+ .map(Entity::getUuid)
+ .toList()
+ );
+ }
+
+ public boolean started() {
+ return started;
+ }
+
+ private void changeState(boolean hasStarted) {
+ started = hasStarted;
+ final var payload = new GamePayload(hasStarted);
+ server.getPlayerManager().sendToAll(ServerPlayNetworking.createS2CPacket(payload));
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/game/GamePayload.java b/src/main/java/world/anhgelus/floodhunt/game/GamePayload.java
new file mode 100644
index 0000000..76368df
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/game/GamePayload.java
@@ -0,0 +1,23 @@
+package world.anhgelus.floodhunt.game;
+
+import net.minecraft.network.RegistryByteBuf;
+import net.minecraft.network.codec.PacketCodec;
+import net.minecraft.network.codec.PacketCodecs;
+import net.minecraft.network.packet.CustomPayload;
+import net.minecraft.util.Identifier;
+import world.anhgelus.floodhunt.Floodhunt;
+
+public record GamePayload(boolean gameLaunched) implements CustomPayload {
+ public static final Identifier GAME_PACKET_ID = Identifier.of(Floodhunt.MOD_ID, "game");
+
+ public static final CustomPayload.Id<GamePayload> ID = new CustomPayload.Id<>(GAME_PACKET_ID);
+ public static final PacketCodec<RegistryByteBuf, GamePayload> CODEC = PacketCodec.tuple(
+ PacketCodecs.BOOLEAN, GamePayload::gameLaunched,
+ GamePayload::new
+ );
+
+ @Override
+ public Id<? extends CustomPayload> getId() {
+ return ID;
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/mixin/NoJoinLeaveMessage.java b/src/main/java/world/anhgelus/floodhunt/mixin/NoJoinLeaveMessage.java
new file mode 100644
index 0000000..376afba
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/mixin/NoJoinLeaveMessage.java
@@ -0,0 +1,18 @@
+package world.anhgelus.floodhunt.mixin;
+
+import net.minecraft.server.PlayerManager;
+import net.minecraft.text.Text;
+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.CallbackInfo;
+
+@Mixin(PlayerManager.class)
+public class NoJoinLeaveMessage {
+ @Inject(at = @At("HEAD"), method = "broadcast*", cancellable = true)
+ public void broadcastNoJoinLeaveMessage(Text message, boolean overlay, CallbackInfo ci) {
+ final var content = message.getContent().toString();
+ if (content.startsWith("translation{key='multiplayer.player.joined")) ci.cancel();
+ else if (content.startsWith("translation{key='multiplayer.player.left")) ci.cancel();
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/mixin/NoMsgCommand.java b/src/main/java/world/anhgelus/floodhunt/mixin/NoMsgCommand.java
new file mode 100644
index 0000000..42ae152
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/mixin/NoMsgCommand.java
@@ -0,0 +1,17 @@
+package world.anhgelus.floodhunt.mixin;
+
+import com.mojang.brigadier.CommandDispatcher;
+import net.minecraft.server.command.MessageCommand;
+import net.minecraft.server.command.ServerCommandSource;
+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.CallbackInfo;
+
+@Mixin(MessageCommand.class)
+public class NoMsgCommand {
+ @Inject(at = @At("HEAD"), method = "register", cancellable = true)
+ private static void register(CommandDispatcher<ServerCommandSource> dispatcher, CallbackInfo ci) {
+ ci.cancel();
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/mixin/NoPortals.java b/src/main/java/world/anhgelus/floodhunt/mixin/NoPortals.java
new file mode 100644
index 0000000..59ca3b7
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/mixin/NoPortals.java
@@ -0,0 +1,19 @@
+package world.anhgelus.floodhunt.mixin;
+
+import net.minecraft.entity.Entity;
+import net.minecraft.server.world.ServerWorld;
+import net.minecraft.world.dimension.PortalManager;
+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 world.anhgelus.floodhunt.Floodhunt;
+
+@Mixin(PortalManager.class)
+public class NoPortals {
+ @Inject(at = @At("HEAD"), method = "tick", cancellable = true)
+ public void disableTick(ServerWorld world, Entity entity, boolean canUsePortals, CallbackInfoReturnable<Boolean> cir) {
+ if (Floodhunt.CONFIG == null || Floodhunt.CONFIG.portalsEnabled()) return;
+ cir.setReturnValue(false);
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/mixin/WorldTimerAccess.java b/src/main/java/world/anhgelus/floodhunt/mixin/WorldTimerAccess.java
new file mode 100644
index 0000000..3b7d7fb
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/mixin/WorldTimerAccess.java
@@ -0,0 +1,45 @@
+package world.anhgelus.floodhunt.mixin;
+
+import net.minecraft.server.world.ServerWorld;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import world.anhgelus.floodhunt.timer.TimerAccess;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+
+@Mixin(ServerWorld.class)
+public class WorldTimerAccess implements TimerAccess {
+ @Unique
+ private final List<TickTask> tasks = new ArrayList<>();
+
+ @Unique
+ private final List<TimerAccess.TickTask> tasksToAdd = new ArrayList<>();
+
+ @Inject(method = "tick", at = @At("TAIL"))
+ private void onTick(BooleanSupplier shouldKeepTicking, CallbackInfo ci) {
+ tasks.stream().filter(TickTask::isRunning).forEach(TickTask::tick);
+ tasks.addAll(tasksToAdd);
+ tasksToAdd.clear();
+ }
+
+ @Override
+ public void dds_runTask(TimerAccess.TickTask task) {
+ tasksToAdd.add(task);
+ }
+
+ @Override
+ public void dds_cancel() {
+ tasks.stream().filter(TickTask::isRunning).forEach(TickTask::cancel);
+ tasks.clear();
+ }
+
+ @Override
+ public List<TickTask> dds_getTasks() {
+ return tasks.stream().filter(TickTask::isRunning).toList();
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/timer/TickTask.java b/src/main/java/world/anhgelus/floodhunt/timer/TickTask.java
new file mode 100644
index 0000000..ca7f700
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/timer/TickTask.java
@@ -0,0 +1,72 @@
+package world.anhgelus.floodhunt.timer;
+
+/**
+ * Represents a complete task called each tick
+ */
+public class TickTask implements TimerAccess.TickTask {
+ public final long ticksDelay;
+ public final long ticksRepeat;
+ public final boolean repeating;
+ public final TimerAccess.Task task;
+ private boolean cancelled = false;
+ private long currentTicking;
+
+ /**
+ * Create a new repeating TickTask
+ *
+ * @param task Task to run after the delay or the repeat time
+ * @param ticksDelay Delay before the first task's run
+ * @param ticksRepeat Repeat each tick (if the repeat is 0, it will repeat each tick, if it is below 0, it will not repeat)
+ * @throws IllegalArgumentException if ticksDelay is below 0
+ */
+ public TickTask(TimerAccess.Task task, long ticksDelay, long ticksRepeat) {
+ if (ticksDelay < 0) throw new IllegalArgumentException("Ticks delay must be non-negative");
+ this.ticksDelay = ticksDelay;
+ this.ticksRepeat = ticksRepeat;
+ this.task = task;
+ repeating = ticksRepeat >= 0;
+ currentTicking = ticksDelay;
+ }
+
+ /**
+ * Create a new delayed TickTask
+ *
+ * @param task Task to run after the delay or the repeat time
+ * @param ticksDelay Delay before the first task's run
+ * @throws IllegalArgumentException if ticksDelay or if ticksRepeat is below 0
+ */
+ public TickTask(TimerAccess.Task task, long ticksDelay) {
+ if (ticksDelay < 0) throw new IllegalArgumentException("Ticks delay must be non-negative");
+ this.ticksDelay = ticksDelay;
+ this.ticksRepeat = -1;
+ this.task = task;
+ repeating = false;
+ currentTicking = ticksDelay;
+ }
+
+ public void tick() {
+ if (--currentTicking > 0) return;
+ task.run();
+ if (repeating) {
+ currentTicking = ticksRepeat;
+ } else {
+ cancel();
+ }
+ }
+
+ public long cancel() {
+ if (cancelled) throw new IllegalStateException("Task already cancelled");
+ cancelled = true;
+ return currentTicking;
+ }
+
+ public boolean isRunning() {
+ return !cancelled;
+ }
+
+ @Override
+ public long getTickingBeforeRun() {
+ if (cancelled) return -1;
+ return currentTicking;
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/timer/TimerAccess.java b/src/main/java/world/anhgelus/floodhunt/timer/TimerAccess.java
new file mode 100644
index 0000000..714ee59
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/timer/TimerAccess.java
@@ -0,0 +1,65 @@
+package world.anhgelus.floodhunt.timer;
+
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.World;
+
+import java.util.List;
+
+public interface TimerAccess {
+ /**
+ * Get the timer linked to the overworld
+ *
+ * @param server Current server
+ * @return TimerAccess linked to the overworld
+ */
+ static TimerAccess getTimerFromOverworld(MinecraftServer server) {
+ final var timer = (TimerAccess) server.getWorld(World.OVERWORLD);
+ if (timer == null)
+ throw new NullPointerException("Impossible to get TimerAccess from the overworld (it is null)");
+ return timer;
+ }
+
+ /**
+ * Run a task (called each tick ticked)
+ *
+ * @param task Task to run
+ */
+ void dds_runTask(TimerAccess.TickTask task);
+
+ void dds_cancel();
+
+ /**
+ * @return All non-cancelled tasks
+ */
+ List<TickTask> dds_getTasks();
+
+ interface TickTask {
+ /**
+ * Tick the task
+ */
+ void tick();
+
+ /**
+ * Cancel the task
+ *
+ * @return the remaining ticks before the run of the Task
+ * @throws IllegalStateException if the task is already cancelled
+ */
+ long cancel();
+
+ boolean isRunning();
+
+ /**
+ * @return the number of ticks before run of the task (if the task is cancelled, returns -1)
+ */
+ long getTickingBeforeRun();
+ }
+
+ /**
+ * Represents a task to run after ticking
+ */
+ @FunctionalInterface
+ interface Task {
+ void run();
+ }
+}
diff --git a/src/main/java/world/anhgelus/floodhunt/utils/TimeUtils.java b/src/main/java/world/anhgelus/floodhunt/utils/TimeUtils.java
new file mode 100644
index 0000000..756e0b0
--- /dev/null
+++ b/src/main/java/world/anhgelus/floodhunt/utils/TimeUtils.java
@@ -0,0 +1,52 @@
+package world.anhgelus.floodhunt.utils;
+
+public class TimeUtils {
+
+ public static String generateString(long time) {
+ final var pt = generateTime(time);
+
+ StringBuilder sb = new StringBuilder();
+ if (pt.hours != 0) {
+ sb.append(pt.hours).append(" hours ");
+ }
+ if (pt.minutes != 0 || pt.hours != 0) {
+ sb.append(pt.minutes).append(" minutes ");
+ }
+ sb.append(pt.seconds).append(" seconds");
+
+ return sb.toString();
+ }
+
+ public static String generateShortString(long time) {
+ final var pt = generateTime(time);
+
+ return padLeft(pt.hours) + ":" +
+ padLeft(pt.minutes) + ":" +
+ padLeft(pt.seconds);
+ }
+
+ private static Time generateTime(long time) {
+ long hours = 0;
+ if (time > 3600) {
+ hours = Math.floorDiv(time, 3600);
+ }
+ long minutes = 0;
+ if (hours != 0 || time > 60) {
+ minutes = Math.floorDiv(time - hours * 3600, 60);
+ }
+ long seconds = (long) Math.floor(time - hours * 3600 - minutes * 60);
+ return new Time(hours, minutes, seconds);
+ }
+
+ private static String padLeft(long n) {
+ if (n < 10 && n != 0) {
+ return "0" + Math.round(n);
+ } else if (n == 0) {
+ return "00";
+ }
+ return Long.toString(Math.round(n));
+ }
+
+ private record Time(long hours, long minutes, long seconds) {
+ }
+}