diff --git a/@orbitmines/minecraft/remastered/bungeecord/src/main/java/com/orbitmines/archive/minecraft/bungeecord/BungeeCord.java b/@orbitmines/minecraft/remastered/bungeecord/src/main/java/com/orbitmines/archive/minecraft/bungeecord/BungeeCord.java
index 7fb7ac997..a94e110ee 100644
--- a/@orbitmines/minecraft/remastered/bungeecord/src/main/java/com/orbitmines/archive/minecraft/bungeecord/BungeeCord.java
+++ b/@orbitmines/minecraft/remastered/bungeecord/src/main/java/com/orbitmines/archive/minecraft/bungeecord/BungeeCord.java
@@ -29,7 +29,7 @@ public void onLoad() {
public void onEnable() {
bungeecord.onEnable();
- for (Server type : List.of(Server.HUB, Server.KITPVP, Server.SURVIVAL, Server.CREATIVE)) {
+ for (Server type : List.of(Server.HUB, Server.KITPVP, Server.SURVIVAL, Server.CREATIVE, Server.FOG)) {
MinecraftServer server = new MinecraftServer(type, "26.1.2", Environment.get("OM_RAM_" + type.toString(), Environment.get("OM_RAM_DEFAULT", "2G")), findAvailablePort());
server.run();
this.registerServer(server);
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/Spigot.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/Spigot.java
index 460e9eb8e..92e97c366 100644
--- a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/Spigot.java
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/Spigot.java
@@ -8,6 +8,7 @@
import com.orbitmines.archive.minecraft.spigot._2019.servers.kitpvp.KitPvP;
import com.orbitmines.archive.minecraft.spigot._2019.servers.survival.Survival;
import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.placeholders.SpigotServer;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
import org.bukkit.plugin.java.JavaPlugin;
public class Spigot extends JavaPlugin {
@@ -23,6 +24,7 @@ public void onLoad() {
case HUB -> new Hub(this);
case KITPVP -> new KitPvP(this);
case CREATIVE -> new Creative(this);
+ case FOG -> new FoG(this);
default -> throw new IllegalArgumentException("Unsupported server type: " + type);
};
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/OMServer.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/OMServer.java
index 04baca826..7471ae31f 100644
--- a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/OMServer.java
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/OMServer.java
@@ -19,6 +19,8 @@
import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.database.models.survival.*;
import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.database.models.creative.CreativePlayerModel;
import com.orbitmines.archive.minecraft.spigot._2019.servers.creative.CreativeWorldMember;
+import com.orbitmines.minecraft.spigot.servers.fog.database.FoGPlayerModel;
+import com.orbitmines.minecraft.spigot.servers.fog.database.FoGRunModel;
import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.discord.SpigotDiscordBot;
import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.events.*;
import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.prevention.Prevention;
@@ -221,7 +223,11 @@ public void onStart() {
/* Creative */
CreativePlayerModel.TABLE,
- CreativeWorldMember.TABLE
+ CreativeWorldMember.TABLE,
+
+ /* FoG */
+ FoGPlayerModel.TABLE,
+ FoGRunModel.TABLE
);
/* Register to bungeecord startup subscriber first, in case it is starting up/down */
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/guis/ServerSelectorGUI.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/guis/ServerSelectorGUI.java
index e5a82c9eb..49916303a 100644
--- a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/guis/ServerSelectorGUI.java
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/guis/ServerSelectorGUI.java
@@ -21,7 +21,6 @@ public class ServerSelectorGUI, P extends OMPlayer unknownGalaxies = Arrays.asList(
(3 * 9) + 1,
- (3 * 9) + 5,
(3 * 9) + 7,
(4 * 9) + 2,
(4 * 9) + 6
@@ -39,6 +38,8 @@ else if (i == (3 * 9) + 5)
setItem(i, Server.CREATIVE);
else if (i == (3 * 9) + 3)
setItem(i, Server.KITPVP);
+ else if (i == (2 * 9) + 4)
+ setItem(i, Server.FOG);
else if (unknownGalaxies.contains(i))
set(i, new Item
(() -> new ItemBuilder(Material.RED_STAINED_GLASS_PANE, 1, "§c" + viewer.translate("spigot", "player.unknown_galaxies"))));
else
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/utils/ServerUtils.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/utils/ServerUtils.java
index 31b97a264..dc59931c8 100644
--- a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/utils/ServerUtils.java
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/archive/minecraft/spigot/_2019/libs/spigot/utils/ServerUtils.java
@@ -22,6 +22,8 @@ public static ItemBuilder getItemBuilder(Server server) {
return new ItemBuilder(Material.IRON_SWORD).addFlag(ItemFlag.HIDE_ATTRIBUTES);
case CREATIVE:
return new ItemBuilder(Material.WOODEN_AXE).addFlag(ItemFlag.HIDE_ATTRIBUTES);
+ case FOG:
+ return new ItemBuilder(Material.NETHER_STAR).addFlag(ItemFlag.HIDE_ATTRIBUTES);
default:
throw new IllegalStateException();
}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoG.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoG.java
new file mode 100644
index 000000000..18bf20f8a
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoG.java
@@ -0,0 +1,215 @@
+package com.orbitmines.minecraft.spigot.servers.fog;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft._2019.libs.player.PlayerInstance;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.ChatHandler;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.OMServer;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.TabListHandler;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.database.models.OMMap;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.events.CommandEvents;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.loot.LootHandler;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.freezer.events.FreezeEvents;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.prevention.Prevention;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.worlds.maps.datapoints.DataPointHandler;
+import com.orbitmines.minecraft.spigot.servers.fog.ability.AbilityRegistry;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandChoice;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandCodex;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandCoopAccept;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandFactory;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandRun;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandShop;
+import com.orbitmines.minecraft.spigot.servers.fog.commands.CommandWorld;
+import com.orbitmines.minecraft.spigot.servers.fog.events.BlockBreakListener;
+import com.orbitmines.minecraft.spigot.servers.fog.events.DamageListener;
+import com.orbitmines.minecraft.spigot.servers.fog.events.DeathListener;
+import com.orbitmines.minecraft.spigot.servers.fog.events.ExperienceSuppressor;
+import com.orbitmines.minecraft.spigot.servers.fog.events.FoGCommandEvents;
+import com.orbitmines.minecraft.spigot.servers.fog.events.ItemDropListener;
+import com.orbitmines.minecraft.spigot.servers.fog.events.LockedSlotsListener;
+import com.orbitmines.minecraft.spigot.servers.fog.kit.FoGLobbyKit;
+import com.orbitmines.minecraft.spigot.servers.fog.level.LevelUpFlow;
+import com.orbitmines.minecraft.spigot.servers.fog.raid.RaidManager;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.run.RunManager;
+import lombok.Getter;
+import org.bukkit.Location;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitRunnable;
+
+public class FoG extends OMServer {
+
+ @Getter private RunManager runManager;
+ @Getter private LevelUpFlow levelUpFlow;
+ @Getter private AbilityRegistry abilityRegistry;
+ @Getter private FoGLobbyKit lobbyKit;
+
+ public FoG(JavaPlugin plugin) {
+ super(plugin);
+ }
+
+ @Override
+ public FoGPlayer newPlayerInstance(org.bukkit.entity.Player player) {
+ return new FoGPlayer(player, this);
+ }
+
+ @Override
+ public void afterStartupSync() {
+ this.runManager = new RunManager(this);
+ this.levelUpFlow = new LevelUpFlow(this);
+ this.abilityRegistry = new AbilityRegistry();
+ this.lobbyKit = new FoGLobbyKit(this);
+
+ /* Hologram tag setup + sweep any worlds already loaded at startup. */
+ com.orbitmines.minecraft.spigot.servers.fog.util.HologramTag.init(plugin);
+ int removed = com.orbitmines.minecraft.spigot.servers.fog.util.HologramTag.cleanupAllLoaded();
+ if (removed > 0) {
+ org.bukkit.Bukkit.getLogger().info("[fog] Startup swept " + removed + " stale hologram entities across loaded worlds.");
+ }
+
+ registerCommands(
+ new CommandRun(this),
+ new CommandWorld(this),
+ new CommandShop(this),
+ new CommandCodex(this),
+ new CommandFactory(this),
+ new CommandChoice(this),
+ new CommandCoopAccept(this)
+ );
+
+ registerEvents(
+ new ExperienceSuppressor(this),
+ new DeathListener(this),
+ new DamageListener(this),
+ new BlockBreakListener(this),
+ new ItemDropListener(this),
+ new LockedSlotsListener(this),
+ new FreezeEvents(this),
+ new com.orbitmines.minecraft.spigot.servers.fog.events.HologramCleanupListener(this),
+ new com.orbitmines.minecraft.spigot.servers.fog.events.RunGUICloseListener(this)
+ );
+
+ /* Per-run raid + mob ticking */
+ new BukkitRunnable() {
+ @Override
+ public void run() {
+ for (FoGPlayer player : getPlayers()) {
+ Run run = player.getActiveRun();
+ if (run == null) continue;
+ RaidManager rm = player.getRaidManager();
+ if (rm != null) rm.tick();
+ }
+ }
+ }.runTaskTimer(plugin, 20L, 20L);
+
+ /* Periodic stats flush for all online players (async thread) */
+ new BukkitRunnable() {
+ @Override
+ public void run() {
+ for (FoGPlayer player : getPlayers()) {
+ com.orbitmines.minecraft.spigot.servers.fog.stats.Stats s = player.getStats();
+ if (s == null) continue;
+ try { s.flush(); } catch (Exception ignored) {}
+ }
+ }
+ }.runTaskTimerAsynchronously(plugin, 20L * 30, 20L * 30);
+ }
+
+ @Override
+ public CommandEvents newCommandEventsInstance() {
+ return new FoGCommandEvents(this);
+ }
+
+ /**
+ * Skip the base class's {@code PatchNotes.build()} — FoG has no patch notes
+ * registered yet, and the default builder NPEs on {@code getLatest(FOG)}.
+ */
+ @Override
+ public void afterStartupAsync() {
+ /* intentionally empty */
+ }
+
+ private void registerCommands(Command... commands) {
+ for (Command command : commands) {
+ command.register();
+ }
+ }
+
+ /**
+ * Creative-style elevated spawn for players who have no active run yet, so
+ * the run selector holograms are visible in open air. Uses the default
+ * world, positions the player high above its spawn, and enables flight.
+ */
+ public void teleportToSkyAnchor(FoGPlayer player) {
+ org.bukkit.World world = org.bukkit.Bukkit.getWorlds().get(0);
+ org.bukkit.Location spawn = world.getSpawnLocation();
+ org.bukkit.Location anchor = new org.bukkit.Location(world, spawn.getX(), 200, spawn.getZ(), 0f, 0f);
+ player.setGameMode(org.bukkit.GameMode.ADVENTURE);
+ player.teleport(anchor);
+ /* Enable flight one tick later — Bukkit may reset allow-flight during a
+ cross-world teleport, and calling setFlying(true) before that settles
+ throws IllegalArgumentException. Also clear the onJoin invulnerability
+ flag once the player is safely in the sky with flight. */
+ new org.bukkit.scheduler.BukkitRunnable() {
+ @Override public void run() {
+ if (!player.bukkit().isOnline()) return;
+ player.setAllowFlight(true);
+ try {
+ player.setFlying(true);
+ } catch (IllegalArgumentException ignored) {
+ /* Gamemode changed between the teleport and this tick — accept it. */
+ }
+ player.bukkit().setInvulnerable(false);
+ }
+ }.runTaskLater(plugin, 1L);
+ }
+
+ @Override
+ public Server getType() {
+ return Server.FOG;
+ }
+
+ @Override
+ protected FoG instance() {
+ return this;
+ }
+
+ @Override
+ public ChatHandler newChatHandler(PlayerInstance sender, ChatHandler.Type type, String message) {
+ return new FoGChatHandler(this, type, sender, message);
+ }
+
+ @Override
+ public TabListHandler newTabListHandler(FoGPlayer player) {
+ return new TabListHandler<>(this, player);
+ }
+
+ @Override
+ public LootHandler newLootHandler(FoGPlayer player) {
+ return new FoGLootHandler(player);
+ }
+
+ @Override
+ public boolean clearPlayerData() { return false; }
+
+ @Override
+ public boolean saveChunksOnRestart() { return true; }
+
+ @Override
+ public boolean broadcastWhenSaving() { return true; }
+
+ @Override
+ public boolean shouldSetupLobby() { return false; }
+
+ @Override
+ public DataPointHandler createDataPointHandler(OMMap.Type type) { return null; }
+
+ @Override
+ public Prevention[] getLobbyPreventions() { return new Prevention[0]; }
+
+ @Override
+ public void setupNpc(String string, Location location) { }
+
+ @Override
+ protected void instantiateLeaderBoards() { }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGChatHandler.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGChatHandler.java
new file mode 100644
index 000000000..1d78cb2ab
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGChatHandler.java
@@ -0,0 +1,15 @@
+package com.orbitmines.minecraft.spigot.servers.fog;
+
+import com.orbitmines.archive.minecraft._2019.libs.player.PlayerInstance;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.ChatHandler;
+
+public class FoGChatHandler extends ChatHandler {
+
+ public FoGChatHandler(FoG server, Type type, PlayerInstance sender) {
+ super(server, type, sender);
+ }
+
+ public FoGChatHandler(FoG server, Type type, PlayerInstance sender, String message) {
+ super(server, type, sender, message);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGLootHandler.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGLootHandler.java
new file mode 100644
index 000000000..5df52212a
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGLootHandler.java
@@ -0,0 +1,28 @@
+package com.orbitmines.minecraft.spigot.servers.fog;
+
+import com.orbitmines.archive.minecraft._2019.libs.database.models.loot.LootItem;
+import com.orbitmines.archive.minecraft._2019.libs.database.models.loot.PeriodLootItem;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.loot.LootHandler;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.loot.LootItemType;
+
+public class FoGLootHandler extends LootHandler {
+
+ public FoGLootHandler(FoGPlayer player) {
+ super(player);
+ }
+
+ @Override
+ protected void give(LootItemType type, LootItem... items) {
+ /* POC: nothing — vote/donation rewards are not wired yet. */
+ }
+
+ @Override
+ protected void give(PeriodLootItem item) {
+ /* POC: nothing. */
+ }
+
+ @Override
+ protected boolean canReceive(PeriodLootItem.Type type) {
+ return true;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGPlayer.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGPlayer.java
new file mode 100644
index 000000000..dab37c565
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/FoGPlayer.java
@@ -0,0 +1,232 @@
+package com.orbitmines.minecraft.spigot.servers.fog;
+
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.OMPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.database.FoGPlayerModel;
+import com.orbitmines.minecraft.spigot.servers.fog.drone.Drone;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.RunSelector;
+import com.orbitmines.minecraft.spigot.servers.fog.level.LevelFormulas;
+import com.orbitmines.minecraft.spigot.servers.fog.raid.RaidManager;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Difficulty;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.scoreboard.FoGScoreboard;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.Stats;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.StatsHolder;
+import com.orbitmines.minecraft.spigot.servers.fog.util.InventoryBlob;
+import lombok.Getter;
+import lombok.Setter;
+import org.bukkit.attribute.Attribute;
+import org.bukkit.attribute.AttributeInstance;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+public class FoGPlayer extends OMPlayer implements StatsHolder {
+
+ @Getter private FoGPlayerModel playerModel;
+ @Getter @Setter private Run activeRun;
+ @Getter private RaidManager raidManager;
+ @Getter private Stats stats;
+ @Getter private final List drones = new ArrayList<>();
+ @Getter @Setter private boolean dronesActive = false;
+
+ /** Pending coop join requests: requester raw name → run id. */
+ @Getter private final Map coopRequestsFrom = new HashMap<>();
+
+ /** Captured on quit (sync) so {@link #afterQuitAsync()} can write to the DB. */
+ private transient String pendingInventoryBlob;
+ private transient Run pendingInventoryRun;
+
+ public FoGPlayer(Player player, FoG server) {
+ super(player, server);
+ }
+
+ @Override
+ protected void register() {
+ server.registerPlayer(this);
+ }
+
+ @Override
+ protected void unregister() {
+ server.unregisterPlayer(this);
+ }
+
+ @Override
+ public FoGPlayer getInstance() {
+ return this;
+ }
+
+ @Override
+ public boolean onJoin() {
+ super.onJoin();
+ this.playerModel = FoGPlayerModel.findOrInitializeBy(getUUID());
+ if (!this.playerModel.isInserted()) this.playerModel.insert();
+
+ server.runSync(() -> {
+ /* Fall-damage safety net — the player may land mid-air if their last
+ logout location was the sky anchor. Invulnerability only here; the
+ downstream paths (joinRun / teleportToSkyAnchor) own flight state.
+ Setting flight during the initial join handshake causes client
+ desync (player can't move, tab/chat silent, no error). */
+ bukkit().setInvulnerable(true);
+
+ resetScoreboard();
+ setScoreboard(new FoGScoreboard(server, this));
+
+ /* Always-on kit (Switch Run + Spectate) so the player has an escape
+ route regardless of state (spectating, no run, or in their run). */
+ server.getLobbyKit().copyAlwaysOnToInventory(this);
+
+ if (playerModel.getActiveRunId() != null) {
+ server.getRunManager().joinRun(this, playerModel.getActiveRunId());
+ } else {
+ /* No active run — elevate the player to a creative-style sky
+ anchor in the default world and open the run selector. */
+ server.teleportToSkyAnchor(this);
+ RunSelector.open(server, this);
+ }
+ });
+
+ return true;
+ }
+
+ @Override
+ public void beforeQuitSync() {
+ /* Sync thread: safe to serialize the inventory, NOT to write the DB.
+ Capture the blob for afterQuitAsync to persist. */
+ if (activeRun != null) {
+ pendingInventoryRun = activeRun;
+ pendingInventoryBlob = InventoryBlob.serialize(bukkit());
+ }
+ for (Drone d : drones) d.despawn();
+ super.beforeQuitSync();
+ }
+
+ @Override
+ public void afterQuitAsync() {
+ if (pendingInventoryRun != null) {
+ pendingInventoryRun.getStore().setMemberInventoryBlob(getUUID(), pendingInventoryBlob);
+ }
+ super.afterQuitAsync();
+ }
+
+ @Override
+ public void onFirstLogin() {
+ /* The sky-anchor + RunSelector.open flow runs in onJoin for everyone
+ without an active run, so first-login has nothing additional to do. */
+ }
+
+ /** Give the freshly-created-run spawn inventory. Only call on run start / first join. */
+ public void giveRunStartInventory() {
+ server.getLobbyKit().copyToInventory(this);
+ }
+
+ public void toggleDroneMode() {
+ setDronesActive(!dronesActive);
+ for (Drone d : drones) d.setMode(dronesActive ? Drone.Mode.ACTIVE : Drone.Mode.IDLE);
+ /* Kit item material flips LEVER / REDSTONE_TORCH — refresh the slot.
+ The ItemHoverActionBar re-renders every tick, so the ACTIVE/idle label
+ updates on its own — no chat message. */
+ server.getLobbyKit().refreshDroneToggle(this);
+ }
+
+ /** In-memory cache of the player's total run XP. Populated on initForRun from a
+ pre-fetched async read; kept in sync by addExperience. Sync-safe to read. */
+ private volatile long cachedTotalXp;
+
+ public int getLevel() {
+ if (activeRun == null) return 0;
+ return LevelFormulas.computeLevel(cachedTotalXp);
+ }
+
+ public long getTotalXp() {
+ return cachedTotalXp;
+ }
+
+ /** Async-only — writes to the RunStore. */
+ public void addExperience(long amount) {
+ Run run = activeRun;
+ if (run == null) return;
+ int before = getLevel();
+ long newTotal = cachedTotalXp + amount;
+ cachedTotalXp = newTotal;
+ run.getStore().setMemberExperience(getUUID(), newTotal);
+ int after = LevelFormulas.computeLevel(newTotal);
+ run.getStore().setMemberLevel(getUUID(), after);
+ server.runSync(this::updateExperienceBar);
+ if (after > before) {
+ int fBefore = before, fAfter = after;
+ server.runSync(() -> {
+ for (int lvl = fBefore + 1; lvl <= fAfter; lvl++) {
+ server.getLevelUpFlow().start(this, lvl, run);
+ }
+ });
+ }
+ }
+
+ /** Sync-safe — reads only the cached XP. */
+ public void updateExperienceBar() {
+ if (activeRun == null) return;
+ int level = getLevel();
+ long into = LevelFormulas.xpIntoLevel(cachedTotalXp, level);
+ long required = LevelFormulas.required(level);
+ bukkit().setLevel(level);
+ bukkit().setExp(required == 0 ? 0f : Math.min(1f, (float) into / (float) required));
+ }
+
+ /** Sync-safe: reads only the in-memory stats map. */
+ public void applyDerivedStats() {
+ if (activeRun == null || stats == null) return;
+ AttributeInstance maxHp = bukkit().getAttribute(Attribute.MAX_HEALTH);
+ if (maxHp != null) maxHp.setBaseValue(Math.max(2.0, 20.0 + stats.getExtraHearts() * 2.0));
+ AttributeInstance speed = bukkit().getAttribute(Attribute.MOVEMENT_SPEED);
+ if (speed != null) speed.setBaseValue(0.1 * stats.getSpeedMultiplier());
+ }
+
+ /**
+ * Sync-only. Applies a new run to this player. DB reads/writes are the caller's
+ * responsibility — see {@link com.orbitmines.minecraft.spigot.servers.fog.run.RunManager}.
+ *
+ * @param newRun new run (or null to leave)
+ * @param restoreFromBlob pre-fetched inventory blob; null means don't touch inventory
+ * @param preloadedStats Stats already loaded async by the caller; becomes this
+ * player's stats handle. Pass a fresh empty Stats for new runs.
+ * @param preloadedTotalXp Member experience pre-fetched from the store (async).
+ */
+ public void initForRun(Run newRun, String restoreFromBlob, Stats preloadedStats, long preloadedTotalXp) {
+ this.activeRun = newRun;
+ if (newRun != null) {
+ this.raidManager = new RaidManager(newRun);
+ this.stats = preloadedStats;
+ this.cachedTotalXp = preloadedTotalXp;
+ if (restoreFromBlob != null) InventoryBlob.deserializeInto(bukkit(), restoreFromBlob);
+ applyDerivedStats();
+ updateExperienceBar();
+ } else {
+ this.raidManager = null;
+ this.stats = null;
+ this.cachedTotalXp = 0L;
+ }
+ }
+
+ @Override
+ public UUID getOwnerUuid() { return getUUID(); }
+
+ @Override
+ public String getStatsPrefix() { return "member:" + getUUID(); }
+
+ @Override
+ public Stats getStats() {
+ /* Returns the already-loaded Stats handle, or null if no run is active.
+ Never allocates here — a new Stats would be empty until load() runs async,
+ which would silently report zeros. Callers must tolerate null. */
+ return stats;
+ }
+
+ public Difficulty getDifficulty() {
+ return activeRun != null ? activeRun.getDifficulty() : Difficulty.NORMAL;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/Ability.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/Ability.java
new file mode 100644
index 000000000..0acdc7880
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/Ability.java
@@ -0,0 +1,26 @@
+package com.orbitmines.minecraft.spigot.servers.fog.ability;
+
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Choice;
+import lombok.Getter;
+
+public enum Ability {
+
+ SHIELD_BASH(Choice.ABILITY_SHIELD, 8000, 3.0),
+ SWEEP_ATTACK(Choice.ABILITY_SWEEP, 4000, 2.0),
+ LEAP(Choice.ABILITY_LEAP, 6000, 0.0);
+
+ @Getter private final Choice unlockChoice;
+ @Getter private final long cooldownMs;
+ @Getter private final double damage;
+
+ Ability(Choice unlockChoice, long cooldownMs, double damage) {
+ this.unlockChoice = unlockChoice;
+ this.cooldownMs = cooldownMs;
+ this.damage = damage;
+ }
+
+ public String nameKey() { return "ability." + name().toLowerCase() + ".name"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/AbilityRegistry.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/AbilityRegistry.java
new file mode 100644
index 000000000..4378acf17
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/ability/AbilityRegistry.java
@@ -0,0 +1,26 @@
+package com.orbitmines.minecraft.spigot.servers.fog.ability;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/** Per-player ability cooldown tracker. */
+public class AbilityRegistry {
+
+ private final Map> cooldowns = new HashMap<>();
+
+ public boolean tryTrigger(UUID uuid, Ability ability) {
+ Map map = cooldowns.computeIfAbsent(uuid, k -> new HashMap<>());
+ long now = System.currentTimeMillis();
+ long ready = map.getOrDefault(ability, 0L);
+ if (now < ready) return false;
+ map.put(ability, now + ability.getCooldownMs());
+ return true;
+ }
+
+ public long remaining(UUID uuid, Ability ability) {
+ Map map = cooldowns.get(uuid);
+ if (map == null) return 0;
+ return Math.max(0, map.getOrDefault(ability, 0L) - System.currentTimeMillis());
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/beehive/BeehiveHandler.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/beehive/BeehiveHandler.java
new file mode 100644
index 000000000..4dfaaa466
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/beehive/BeehiveHandler.java
@@ -0,0 +1,113 @@
+package com.orbitmines.minecraft.spigot.servers.fog.beehive;
+
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.run.RunStore;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.block.Campfire;
+import org.bukkit.entity.Bee;
+import org.bukkit.entity.EntityType;
+
+import java.util.Random;
+
+/**
+ * Scatters bee-hive "nests" around trees. Each nest is:
+ * - a Beehive block placed on top of the highest solid block;
+ * - a lit campfire one block below the hive, producing signal smoke;
+ * - a ring of random flowers (dandelion, poppy, allium, etc.) on the ground;
+ * - a small swarm of Bees spawned near each hive.
+ *
+ * Each placed hive's block coordinates are remembered in the {@link RunStore} so
+ * {@link com.orbitmines.minecraft.spigot.servers.fog.events.BlockBreakListener}
+ * can keep the hive unbreakable (part of the world's natural decor).
+ */
+public class BeehiveHandler {
+
+ private static final Random RANDOM = new Random();
+
+ private static final Material[] FLOWERS = {
+ Material.DANDELION, Material.POPPY, Material.BLUE_ORCHID, Material.ALLIUM,
+ Material.AZURE_BLUET, Material.RED_TULIP, Material.ORANGE_TULIP, Material.WHITE_TULIP,
+ Material.PINK_TULIP, Material.OXEYE_DAISY, Material.CORNFLOWER, Material.LILY_OF_THE_VALLEY
+ };
+
+ public static boolean isProtectedHive(Run run, Location location) {
+ if (run == null || location == null) return false;
+ return run.getStore().isHoneyTree(location.getBlockX(), location.getBlockY(), location.getBlockZ());
+ }
+
+ /**
+ * Populate the area around {@code origin} with up to {@code count} bee-hive nests.
+ * Only attaches to trees (highest block is a log). Idempotent in the sense that
+ * repeated calls may skip positions that already have a hive recorded.
+ */
+ public static void decorateNearbyTrees(Run run, Location origin, int radius, int count) {
+ RunStore store = run.getStore();
+ World world = origin.getWorld();
+ int placed = 0;
+ for (int tries = 0; tries < 60 && placed < count; tries++) {
+ int dx = RANDOM.nextInt(radius * 2) - radius;
+ int dz = RANDOM.nextInt(radius * 2) - radius;
+ int x = origin.getBlockX() + dx;
+ int z = origin.getBlockZ() + dz;
+ int groundY = world.getHighestBlockYAt(x, z);
+ Block ground = world.getBlockAt(x, groundY, z);
+ if (!ground.getType().name().endsWith("_LOG")) continue;
+
+ /* Hive goes one block above the tree top; campfire directly below the hive. */
+ Block hiveBlock = world.getBlockAt(x, groundY + 1, z);
+ Block campfireBlock = world.getBlockAt(x, groundY, z);
+
+ placeCampfire(campfireBlock);
+ placeHive(hiveBlock);
+ scatterFlowers(world, x, groundY, z, 3);
+ spawnBees(world, hiveBlock.getLocation(), 2 + RANDOM.nextInt(2));
+
+ store.setHoneyTree(hiveBlock.getX(), hiveBlock.getY(), hiveBlock.getZ());
+ placed++;
+ }
+ }
+
+ private static void placeHive(Block block) {
+ block.setType(Material.BEE_NEST, false);
+ }
+
+ private static void placeCampfire(Block block) {
+ block.setType(Material.CAMPFIRE, false);
+ if (block.getState() instanceof Campfire fire) {
+ if (fire.getBlockData() instanceof org.bukkit.block.data.type.Campfire cd) {
+ cd.setLit(true);
+ cd.setSignalFire(true);
+ fire.setBlockData(cd);
+ }
+ fire.update(true, false);
+ }
+ }
+
+ private static void scatterFlowers(World world, int cx, int groundY, int cz, int ringRadius) {
+ for (int dx = -ringRadius; dx <= ringRadius; dx++) {
+ for (int dz = -ringRadius; dz <= ringRadius; dz++) {
+ if (Math.abs(dx) + Math.abs(dz) < ringRadius - 1) continue;
+ if (RANDOM.nextInt(3) != 0) continue;
+ int fx = cx + dx, fz = cz + dz;
+ int fy = world.getHighestBlockYAt(fx, fz);
+ Block under = world.getBlockAt(fx, fy - 1, fz);
+ Block at = world.getBlockAt(fx, fy, fz);
+ if (!at.getType().isAir()) continue;
+ if (!under.getType().isSolid()) continue;
+ at.setType(FLOWERS[RANDOM.nextInt(FLOWERS.length)], false);
+ }
+ }
+ }
+
+ private static void spawnBees(World world, Location at, int count) {
+ for (int i = 0; i < count; i++) {
+ Location spawn = at.clone().add(RANDOM.nextInt(4) - 2, 1, RANDOM.nextInt(4) - 2);
+ Bee bee = (Bee) world.spawnEntity(spawn, EntityType.BEE);
+ bee.setRemoveWhenFarAway(false);
+ bee.setAnger(0);
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/Boss.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/Boss.java
new file mode 100644
index 000000000..ef4294abc
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/Boss.java
@@ -0,0 +1,42 @@
+package com.orbitmines.minecraft.spigot.servers.fog.boss;
+
+import com.orbitmines.minecraft.spigot.servers.fog.mob.FoGMob;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import lombok.Getter;
+import org.bukkit.boss.BarColor;
+import org.bukkit.boss.BarStyle;
+import org.bukkit.boss.BossBar;
+import org.bukkit.entity.Player;
+
+/** Extends FoGMob with a BossBar so the client sees progressive health. */
+public abstract class Boss extends FoGMob {
+
+ @Getter protected BossBar bossBar;
+
+ protected Boss(Run run) {
+ super(run);
+ }
+
+ protected void initBossBar(String title, BarColor color) {
+ this.bossBar = org.bukkit.Bukkit.createBossBar(title, color, BarStyle.SOLID);
+ this.bossBar.setProgress(1.0);
+ }
+
+ public void addViewer(Player player) {
+ if (bossBar != null) bossBar.addPlayer(player);
+ }
+
+ public void removeViewer(Player player) {
+ if (bossBar != null) bossBar.removePlayer(player);
+ }
+
+ public void updateBar() {
+ if (bossBar == null || !isAlive()) return;
+ bossBar.setProgress(Math.max(0.0, Math.min(1.0, entity.getHealth() / entity.getMaxHealth())));
+ }
+
+ public void destroy() {
+ if (bossBar != null) bossBar.removeAll();
+ super.remove();
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/UndeadKnight.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/UndeadKnight.java
new file mode 100644
index 000000000..853b12bae
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/boss/UndeadKnight.java
@@ -0,0 +1,100 @@
+package com.orbitmines.minecraft.spigot.servers.fog.boss;
+
+import com.orbitmines.minecraft.spigot.servers.fog.mob.DebuffMage;
+import com.orbitmines.minecraft.spigot.servers.fog.mob.FireMage;
+import com.orbitmines.minecraft.spigot.servers.fog.mob.FoGZombie;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import org.bukkit.Location;
+import org.bukkit.boss.BarColor;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.PiglinBrute;
+
+import java.util.Random;
+
+/**
+ * First boss. Base Piglin Brute stand-in.
+ * - Every 30s: summons a random undead mage (FireMage or DebuffMage)
+ * - Every 15s: summons 2/3/4/5 zombies depending on hp %
+ * - Every 10s: pulls players in front of him
+ * - Below 50%: every 15s charges a random player knocking them up
+ */
+public class UndeadKnight extends Boss {
+
+ private static final Random RANDOM = new Random();
+
+ private long lastSummon;
+ private long lastZombies;
+ private long lastPull;
+ private long lastCharge;
+
+ public UndeadKnight(Run run) {
+ super(run);
+ }
+
+ @Override public double baseHealth() { return 300.0; }
+ @Override public String translationKey() { return "boss.undead_knight.name"; }
+ @Override public String colorPrefix() { return "§4"; }
+
+ @Override
+ public void spawn(Location at) {
+ PiglinBrute brute = (PiglinBrute) at.getWorld().spawnEntity(at, EntityType.PIGLIN_BRUTE);
+ brute.setMaxHealth(baseHealth());
+ brute.setHealth(baseHealth());
+ brute.setCustomName(displayName());
+ brute.setCustomNameVisible(true);
+ this.entity = brute;
+ this.entityUuid = brute.getUniqueId();
+ initBossBar(displayName(), BarColor.RED);
+ for (Player p : at.getWorld().getPlayers()) addViewer(p);
+ }
+
+ @Override
+ public void tick() {
+ if (!isAlive()) return;
+ updateBar();
+ long now = entity.getWorld().getFullTime();
+ double ratio = entity.getHealth() / entity.getMaxHealth();
+
+ if (now - lastSummon > 600) {
+ if (RANDOM.nextBoolean())
+ new FireMage(run).spawn(entity.getLocation());
+ else
+ new DebuffMage(run).spawn(entity.getLocation());
+ lastSummon = now;
+ }
+
+ if (now - lastZombies > 300) {
+ int count = ratio > 0.75 ? 2 : ratio > 0.5 ? 3 : ratio > 0.25 ? 4 : 5;
+ for (int i = 0; i < count; i++) {
+ FoGZombie z = new FoGZombie(run);
+ z.spawn(entity.getLocation().add(RANDOM.nextInt(6) - 3, 0, RANDOM.nextInt(6) - 3));
+ }
+ lastZombies = now;
+ }
+
+ if (now - lastPull > 200) {
+ for (Player p : entity.getWorld().getPlayers()) {
+ if (p.getLocation().distance(entity.getLocation()) > 10) continue;
+ org.bukkit.util.Vector dir = entity.getLocation().toVector().subtract(p.getLocation().toVector()).normalize().multiply(0.8);
+ p.setVelocity(dir);
+ }
+ lastPull = now;
+ }
+
+ if (ratio < 0.5 && now - lastCharge > 300) {
+ Player best = null;
+ double bestDist = 16 * 16;
+ for (Player p : entity.getWorld().getPlayers()) {
+ double d = p.getLocation().distanceSquared(entity.getLocation());
+ if (d < bestDist) { bestDist = d; best = p; }
+ }
+ if (best != null) {
+ org.bukkit.util.Vector dir = best.getLocation().toVector().subtract(entity.getLocation().toVector()).normalize().multiply(2.0).setY(0.9);
+ entity.setVelocity(dir);
+ best.setVelocity(new org.bukkit.util.Vector(0, 1.4, 0));
+ }
+ lastCharge = now;
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Choice.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Choice.java
new file mode 100644
index 000000000..987f43e13
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Choice.java
@@ -0,0 +1,113 @@
+package com.orbitmines.minecraft.spigot.servers.fog.choice;
+
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import lombok.Getter;
+import org.bukkit.Material;
+
+/**
+ * Master enum of all level-up choices. Display name + description come from
+ * {@code fog/choice..{name,description}}.
+ */
+public enum Choice {
+
+ /* === FACTIONS === (unique, one-per-player once-per-run) */
+ FACTION_OMEGA (Rarity.LEGENDARY, true, Category.FACTION, Material.ENDER_EYE, 1, 1),
+ FACTION_ALPHA (Rarity.LEGENDARY, true, Category.FACTION, Material.BLAZE_POWDER, 1, 1),
+ FACTION_BETA (Rarity.LEGENDARY, true, Category.FACTION, Material.PRISMARINE_CRYSTALS, 1, 1),
+
+ /* === STATS === */
+ EXTRA_HP_4 (Rarity.COMMON, false, Category.STAT, Material.RED_DYE, 1, 10),
+ DAMAGE_5 (Rarity.COMMON, false, Category.STAT, Material.IRON_SWORD, 1, 10),
+ SPEED_5 (Rarity.COMMON, false, Category.STAT, Material.FEATHER, 1, 5),
+ MINING_SPEED_5 (Rarity.COMMON, false, Category.STAT, Material.IRON_PICKAXE, 1, 10),
+
+ /* === SKILLS === */
+ SKILL_LUMBERJACK (Rarity.RARE, false, Category.SKILL, Material.IRON_AXE, 1, 1),
+ SKILL_FISHING (Rarity.RARE, false, Category.SKILL, Material.FISHING_ROD, 1, 1),
+ SKILL_BOTANY (Rarity.RARE, false, Category.SKILL, Material.OAK_SAPLING, 1, 5),
+ SKILL_BEEKEEPING (Rarity.RARE, false, Category.SKILL, Material.HONEY_BOTTLE, 1, 1),
+
+ /* === ORES === */
+ ORE_COPPER (Rarity.COMMON, false, Category.ORE, Material.COPPER_INGOT, 1, 1),
+ ORE_COBALT (Rarity.RARE, false, Category.ORE, Material.LIGHT_BLUE_DYE, 3, 1),
+ ORE_STRONTIUM (Rarity.RARE, false, Category.ORE, Material.GLOWSTONE_DUST, 5, 1),
+ ORE_AMETHYST (Rarity.RARE, false, Category.ORE, Material.AMETHYST_SHARD, 5, 1),
+ ORE_URANIUM (Rarity.EPIC, false, Category.ORE, Material.LIME_DYE, 8, 1),
+ ORE_IRIDIUM (Rarity.EPIC, false, Category.ORE, Material.LIGHT_GRAY_DYE, 10, 1),
+ ORE_FRANCIUM (Rarity.LEGENDARY, false, Category.ORE, Material.MAGENTA_DYE, 15, 1),
+
+ /* === TREES === */
+ TREE_TAR (Rarity.COMMON, false, Category.TREE, Material.COAL, 1, 1),
+ TREE_GLOWWOOD (Rarity.RARE, false, Category.TREE, Material.GLOW_INK_SAC, 3, 1),
+ TREE_VOIDOAK (Rarity.EPIC, false, Category.TREE, Material.SCULK, 8, 1),
+
+ /* === DRONES === */
+ DRONE_UNLOCK (Rarity.EPIC, false, Category.DRONE, Material.ALLAY_SPAWN_EGG, 10, 9),
+ DRONE_FACTORY (Rarity.LEGENDARY, true, Category.STRUCTURE,Material.SMITHING_TABLE, 10, 1),
+
+ /* === STRUCTURES === */
+ STRUCTURE_FARM (Rarity.EPIC, true, Category.STRUCTURE,Material.WHEAT, 1, 1),
+ STRUCTURE_ENCHANT (Rarity.EPIC, true, Category.STRUCTURE,Material.ENCHANTING_TABLE, 1, 1),
+ STRUCTURE_CRATES (Rarity.LEGENDARY, true, Category.STRUCTURE,Material.CHEST, 1, 1),
+
+ /* === MAP EXPANSION === */
+ MAP_EXPANSION (Rarity.LEGENDARY, false, Category.MAP, Material.MAP, 1, 20),
+
+ /* === ENCHANTS === */
+ ENCHANT_AUTO_REPLANT (Rarity.RARE, false, Category.ENCHANT, Material.ENCHANTED_BOOK, 1, 1),
+ ENCHANT_HOMING_ARROW (Rarity.EPIC, false, Category.ENCHANT, Material.ENCHANTED_BOOK, 3, 1),
+ ENCHANT_RAIN_ARROWS (Rarity.LEGENDARY, false, Category.ENCHANT, Material.ENCHANTED_BOOK, 5, 1),
+ ENCHANT_ABSORPTION (Rarity.RARE, false, Category.ENCHANT, Material.ENCHANTED_BOOK, 3, 1),
+ ENCHANT_CHOP_TREE (Rarity.EPIC, false, Category.ENCHANT, Material.ENCHANTED_BOOK, 3, 1),
+
+ /* === RECIPES === */
+ RECIPE_ORE_SUIT (Rarity.RARE, false, Category.RECIPE, Material.IRON_CHESTPLATE, 2, 1),
+ RECIPE_PICKAXE_UP (Rarity.RARE, false, Category.RECIPE, Material.DIAMOND_PICKAXE, 2, 1),
+ RECIPE_BACKPACK (Rarity.RARE, false, Category.RECIPE, Material.BUNDLE, 2, 1),
+
+ /* === ABILITIES === */
+ ABILITY_SHIELD (Rarity.EPIC, false, Category.ABILITY, Material.SHIELD, 5, 1),
+ ABILITY_SWEEP (Rarity.RARE, false, Category.ABILITY, Material.IRON_AXE, 3, 1),
+ ABILITY_LEAP (Rarity.RARE, false, Category.ABILITY, Material.RABBIT_FOOT, 3, 1),
+
+ /* === QUESTS === */
+ QUEST_COLLECT (Rarity.COMMON, false, Category.QUEST, Material.WRITABLE_BOOK, 1, 1),
+ QUEST_DEFEAT (Rarity.COMMON, false, Category.QUEST, Material.WRITABLE_BOOK, 1, 1);
+
+ public enum Category {
+ FACTION, STAT, SKILL, ORE, TREE, DRONE, STRUCTURE, MAP, ENCHANT, RECIPE, ABILITY, QUEST
+ }
+
+ @Getter private final Rarity rarity;
+ @Getter private final boolean unique;
+ @Getter private final Category category;
+ @Getter private final Material icon;
+ @Getter private final int minLevel;
+ @Getter private final int maxStacks;
+
+ Choice(Rarity rarity, boolean unique, Category category, Material icon, int minLevel, int maxStacks) {
+ this.rarity = rarity;
+ this.unique = unique;
+ this.category = category;
+ this.icon = icon;
+ this.minLevel = minLevel;
+ this.maxStacks = maxStacks;
+ }
+
+ public String nameKey() { return "choice." + name().toLowerCase() + ".name"; }
+ public String descriptionKey() { return "choice." + name().toLowerCase() + ".description"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+ public String[] getDescriptionLines(Languageable viewer) {
+ return viewer.getLanguage().getStringArray("fog", descriptionKey());
+ }
+
+ public String getStorageKey() {
+ return name().toLowerCase();
+ }
+
+ public static Choice parse(String s) {
+ if (s == null) return null;
+ try { return Choice.valueOf(s.toUpperCase()); } catch (IllegalArgumentException e) { return null; }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoicePicker.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoicePicker.java
new file mode 100644
index 000000000..43dc75941
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoicePicker.java
@@ -0,0 +1,111 @@
+package com.orbitmines.minecraft.spigot.servers.fog.choice;
+
+import com.orbitmines.minecraft.spigot.servers.fog.faction.Faction;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.run.RunStore;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.Stats;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Picks three {@link Choice}s for a level-up prompt, applying rarity weights and
+ * exclusion rules. Sync-safe: reads only from the in-memory caches on
+ * {@link Stats} and {@link Run} — never touches the store directly.
+ *
+ *
{@link #recordChoice(Run, UUID, int, Choice)} writes to the store
+ * (async-only) and returns without updating in-memory caches. Callers
+ * should have already bumped {@link Stats#onChoiceActivated(Choice)} and
+ * {@link Run#markUniqueTaken(Choice)} on the sync thread before scheduling the
+ * record — see {@link #applyInMemory(Run, Stats, Choice)}.
+ */
+public class ChoicePicker {
+
+ private static final Random RANDOM = new Random();
+
+ public static List pickThree(Run run, Stats stats, int level) {
+ List candidates = new ArrayList<>();
+
+ Set excluded = new HashSet<>();
+ for (Choice c : Choice.values()) {
+ if (c.isUnique() && run.isUniqueTaken(c)) excluded.add(c);
+ if (c.getMaxStacks() > 0 && stats.getChoiceStackCount(c) >= c.getMaxStacks()) excluded.add(c);
+ }
+ Faction current = stats.getFaction();
+ if (current != null) {
+ if (current != Faction.OMEGA) excluded.add(Choice.FACTION_OMEGA);
+ if (current != Faction.ALPHA) excluded.add(Choice.FACTION_ALPHA);
+ if (current != Faction.BETA) excluded.add(Choice.FACTION_BETA);
+ }
+
+ for (Choice c : Choice.values()) {
+ if (excluded.contains(c)) continue;
+ if (level < c.getMinLevel()) continue;
+ candidates.add(c);
+ }
+
+ /* A previously-DAMAGED choice on this level is guaranteed to reappear. */
+ Choice previouslyMade = stats.getDamagedChoiceAt(level);
+ List chosen = new ArrayList<>();
+ if (previouslyMade != null && candidates.contains(previouslyMade)) {
+ chosen.add(previouslyMade);
+ candidates.remove(previouslyMade);
+ }
+
+ /* Guaranteed Drone Factory at lvl 10 if not yet unlocked. */
+ if (level == 10 && !run.isUniqueTaken(Choice.DRONE_FACTORY) && candidates.contains(Choice.DRONE_FACTORY)) {
+ chosen.add(Choice.DRONE_FACTORY);
+ candidates.remove(Choice.DRONE_FACTORY);
+ }
+
+ while (chosen.size() < 3 && !candidates.isEmpty()) {
+ Choice roll = weightedPick(candidates);
+ chosen.add(roll);
+ candidates.remove(roll);
+ }
+
+ while (chosen.size() < 3) {
+ chosen.add(Choice.EXTRA_HP_4);
+ }
+
+ return Collections.unmodifiableList(chosen);
+ }
+
+ private static Choice weightedPick(List pool) {
+ int totalWeight = 0;
+ for (Choice c : pool) totalWeight += c.getRarity().getWeight();
+ int r = RANDOM.nextInt(totalWeight);
+ int cum = 0;
+ for (Choice c : pool) {
+ cum += c.getRarity().getWeight();
+ if (r < cum) return c;
+ }
+ return pool.get(0);
+ }
+
+ /**
+ * Sync-safe. Mutates the in-memory caches to reflect a just-applied choice.
+ * Pair this with {@link #recordChoice(Run, UUID, int, Choice)} on async to
+ * keep the store in sync.
+ */
+ public static void applyInMemory(Run run, Stats stats, Choice choice) {
+ if (stats != null) stats.onChoiceActivated(choice);
+ if (run != null) run.markUniqueTaken(choice);
+ }
+
+ /** Async only. Persists the choice to the store. Does not touch caches. */
+ public static void recordChoice(Run run, UUID uuid, int level, Choice choice) {
+ RunStore store = run.getStore();
+ store.setChoiceState(uuid, level, choice, ChoiceState.ACTIVE);
+ store.incrementChoiceStackCount(uuid, choice);
+ if (choice.isUnique()) store.setUniqueHolder(choice, uuid);
+ if (choice == Choice.FACTION_OMEGA) store.setMemberFaction(uuid, Faction.OMEGA);
+ if (choice == Choice.FACTION_ALPHA) store.setMemberFaction(uuid, Faction.ALPHA);
+ if (choice == Choice.FACTION_BETA) store.setMemberFaction(uuid, Faction.BETA);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoiceState.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoiceState.java
new file mode 100644
index 000000000..c7b8931df
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/ChoiceState.java
@@ -0,0 +1,14 @@
+package com.orbitmines.minecraft.spigot.servers.fog.choice;
+
+public enum ChoiceState {
+ /** Choice is active — effects apply, items/enchants normal. */
+ ACTIVE,
+ /** Choice was tied to a level that was lost on death. Item/enchant/unlock
+ is suppressed and visually marked (strikethrough + red "DAMAGED"). */
+ DAMAGED;
+
+ public static ChoiceState parse(String s) {
+ if (s == null) return ACTIVE;
+ try { return ChoiceState.valueOf(s.toUpperCase()); } catch (IllegalArgumentException e) { return ACTIVE; }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Rarity.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Rarity.java
new file mode 100644
index 000000000..ce3aea0bc
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/choice/Rarity.java
@@ -0,0 +1,34 @@
+package com.orbitmines.minecraft.spigot.servers.fog.choice;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import lombok.Getter;
+
+/** Choice rarity; weights sum to 100. Display name comes from `fog/rarity..name`. */
+public enum Rarity {
+
+ COMMON(Color.WHITE, 50),
+ RARE(Color.AQUA, 30),
+ EPIC(Color.PURPLE, 15),
+ LEGENDARY(Color.YELLOW, 5);
+
+ @Getter private final Color color;
+ @Getter private final int weight;
+
+ Rarity(Color color, int weight) {
+ this.color = color;
+ this.weight = weight;
+ }
+
+ public String getTranslationKey() {
+ return "rarity." + name().toLowerCase() + ".name";
+ }
+
+ public String getDisplayName(Languageable viewer) {
+ return viewer.translate("fog", getTranslationKey());
+ }
+
+ public String getColoredName(Languageable viewer) {
+ return color.getCc() + "§l" + getDisplayName(viewer);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/ChoiceArgument.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/ChoiceArgument.java
new file mode 100644
index 000000000..8a4a43f7c
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/ChoiceArgument.java
@@ -0,0 +1,79 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Argument;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Choice;
+import lombok.Getter;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+/** Brigadier argument that autocompletes all {@link Choice} enum names. */
+public class ChoiceArgument extends Argument {
+
+ @Getter private final String name = "choice";
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.choice.argument.description");
+ }
+
+ @Override
+ public Choice getValue(FoGPlayer player, String string) {
+ Choice value = Choice.parse(string);
+ if (value == null) {
+ player.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED,
+ "fog", "player.command.choice.invalid", string);
+ }
+ return value;
+ }
+
+ @Override
+ public String invalidReason(FoGPlayer player, String string, Choice value) {
+ return player.translate("fog", "player.command.choice.invalid", string);
+ }
+
+ @Override
+ public String getValidTooltip(FoGPlayer player, Choice value) {
+ return value.getRarity().getColor().getCc() + value.getDisplayName(player);
+ }
+
+ @Override
+ public Set getExamples(FoGPlayer player, int limit) {
+ Set out = new LinkedHashSet<>();
+ int n = 0;
+ for (Choice c : Choice.values()) {
+ out.add(c.name());
+ if (++n >= limit) break;
+ }
+ return out;
+ }
+
+ @Override
+ public CompletableFuture getSuggestions(FoGPlayer player, CommandContext context, SuggestionsBuilder builder) throws CommandSyntaxException {
+ String remaining = builder.getRemaining().toUpperCase();
+ for (Choice c : Choice.values()) {
+ if (!c.name().startsWith(remaining)) continue;
+ String display = c.getRarity().getColor().getCc() + c.getDisplayName(player);
+ builder.suggest(c.name(), () -> display);
+ }
+ return builder.buildFuture();
+ }
+
+ @Override
+ public StringArgumentType.StringType getType() {
+ return StringArgumentType.StringType.SINGLE_WORD;
+ }
+
+ @Override
+ protected ChoiceArgument getInstance() {
+ return this;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandChoice.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandChoice.java
new file mode 100644
index 000000000..edf965388
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandChoice.java
@@ -0,0 +1,69 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft._2019.libs.rank.StaffRank;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor1;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Choice;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.ChoicePicker;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+
+/**
+ * DEV+ command for triggering level-up prompts / directly applying a specific choice.
+ *
+ * /choice → open the normal 3-option level-up prompt at current level + 1
+ * /choice DRONE_UNLOCK → apply that specific choice at current level + 1, skipping the prompt
+ *
+ *
Brigadier command executors run on the async DB thread, so any Bukkit-side
+ * work (entity spawns, attribute writes, chat to the player) must be wrapped in
+ * {@code server.runSync}.
+ */
+public class CommandChoice extends Command {
+
+ public CommandChoice(FoG server) {
+ super(server, Server.FOG, "choice");
+
+ requires(StaffRank.DEVELOPER);
+
+ executes((Executor0) player -> {
+ if (!player.isEligible(StaffRank.DEVELOPER)) return;
+ Run run = player.getActiveRun();
+ if (run == null) {
+ player.sendMessage("FoG", Color.RED, "fog", "player.command.choice.no_run");
+ return;
+ }
+ /* LevelUpFlow.start spawns armor stands + floating items — must be sync. */
+ server.runSync(() -> server.getLevelUpFlow().start(player, player.getLevel() + 1, run));
+ });
+
+ withArg(new ChoiceArgument().executes(
+ (Executor1) (player, choice) -> {
+ if (!player.isEligible(StaffRank.DEVELOPER)) return;
+ Run run = player.getActiveRun();
+ if (run == null) {
+ player.sendMessage("FoG", Color.RED, "fog", "player.command.choice.no_run");
+ return;
+ }
+ int level = player.getLevel() + 1;
+ server.runSync(() -> {
+ ChoicePicker.applyInMemory(run, player.getStats(), choice);
+ player.applyDerivedStats();
+ player.sendMessage("FoG", Color.LIME, "fog", "player.command.choice.applied",
+ choice.getDisplayName(player), level);
+ });
+ /* This executor is already on an async thread — persist directly. */
+ ChoicePicker.recordChoice(run, player.getUUID(), level, choice);
+ run.getStore().setMemberLevel(player.getUUID(), level);
+ }
+ ));
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.choice.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCodex.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCodex.java
new file mode 100644
index 000000000..e400979d2
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCodex.java
@@ -0,0 +1,24 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.RecipeBookGUI;
+
+public class CommandCodex extends Command {
+
+ public CommandCodex(FoG server) {
+ super(server, Server.FOG, "codex", "recipes");
+
+ executes((Executor0) player -> {
+ new RecipeBookGUI(server, player).open();
+ });
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.codex.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCoopAccept.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCoopAccept.java
new file mode 100644
index 000000000..67b40b9a9
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandCoopAccept.java
@@ -0,0 +1,46 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft._2019.libs.jedis.OnlinePlayer;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.arguments.GlobalPlayerArgument;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor1;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+
+import java.util.Map;
+
+/** {@code /coopaccept } — accept a pending coop-join request. */
+public class CommandCoopAccept extends Command {
+
+ public CommandCoopAccept(FoG server) {
+ super(server, Server.FOG, "coopaccept");
+
+ withArg(new GlobalPlayerArgument(false).executes(
+ (Executor1>) (host, requester) -> {
+ Map reqs = host.getCoopRequestsFrom();
+ Long runId = reqs.remove(requester.getRawName());
+ if (runId == null) {
+ host.sendMessage("Coop", Color.RED, "fog", "coop.request.none", requester.getRawName());
+ return;
+ }
+ host.sendMessage("Coop", Color.LIME, "fog", "coop.request.accepted", requester.getRawName());
+
+ /* Look up the requester as a FoGPlayer by UUID and join them into the run. */
+ FoGPlayer requesterPlayer = server.getPlayer(org.bukkit.Bukkit.getPlayer(requester.getUUID()));
+ if (requesterPlayer == null) {
+ host.sendMessage("Coop", Color.RED, "fog", "coop.request.requester_offline", requester.getRawName());
+ return;
+ }
+ requesterPlayer.sendMessage("Coop", Color.LIME, "fog", "coop.request.you_were_accepted", host.getRawName());
+ server.getRunManager().joinRun(requesterPlayer, runId);
+ }
+ ));
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.coopaccept.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandFactory.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandFactory.java
new file mode 100644
index 000000000..7b8b7d3ab
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandFactory.java
@@ -0,0 +1,24 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.DroneFactoryGUI;
+
+public class CommandFactory extends Command {
+
+ public CommandFactory(FoG server) {
+ super(server, Server.FOG, "factory", "drone");
+
+ executes((Executor0) player -> {
+ new DroneFactoryGUI(server, player).open();
+ });
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.factory.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandRun.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandRun.java
new file mode 100644
index 000000000..c5b2319c4
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandRun.java
@@ -0,0 +1,22 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.RunSelector;
+
+public class CommandRun extends Command {
+
+ public CommandRun(FoG server) {
+ super(server, Server.FOG, "run");
+
+ executes((Executor0) player -> RunSelector.open(server, player));
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.run.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandShop.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandShop.java
new file mode 100644
index 000000000..0e4b0bee3
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandShop.java
@@ -0,0 +1,24 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.ShopGUI;
+
+public class CommandShop extends Command {
+
+ public CommandShop(FoG server) {
+ super(server, Server.FOG, "fogshop");
+
+ executes((Executor0) player -> {
+ new ShopGUI(server, player).open();
+ });
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.shop.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandWorld.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandWorld.java
new file mode 100644
index 000000000..eb23a9b9d
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/commands/CommandWorld.java
@@ -0,0 +1,44 @@
+package com.orbitmines.minecraft.spigot.servers.fog.commands;
+
+import com.orbitmines.archive.minecraft._2019.libs.Server;
+import com.orbitmines.archive.minecraft._2019.libs.jedis.OnlinePlayer;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.arguments.GlobalPlayerArgument;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.Command;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor0;
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.commands.brigadier.executors.Executor1;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.SpectatorJoinGUI;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+/** Spectate another player's active run: `/world [PLAYER]`. */
+public class CommandWorld extends Command {
+
+ public CommandWorld(FoG server) {
+ super(server, Server.FOG, "world");
+
+ executes((Executor0) viewer -> SpectatorJoinGUI.open(server, viewer));
+
+ withArg(new GlobalPlayerArgument(true).executes(
+ (Executor1>) (viewer, target) -> {
+ Player bukkit = Bukkit.getPlayer(target.getUUID());
+ if (bukkit == null) {
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED, "fog", "command.world.not_online", target.getRawName());
+ return;
+ }
+ FoGPlayer targetPlayer = server.getPlayer(bukkit);
+ if (targetPlayer == null || targetPlayer.getActiveRun() == null) {
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED, "fog", "command.world.not_in_run", target.getRawName());
+ return;
+ }
+ server.getRunManager().spectateRun(viewer, targetPlayer.getActiveRun().getId());
+ })
+ );
+ }
+
+ @Override
+ public String getDescription(FoGPlayer player) {
+ return player.translate("fog", "player.command.world.description");
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/Crate.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/Crate.java
new file mode 100644
index 000000000..034a7462d
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/Crate.java
@@ -0,0 +1,35 @@
+package com.orbitmines.minecraft.spigot.servers.fog.crate;
+
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Rarity;
+
+import java.util.Map;
+import java.util.Random;
+
+/** Opens a crate and produces a "Level-up trigger" of a particular rarity. */
+public class Crate {
+
+ private static final Random RANDOM = new Random();
+
+ /** Returns a rarity to use when pushing tracked XP / level-up choice. */
+ public static Rarity roll(CrateType crate) {
+ int total = 0;
+ for (int w : crate.getRarityWeights().values()) total += w;
+ int r = RANDOM.nextInt(total);
+ int cum = 0;
+ for (Map.Entry e : crate.getRarityWeights().entrySet()) {
+ cum += e.getValue();
+ if (r < cum) return e.getKey();
+ }
+ return Rarity.COMMON;
+ }
+
+ /** Amount of tracked XP a rarity grants when converted from a crate. */
+ public static int xpReward(Rarity rarity) {
+ return switch (rarity) {
+ case COMMON -> 50;
+ case RARE -> 150;
+ case EPIC -> 400;
+ case LEGENDARY -> 1000;
+ };
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/CrateType.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/CrateType.java
new file mode 100644
index 000000000..a16ff9ace
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/crate/CrateType.java
@@ -0,0 +1,36 @@
+package com.orbitmines.minecraft.spigot.servers.fog.crate;
+
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Rarity;
+import lombok.Getter;
+import org.bukkit.Material;
+
+import java.util.Map;
+
+public enum CrateType {
+
+ BASIC(Material.CHEST, Material.TRIPWIRE_HOOK, Map.of(
+ Rarity.COMMON, 70,
+ Rarity.RARE, 25,
+ Rarity.EPIC, 4,
+ Rarity.LEGENDARY, 1)),
+ EPIC(Material.TRAPPED_CHEST, Material.LAPIS_LAZULI, Map.of(
+ Rarity.COMMON, 10,
+ Rarity.RARE, 50,
+ Rarity.EPIC, 30,
+ Rarity.LEGENDARY, 10));
+
+ @Getter private final Material icon;
+ @Getter private final Material keyMaterial;
+ @Getter private final Map rarityWeights;
+
+ CrateType(Material icon, Material keyMaterial, Map rarityWeights) {
+ this.icon = icon;
+ this.keyMaterial = keyMaterial;
+ this.rarityWeights = Map.copyOf(rarityWeights);
+ }
+
+ public String nameKey() { return "crate." + name().toLowerCase() + ".name"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGPlayerModel.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGPlayerModel.java
new file mode 100644
index 000000000..2d7025d15
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGPlayerModel.java
@@ -0,0 +1,85 @@
+package com.orbitmines.minecraft.spigot.servers.fog.database;
+
+import com.orbitmines.archive.minecraft._2019.libs.database.mysql.OMMySQLModel;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.Column;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.ColumnKey;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumn;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumnInstance;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumnKey;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLTable;
+import com.orbitmines.archive.minecraft._2019.utils.database.model.ModelSelector;
+import com.orbitmines.archive.minecraft._2019.utils.database.model.mysql.MySQLModelColumn;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+public class FoGPlayerModel extends OMMySQLModel {
+
+ public static MySQLTable TABLE = new MySQLTable("fog_players", MySQLModelColumn.toColumns(column.values()));
+
+ @Getter private UUID uuid;
+ @Getter @Setter private Long activeRunId;
+
+ public FoGPlayerModel() {
+ }
+
+ public FoGPlayerModel(UUID uuid) {
+ this.uuid = uuid;
+ this.activeRunId = null;
+ }
+
+ @Override
+ protected void load() {
+ this.uuid = getUUID(column.UUID);
+ this.activeRunId = getLong(column.ACTIVE_RUN_ID);
+ }
+
+ @Override
+ protected MySQLTable getTable() {
+ return TABLE;
+ }
+
+ @Override
+ protected ModelSelector[] getUniqueIdentifier() {
+ return localIdentifiers(column.UUID);
+ }
+
+ @Override
+ protected column getSortedIdentifier() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ protected String stringifyValue(column column) {
+ switch (column) {
+ case UUID:
+ return stringify(this.uuid);
+ case ACTIVE_RUN_ID:
+ return stringify(this.activeRunId);
+ default:
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ @Override
+ protected column[] getColumns() {
+ return column.values();
+ }
+
+ @AllArgsConstructor
+ public enum column implements MySQLModelColumn {
+ UUID(new MySQLColumnKey("uuid", Column.Type.VARCHAR, ColumnKey.Key.PRIMARY, 36)),
+ ACTIVE_RUN_ID(new MySQLColumn("active_run_id", Column.Type.BIGINT));
+
+ @Getter private final MySQLColumnInstance column;
+ }
+
+ public static FoGPlayerModel findOrInitializeBy(UUID uuid) {
+ FoGPlayerModel model = findBy(FoGPlayerModel.class, column.UUID.is(uuid));
+ if (model != null)
+ return model;
+ return new FoGPlayerModel(uuid);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGRunModel.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGRunModel.java
new file mode 100644
index 000000000..de7a478b0
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/database/FoGRunModel.java
@@ -0,0 +1,103 @@
+package com.orbitmines.minecraft.spigot.servers.fog.database;
+
+import com.orbitmines.archive.minecraft._2019.libs.database.mysql.OMMySQLModel;
+import com.orbitmines.archive.minecraft._2019.utils.DateUtils;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.Column;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.ColumnKey;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumn;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumnInstance;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLColumnKey;
+import com.orbitmines.archive.minecraft._2019.utils.database.lib.froms.MySQLTable;
+import com.orbitmines.archive.minecraft._2019.utils.database.model.ModelSelector;
+import com.orbitmines.archive.minecraft._2019.utils.database.model.mysql.MySQLModelColumn;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+import java.util.UUID;
+
+/** Spawn coordinates are persisted on the world itself via {@code World#setSpawnLocation}; not in the DB. */
+public class FoGRunModel extends OMMySQLModel {
+
+ public static MySQLTable TABLE = new MySQLTable("fog_runs", MySQLModelColumn.toColumns(column.values()));
+
+ @Getter private Long id;
+ @Getter @Setter private UUID ownerUuid;
+ @Getter @Setter private String difficulty;
+ @Getter @Setter private String worldFileName;
+ @Getter @Setter private String state;
+ @Getter private Date createdAt;
+
+ public FoGRunModel() {
+ }
+
+ public FoGRunModel(UUID ownerUuid, String difficulty, String worldFileName, String state) {
+ this.ownerUuid = ownerUuid;
+ this.difficulty = difficulty;
+ this.worldFileName = worldFileName;
+ this.state = state;
+ this.createdAt = DateUtils.now();
+ }
+
+ @Override
+ protected void load() {
+ this.id = getLong(column.ID);
+ this.ownerUuid = getUUID(column.OWNER_UUID);
+ this.difficulty = getString(column.DIFFICULTY);
+ this.worldFileName = getString(column.WORLD_FILE_NAME);
+ this.state = getString(column.STATE);
+ this.createdAt = getDate(column.CREATED_AT, DateUtils.DATE_TIME_FORMAT);
+ }
+
+ @Override
+ public void insert() {
+ setupReloadAfterInsert(localIdentifiers(column.OWNER_UUID, column.WORLD_FILE_NAME));
+ super.insert();
+ }
+
+ @Override
+ protected MySQLTable getTable() {
+ return TABLE;
+ }
+
+ @Override
+ protected ModelSelector[] getUniqueIdentifier() {
+ return localIdentifiers(column.ID);
+ }
+
+ @Override
+ protected column getSortedIdentifier() {
+ return column.CREATED_AT;
+ }
+
+ @Override
+ protected String stringifyValue(column column) {
+ switch (column) {
+ case ID: return stringify(this.id);
+ case OWNER_UUID: return stringify(this.ownerUuid);
+ case DIFFICULTY: return this.difficulty;
+ case WORLD_FILE_NAME: return this.worldFileName;
+ case STATE: return this.state;
+ case CREATED_AT: return stringify(this.createdAt, DateUtils.DATE_TIME_FORMAT);
+ default: throw new UnsupportedOperationException();
+ }
+ }
+
+ @Override
+ protected column[] getColumns() {
+ return column.values();
+ }
+
+ @AllArgsConstructor
+ public enum column implements MySQLModelColumn {
+ ID(new MySQLColumnKey("id", Column.Type.BIGINT, ColumnKey.Key.PRIMARY).autoIncrement()),
+ OWNER_UUID(new MySQLColumn("owner_uuid", Column.Type.VARCHAR, 36).indexed()),
+ DIFFICULTY(new MySQLColumn("difficulty", Column.Type.VARCHAR).notNull()),
+ WORLD_FILE_NAME(new MySQLColumn("world_file_name", Column.Type.VARCHAR).notNull()),
+ STATE(new MySQLColumn("state", Column.Type.VARCHAR).notNull()),
+ CREATED_AT(new MySQLColumn("created_at", Column.Type.DATETIME).indexed());
+
+ @Getter private final MySQLColumnInstance column;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/Drone.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/Drone.java
new file mode 100644
index 000000000..80d272ca8
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/Drone.java
@@ -0,0 +1,83 @@
+package com.orbitmines.minecraft.spigot.servers.fog.drone;
+
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.Stats;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.StatsHolder;
+import com.orbitmines.minecraft.spigot.servers.fog.util.Fogi18n;
+import lombok.Getter;
+import lombok.Setter;
+import org.bukkit.Location;
+import org.bukkit.entity.Allay;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.LivingEntity;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * A single drone belonging to a player. Modelled on an Allay; up to 9 per player.
+ */
+public class Drone implements StatsHolder {
+
+ public enum Mode { IDLE, ACTIVE }
+
+ @Getter private final int id;
+ @Getter private final UUID ownerUuid;
+ @Getter @Setter private Mode mode = Mode.IDLE;
+ @Getter private final List modules = new ArrayList<>();
+
+ @Getter @Setter private Allay entity;
+ @Getter @Setter private double health = 20.0;
+ @Getter @Setter private double maxHealth = 20.0;
+
+ private final Stats stats;
+
+ public Drone(int id, UUID ownerUuid, Run run) {
+ this.id = id;
+ this.ownerUuid = ownerUuid;
+ this.stats = new Stats(run.getStore(), "member:" + ownerUuid + ":drone:" + id);
+ }
+
+ public boolean addModule(DroneModule module) {
+ if (modules.size() >= 4) return false; /* POC cap */
+ modules.add(module);
+ return true;
+ }
+
+ public boolean removeModule(ModuleType type) {
+ return modules.removeIf(m -> m.getType() == type);
+ }
+
+ public boolean hasModule(ModuleType type) {
+ return modules.stream().anyMatch(m -> m.getType() == type);
+ }
+
+ @Override
+ public UUID getOwnerUuid() { return ownerUuid; }
+
+ @Override
+ public String getStatsPrefix() { return "member:" + ownerUuid + ":drone:" + id; }
+
+ @Override
+ public Stats getStats() { return stats; }
+
+ /** Spawn the Allay entity at the given location (main thread). */
+ public void spawn(Location at) {
+ if (entity != null && entity.isValid()) return;
+ LivingEntity e = (LivingEntity) at.getWorld().spawnEntity(at, EntityType.ALLAY);
+ if (e instanceof Allay) {
+ this.entity = (Allay) e;
+ this.entity.setCustomName("§b§l" + Fogi18n.defaultText("drone.entity_name", id));
+ this.entity.setCustomNameVisible(true);
+ this.entity.setRemoveWhenFarAway(false);
+ }
+ }
+
+ public void despawn() {
+ if (entity != null && entity.isValid()) {
+ entity.remove();
+ }
+ entity = null;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneFactory.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneFactory.java
new file mode 100644
index 000000000..5bb79f217
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneFactory.java
@@ -0,0 +1,40 @@
+package com.orbitmines.minecraft.spigot.servers.fog.drone;
+
+import com.orbitmines.minecraft.spigot.servers.fog.ore.Ore;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+
+/** Ore-based repair & module-removal economics for drones. */
+public class DroneFactory {
+
+ /** Cost to fully repair a drone from 0 HP, in units per listed ore. */
+ public static int repairCost(Drone drone) {
+ int total = 0;
+ for (DroneModule module : drone.getModules()) {
+ for (Ore o : module.getType().getRepairOres()) {
+ total += module.getType().getRepairCostPerOre() * Math.max(1, module.getTier());
+ }
+ }
+ return total;
+ }
+
+ /**
+ * Cost to remove a module. Charged in the same ores that would be needed
+ * to repair — keeps the recipe relationship consistent.
+ */
+ public static int removeCost(DroneModule module) {
+ int total = 0;
+ for (Ore o : module.getType().getRepairOres()) {
+ total += module.getType().getRepairCostPerOre() * 2 * Math.max(1, module.getTier());
+ }
+ return total;
+ }
+
+ public static void repair(Drone drone) {
+ drone.setHealth(drone.getMaxHealth());
+ }
+
+ public static boolean canCraftDrone(Run run) {
+ /* POC: infinite. Real build: consume resources. */
+ return true;
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneModule.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneModule.java
new file mode 100644
index 000000000..fdf7fc629
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/DroneModule.java
@@ -0,0 +1,20 @@
+package com.orbitmines.minecraft.spigot.servers.fog.drone;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/** One equipped module on a drone. Not a Bukkit item — just a record. */
+public class DroneModule {
+
+ @Getter private final ModuleType type;
+ @Getter @Setter private int tier;
+
+ public DroneModule(ModuleType type, int tier) {
+ this.type = type;
+ this.tier = tier;
+ }
+
+ public DroneModule(ModuleType type) {
+ this(type, 1);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/ModuleType.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/ModuleType.java
new file mode 100644
index 000000000..84bb9a68c
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/drone/ModuleType.java
@@ -0,0 +1,41 @@
+package com.orbitmines.minecraft.spigot.servers.fog.drone;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import com.orbitmines.minecraft.spigot.servers.fog.ore.Ore;
+import lombok.Getter;
+import org.bukkit.Material;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public enum ModuleType {
+
+ COLLECT (Color.YELLOW, Material.HOPPER, Arrays.asList(Ore.IRON, Ore.COPPER), 2),
+ SKILL (Color.GREEN, Material.IRON_AXE, Arrays.asList(Ore.IRON, Ore.COBALT), 3),
+ ENCHANTMENT(Color.AQUA, Material.ENCHANTED_BOOK, Arrays.asList(Ore.AMETHYST, Ore.COBALT), 3),
+ COMBAT (Color.RED, Material.IRON_SWORD, Arrays.asList(Ore.IRON, Ore.URANIUM), 4),
+ SHIELD (Color.BLUE, Material.SHIELD, Arrays.asList(Ore.IRIDIUM), 3),
+ INSPIRE (Color.PURPLE, Material.BELL, Arrays.asList(Ore.AMETHYST, Ore.FRANCIUM), 5);
+
+ @Getter private final Color color;
+ @Getter private final Material icon;
+ @Getter private final List repairOres;
+ @Getter private final int repairCostPerOre;
+
+ ModuleType(Color color, Material icon, List repairOres, int repairCostPerOre) {
+ this.color = color;
+ this.icon = icon;
+ this.repairOres = Collections.unmodifiableList(repairOres);
+ this.repairCostPerOre = repairCostPerOre;
+ }
+
+ public String nameKey() { return "module." + name().toLowerCase() + ".name"; }
+ public String descriptionKey() { return "module." + name().toLowerCase() + ".description"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+ public String[] getDescriptionLines(Languageable viewer) {
+ return viewer.getLanguage().getStringArray("fog", descriptionKey());
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/CustomEnchant.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/CustomEnchant.java
new file mode 100644
index 000000000..6c4a9c3e4
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/CustomEnchant.java
@@ -0,0 +1,65 @@
+package com.orbitmines.minecraft.spigot.servers.fog.enchant;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Choice;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.ChoiceState;
+import lombok.Getter;
+import net.md_5.bungee.api.ChatColor;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public enum CustomEnchant {
+
+ AUTO_REPLANT(Choice.ENCHANT_AUTO_REPLANT, Color.LIME),
+ HOMING_ARROW(Choice.ENCHANT_HOMING_ARROW, Color.AQUA),
+ RAIN_ARROWS (Choice.ENCHANT_RAIN_ARROWS, Color.ORANGE),
+ ABSORPTION (Choice.ENCHANT_ABSORPTION, Color.BLUE),
+ TREEFELLER (Choice.ENCHANT_CHOP_TREE, Color.GREEN);
+
+ @Getter private final Choice unlockChoice;
+ @Getter private final Color color;
+
+ CustomEnchant(Choice unlockChoice, Color color) {
+ this.unlockChoice = unlockChoice;
+ this.color = color;
+ }
+
+ public String nameKey() { return "enchant." + name().toLowerCase() + ".name"; }
+ public String damagedSuffixKey() { return "enchant.damaged_suffix"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+
+ /** Format enchant name for lore, applying strikethrough/red when DAMAGED. */
+ public String loreLine(Languageable viewer, ChoiceState state, int level) {
+ String displayName = getDisplayName(viewer);
+ String levelStr = level <= 1 ? "" : " " + roman(level);
+ if (state == ChoiceState.DAMAGED) {
+ return ChatColor.DARK_RED + "§m" + displayName + levelStr + "§r " + ChatColor.RED + "§l" + viewer.translate("fog", damagedSuffixKey());
+ }
+ return color.getCc() + displayName + levelStr;
+ }
+
+ public static List describe(ItemStack stack) {
+ List out = new ArrayList<>();
+ if (stack == null) return out;
+ ItemMeta meta = stack.getItemMeta();
+ if (meta == null || !meta.hasLore()) return out;
+ out.addAll(meta.getLore());
+ return out;
+ }
+
+ private static String roman(int n) {
+ return switch (n) {
+ case 1 -> "I";
+ case 2 -> "II";
+ case 3 -> "III";
+ case 4 -> "IV";
+ case 5 -> "V";
+ default -> Integer.toString(n);
+ };
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/EnchantRegistry.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/EnchantRegistry.java
new file mode 100644
index 000000000..78a65dd19
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/enchant/EnchantRegistry.java
@@ -0,0 +1,31 @@
+package com.orbitmines.minecraft.spigot.servers.fog.enchant;
+
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.ChoiceState;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Simple re-renderer for enchantment lore. Real impl uses persistent data. */
+public class EnchantRegistry {
+
+ public static ItemStack apply(Languageable viewer, ItemStack stack, CustomEnchant enchant, int level, ChoiceState state) {
+ if (stack == null) return null;
+ ItemMeta meta = stack.getItemMeta();
+ if (meta == null) return stack;
+
+ List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>();
+ String currentName = enchant.getDisplayName(viewer).toLowerCase();
+ lore.removeIf(line -> line != null && line.toLowerCase().contains(currentName));
+ lore.add(enchant.loreLine(viewer, state, level));
+ meta.setLore(lore);
+ stack.setItemMeta(meta);
+ return stack;
+ }
+
+ public static ItemStack markDamaged(Languageable viewer, ItemStack stack, CustomEnchant enchant) {
+ return apply(viewer, stack, enchant, 1, ChoiceState.DAMAGED);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/BlockBreakListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/BlockBreakListener.java
new file mode 100644
index 000000000..710a3a689
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/BlockBreakListener.java
@@ -0,0 +1,57 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.beehive.BeehiveHandler;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.Stats;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.ToolStatsStamp;
+import com.orbitmines.minecraft.spigot.servers.fog.stats.ToolType;
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.block.BlockBreakEvent;
+import org.bukkit.inventory.ItemStack;
+
+/**
+ * Bumps the player's per-tool block-break counter on every break, Prison-style.
+ * Also protects FoG-placed bee-nest structures (unbreakable: nest, campfire
+ * underneath, and surrounding log they were attached to).
+ */
+public class BlockBreakListener implements Listener {
+
+ private final FoG server;
+
+ public BlockBreakListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onBreak(BlockBreakEvent event) {
+ FoGPlayer player = server.getPlayer(event.getPlayer());
+ if (player == null) return;
+ Run run = player.getActiveRun();
+ if (run == null) return;
+
+ Block block = event.getBlock();
+ if (block.getType() == Material.BEE_NEST || block.getType() == Material.BEEHIVE || block.getType() == Material.CAMPFIRE) {
+ if (BeehiveHandler.isProtectedHive(run, block.getLocation())
+ || BeehiveHandler.isProtectedHive(run, block.getLocation().clone().add(0, 1, 0))) {
+ event.setCancelled(true);
+ return;
+ }
+ }
+
+ Stats stats = player.getStats();
+ if (stats == null) return; // run joined mid-tick before stats finished loading
+ ToolType tool = ToolType.fromItem(event.getPlayer().getInventory().getItemInMainHand().getType());
+ stats.incrementBlocksBroken(tool);
+
+ /* Stamp the updated count onto the tool's metadata + lore so the player sees it. */
+ ItemStack stack = event.getPlayer().getInventory().getItemInMainHand();
+ long total = stats.getBlocksBroken(tool);
+ ItemStack updated = ToolStatsStamp.stamp(server, player, stack, tool, total);
+ if (updated != null) event.getPlayer().getInventory().setItemInMainHand(updated);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DamageListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DamageListener.java
new file mode 100644
index 000000000..70d1ea353
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DamageListener.java
@@ -0,0 +1,53 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import org.bukkit.entity.Bee;
+import org.bukkit.entity.LivingEntity;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.Ravager;
+import org.bukkit.entity.Zombie;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.potion.PotionEffect;
+import org.bukkit.potion.PotionEffectType;
+import org.bukkit.util.Vector;
+
+import java.util.Random;
+
+/** Applies FoG-specific damage side-effects (hunger, knock-up, poison). */
+public class DamageListener implements Listener {
+
+ private static final Random RANDOM = new Random();
+ private final FoG server;
+
+ public DamageListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onHit(EntityDamageByEntityEvent event) {
+ if (!(event.getEntity() instanceof Player victim)) return;
+ if (!(event.getDamager() instanceof LivingEntity damager)) return;
+
+ /* Zombie → chance of hunger if food bar is empty */
+ if (damager instanceof Zombie) {
+ if (victim.getFoodLevel() <= 0 && RANDOM.nextInt(100) < 20) {
+ victim.addPotionEffect(new PotionEffect(PotionEffectType.HUNGER, 20 * 5, 0));
+ victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 20 * 3, 2));
+ }
+ }
+
+ /* Ravager (Bull) → knock up */
+ if (damager instanceof Ravager) {
+ victim.setVelocity(victim.getVelocity().add(new Vector(0, 1.4, 0)));
+ }
+
+ /* Bee → 20% poison chance */
+ if (damager instanceof Bee) {
+ if (RANDOM.nextInt(100) < 20) {
+ victim.addPotionEffect(new PotionEffect(PotionEffectType.POISON, 20 * 2, 0));
+ }
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DeathListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DeathListener.java
new file mode 100644
index 000000000..f4563efc4
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/DeathListener.java
@@ -0,0 +1,110 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.Choice;
+import com.orbitmines.minecraft.spigot.servers.fog.choice.ChoiceState;
+import com.orbitmines.minecraft.spigot.servers.fog.level.LevelFormulas;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Difficulty;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import com.orbitmines.minecraft.spigot.servers.fog.run.RunStore;
+import com.orbitmines.minecraft.spigot.servers.fog.structure.Compartment;
+import com.orbitmines.minecraft.spigot.servers.fog.structure.CompartmentType;
+import org.bukkit.GameMode;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.PlayerDeathEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * On death: lose up to N levels ({@link Difficulty#getMaxLevelsLostOnDeath()}). Choices
+ * made at those levels flip to DAMAGED state, tracked XP rolls back to the start of
+ * the target level, and Hardcore enters permanent spectator mode.
+ */
+public class DeathListener implements Listener {
+
+ private final FoG server;
+
+ public DeathListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onDeath(PlayerDeathEvent event) {
+ Player bukkit = event.getEntity();
+ FoGPlayer player = server.getPlayer(bukkit);
+ if (player == null) return;
+ Run run = player.getActiveRun();
+ if (run == null) return;
+
+ Difficulty diff = run.getDifficulty();
+ int currentLevel = player.getLevel();
+ int target = Math.max(0, currentLevel - diff.getMaxLevelsLostOnDeath());
+ UUID uuid = player.getUUID();
+
+ /* Sync bits — tweak the death event immediately so vanilla doesn't drop XP. */
+ event.setKeepInventory(!diff.isPermadeath());
+ event.setKeepLevel(true);
+ event.setDroppedExp(0);
+
+ if (diff.isPermadeath()) {
+ server.runSync(() -> player.setGameMode(GameMode.SPECTATOR));
+ }
+
+ player.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED, "fog", "death.lost_levels",
+ currentLevel - target);
+
+ /* Async: walk the choice grid, flip DAMAGED, roll back XP. */
+ final int fTarget = target;
+ server.runAsync(() -> {
+ RunStore store = run.getStore();
+ List toSeal = new ArrayList<>();
+ for (int lvl = fTarget + 1; lvl <= currentLevel; lvl++) {
+ for (Choice c : Choice.values()) {
+ if (store.getChoiceState(uuid, lvl, c) == ChoiceState.ACTIVE) {
+ store.setChoiceState(uuid, lvl, c, ChoiceState.DAMAGED);
+ if (player.getStats() != null) player.getStats().onChoiceDamaged(lvl, c);
+ if (c.getCategory() == Choice.Category.STRUCTURE) {
+ CompartmentType ct = guessCompartmentType(c);
+ if (ct != null) toSeal.add(ct);
+ }
+ }
+ }
+ }
+
+ long newTotalXp = 0;
+ for (int i = 0; i < fTarget; i++) newTotalXp += LevelFormulas.required(i);
+ store.setMemberExperience(uuid, newTotalXp);
+ store.setMemberLevel(uuid, fTarget);
+ if (diff.isPermadeath()) store.setMemberDead(uuid, true);
+
+ /* Sync-seal each damaged compartment (block ops). */
+ for (CompartmentType ct : toSeal) {
+ Integer cx = store.getCompartmentX(ct);
+ Integer cy = store.getCompartmentY(ct);
+ Integer cz = store.getCompartmentZ(ct);
+ if (cx == null || cy == null || cz == null) continue;
+ int fx = cx, fy = cy, fz = cz;
+ Compartment.markDamagedPersist(store, ct);
+ server.runSync(() -> Compartment.markDamagedBlocks(run, ct, fx, fy, fz));
+ }
+
+ /* Refresh derived stats after the rollback, sync. */
+ server.runSync(player::applyDerivedStats);
+ });
+ }
+
+ private CompartmentType guessCompartmentType(Choice c) {
+ return switch (c) {
+ case STRUCTURE_FARM -> CompartmentType.FARM;
+ case STRUCTURE_ENCHANT -> CompartmentType.ENCHANTING;
+ case STRUCTURE_CRATES -> CompartmentType.CRATES;
+ case DRONE_FACTORY -> CompartmentType.DRONE_FACTORY;
+ default -> null;
+ };
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ExperienceSuppressor.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ExperienceSuppressor.java
new file mode 100644
index 000000000..ab6712e23
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ExperienceSuppressor.java
@@ -0,0 +1,50 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
+import org.bukkit.event.entity.ExpBottleEvent;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.event.player.PlayerExpChangeEvent;
+import org.bukkit.entity.ExperienceOrb;
+
+/**
+ * Blocks all vanilla XP sources. The bar is managed via
+ * {@link com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer#updateExperienceBar()}.
+ * XP bottles from crates push tracked XP via a different path.
+ */
+public class ExperienceSuppressor implements Listener {
+
+ private final FoG server;
+
+ public ExperienceSuppressor(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onExpChange(PlayerExpChangeEvent event) {
+ event.setAmount(0);
+ }
+
+ @EventHandler
+ public void onOrbTarget(EntityTargetLivingEntityEvent event) {
+ /* Prevent XP orbs from homing on players (they're destroyed when ignored). */
+ if (event.getEntity() instanceof ExperienceOrb) {
+ event.setCancelled(true);
+ event.getEntity().remove();
+ }
+ }
+
+ @EventHandler
+ public void onExpBottle(ExpBottleEvent event) {
+ event.setExperience(0);
+ event.setShowEffect(true);
+ }
+
+ @EventHandler
+ public void onDeath(PlayerDeathEvent event) {
+ event.setDroppedExp(0);
+ event.setKeepLevel(true);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/FoGCommandEvents.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/FoGCommandEvents.java
new file mode 100644
index 000000000..f201b16cd
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/FoGCommandEvents.java
@@ -0,0 +1,12 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.archive.minecraft.spigot._2019.libs.spigot.events.CommandEvents;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+
+public class FoGCommandEvents extends CommandEvents {
+
+ public FoGCommandEvents(FoG server) {
+ super(server);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/HologramCleanupListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/HologramCleanupListener.java
new file mode 100644
index 000000000..c40ccf8cf
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/HologramCleanupListener.java
@@ -0,0 +1,30 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.util.HologramTag;
+import org.bukkit.Bukkit;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.world.WorldLoadEvent;
+
+/**
+ * Removes leftover FoG holograms (tagged armor stands + floating items) whenever a
+ * world is loaded. This covers world-reloads mid-session and any run-worlds that
+ * come back with stale holograms saved in their level.dat from a prior session.
+ */
+public class HologramCleanupListener implements Listener {
+
+ private final FoG server;
+
+ public HologramCleanupListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onWorldLoad(WorldLoadEvent event) {
+ int removed = HologramTag.cleanupWorld(event.getWorld());
+ if (removed > 0) {
+ Bukkit.getLogger().info("[fog] Removed " + removed + " stale hologram entities in " + event.getWorld().getName());
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ItemDropListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ItemDropListener.java
new file mode 100644
index 000000000..61119c70b
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/ItemDropListener.java
@@ -0,0 +1,30 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerDropItemEvent;
+import org.bukkit.inventory.ItemStack;
+
+/**
+ * Blocks FoG lobby-kit items from being dropped. Those items are stamped with
+ * {@code interactive_kit:item_id} by {@link
+ * com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.kits.interactive.InteractiveKit.Interaction#applyId}.
+ */
+public class ItemDropListener implements Listener {
+
+ private final FoG server;
+
+ public ItemDropListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onDrop(PlayerDropItemEvent event) {
+ ItemStack stack = event.getItemDrop().getItemStack();
+ Long id = server.getNms().customItem().getMetaDataLong(stack, "interactive_kit", "item_id");
+ if (id != null && id >= 0) {
+ event.setCancelled(true);
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/LockedSlotsListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/LockedSlotsListener.java
new file mode 100644
index 000000000..c441083f0
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/LockedSlotsListener.java
@@ -0,0 +1,71 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryAction;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryDragEvent;
+import org.bukkit.inventory.Inventory;
+
+/**
+ * Keeps the lobby-kit items pinned to their fixed slots (the player can't move,
+ * swap, or drop them) AND treats a plain click on one of those slots as a
+ * trigger — firing the same action that right-clicking the item in hand would.
+ *
+ *
Needed because slots 16 / 17 (top-right of the main inventory) can't be
+ * right-clicked in the world without first moving the item to the hotbar, which
+ * we also forbid. So the only sensible UX is to dispatch from the inventory
+ * click itself.
+ */
+public class LockedSlotsListener implements Listener {
+
+ private final FoG server;
+
+ public LockedSlotsListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onClick(InventoryClickEvent event) {
+ if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player bp)) return;
+ /* Only police the player's own inventory, not other inventories. */
+ Inventory top = event.getView().getTopInventory();
+ boolean clickingPlayerInv = event.getClickedInventory() == bp.getInventory();
+ boolean shiftingIntoPlayerInv = event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY
+ && event.getClickedInventory() == top;
+ if (!clickingPlayerInv && !shiftingIntoPlayerInv) return;
+
+ int slot = event.getSlot();
+ if (!server.getLobbyKit().getLockedSlots().contains(slot)) return;
+ /* Always cancel so the item can't be picked up / swapped out. */
+ event.setCancelled(true);
+
+ /* Only dispatch the action when the player's own inventory is clicked
+ directly — shift-click-from-top-inventory shouldn't open another GUI. */
+ if (!clickingPlayerInv) return;
+
+ FoGPlayer player = server.getPlayer(bp);
+ if (player == null) return;
+ /* Close the inventory first on the next tick if the action opens another GUI.
+ The kit's own fireSlotAction handles no-ops when a hologram selector is up. */
+ int finalSlot = slot;
+ server.runSync(() -> {
+ bp.closeInventory();
+ server.getLobbyKit().fireSlotAction(finalSlot, player);
+ });
+ }
+
+ @EventHandler
+ public void onDrag(InventoryDragEvent event) {
+ if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player bp)) return;
+ for (int raw : event.getRawSlots()) {
+ int slot = event.getView().convertSlot(raw);
+ if (server.getLobbyKit().getLockedSlots().contains(slot)) {
+ event.setCancelled(true);
+ return;
+ }
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/RunGUICloseListener.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/RunGUICloseListener.java
new file mode 100644
index 000000000..3450f728e
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/events/RunGUICloseListener.java
@@ -0,0 +1,33 @@
+package com.orbitmines.minecraft.spigot.servers.fog.events;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.CoopJoinGUI;
+import com.orbitmines.minecraft.spigot.servers.fog.gui.SpectatorJoinGUI;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+
+/**
+ * Re-opens the top-level RunSelector when a player closes a run-related GUI
+ * (coop / spectate) without actually committing to their action — cancelling
+ * drops them back at the top-level choice rather than leaving them stranded.
+ */
+public class RunGUICloseListener implements Listener {
+
+ private final FoG server;
+
+ public RunGUICloseListener(FoG server) {
+ this.server = server;
+ }
+
+ @EventHandler
+ public void onClose(InventoryCloseEvent event) {
+ if (!(event.getPlayer() instanceof Player bukkit)) return;
+ FoGPlayer player = server.getPlayer(bukkit);
+ if (player == null) return;
+ CoopJoinGUI.onInventoryClose(server, player, event.getInventory());
+ SpectatorJoinGUI.onInventoryClose(server, player, event.getInventory());
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/faction/Faction.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/faction/Faction.java
new file mode 100644
index 000000000..9f9d32072
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/faction/Faction.java
@@ -0,0 +1,38 @@
+package com.orbitmines.minecraft.spigot.servers.fog.faction;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.player.Languageable;
+import lombok.Getter;
+import org.bukkit.Material;
+
+public enum Faction {
+
+ OMEGA(Color.PURPLE, Material.ENDER_EYE),
+ ALPHA(Color.RED, Material.BLAZE_POWDER),
+ BETA (Color.AQUA, Material.PRISMARINE_CRYSTALS);
+
+ @Getter private final Color color;
+ @Getter private final Material icon;
+
+ Faction(Color color, Material icon) {
+ this.color = color;
+ this.icon = icon;
+ }
+
+ public String nameKey() { return "faction." + name().toLowerCase() + ".name"; }
+ public String descriptionKey() { return "faction." + name().toLowerCase() + ".description"; }
+
+ public String getDisplayName(Languageable viewer) { return viewer.translate("fog", nameKey()); }
+ public String[] getDescriptionLines(Languageable viewer) {
+ return viewer.getLanguage().getStringArray("fog", descriptionKey());
+ }
+
+ public String getColoredName(Languageable viewer) {
+ return color.getCc() + "§l" + getDisplayName(viewer);
+ }
+
+ public static Faction parse(String s) {
+ if (s == null) return null;
+ try { return Faction.valueOf(s.toUpperCase()); } catch (IllegalArgumentException e) { return null; }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/CoopJoinGUI.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/CoopJoinGUI.java
new file mode 100644
index 000000000..5a4866929
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/CoopJoinGUI.java
@@ -0,0 +1,111 @@
+package com.orbitmines.minecraft.spigot.servers.fog.gui;
+
+import com.orbitmines.archive.minecraft._2019.libs.Color;
+import com.orbitmines.archive.minecraft._2019.libs.player.Name;
+import com.orbitmines.archive.minecraft._2019.libs.utils.Message;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.chat.text.TextBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.item.ItemBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.item.mutable.MutableItemBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.guis.GUI;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Run;
+import net.md_5.bungee.api.chat.ClickEvent;
+import net.md_5.bungee.api.chat.HoverEvent;
+import org.bukkit.Material;
+import org.bukkit.inventory.Inventory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * List of online players currently in a run. Clicking sends a coop-join request
+ * to the target (like /tphere). The target accepts via a clickable chat message
+ * or {@code /coopaccept }.
+ *
+ *
If the viewer closes this GUI without clicking any player, the RunSelector
+ * is re-opened — cancelling coop puts them back at the top-level choice.
+ */
+public class CoopJoinGUI extends GUI {
+
+ /** UUID → currently-open GUI; used by {@link com.orbitmines.minecraft.spigot.servers.fog.events.CoopCloseListener}. */
+ private static final Map OPEN = new HashMap<>();
+
+ private final FoG fog;
+ private boolean requestSent;
+
+ public static void open(FoG server, FoGPlayer viewer) {
+ CoopJoinGUI gui = new CoopJoinGUI(server, viewer);
+ OPEN.put(viewer.getUUID(), gui);
+ gui.open();
+ }
+
+ /**
+ * Called from {@link com.orbitmines.minecraft.spigot.servers.fog.events.CoopCloseListener}
+ * when the viewer closes any inventory. If the closed one is the registered
+ * CoopJoinGUI and no request was sent, re-open RunSelector on the next tick.
+ */
+ public static void onInventoryClose(FoG server, FoGPlayer viewer, Inventory closed) {
+ CoopJoinGUI gui = OPEN.get(viewer.getUUID());
+ if (gui == null) return;
+ if (gui.getInventory() != closed) return;
+ OPEN.remove(viewer.getUUID());
+ if (!gui.requestSent) {
+ server.runSync(() -> RunSelector.open(server, viewer));
+ }
+ }
+
+ private CoopJoinGUI(FoG server, FoGPlayer viewer) {
+ super(54, "§0§l" + viewer.translate("fog", "gui.coop_join.title"), viewer);
+ this.fog = server;
+
+ int slot = 0;
+ for (FoGPlayer other : server.getPlayers()) {
+ if (other == viewer) continue;
+ Run run = other.getActiveRun();
+ if (run == null) continue;
+ final Run finalRun = run;
+ final String otherName = other.getName();
+ final FoGPlayer finalOther = other;
+ set(slot++, new Item(() ->
+ new ItemBuilder(Material.PLAYER_HEAD, 1, "§e§l" + otherName)
+ .addLore(viewer.translate("fog", "gui.coop_join.run", finalRun.getId()))
+ .addLore(viewer.translate("fog", "gui.coop_join.difficulty", finalRun.getDifficulty().getColoredName(viewer)))
+ .addLore(" ")
+ .addLore("§a§l" + viewer.translate("fog", "gui.coop_join.click_to_request")),
+ event -> {
+ this.requestSent = true;
+ OPEN.remove(viewer.getUUID());
+ viewer.closeInventory();
+ sendRequest(server, viewer, finalOther, finalRun);
+ }
+ ));
+ if (slot >= 45) break;
+ }
+
+ for (int s = slot; s < getInventory().getSize(); s++) {
+ set(s, new Item(() ->
+ new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, 1, "§f")));
+ }
+ }
+
+ /** Send a coop request to {@code target}. Target sees a click-to-accept chat message. */
+ private static void sendRequest(FoG server, FoGPlayer requester, FoGPlayer target, Run run) {
+ target.getCoopRequestsFrom().put(requester.getRawName(), run.getId());
+
+ requester.sendMessage("Coop", Color.LIME, "fog", "coop.request.sent", target.getName(Name.RAW_COLORED) + "§7");
+
+ target.sendRawMessage("");
+ target.sendMessage("Coop", Color.BLUE, "fog", "coop.request.received", requester.getName(Name.RAW_COLORED) + "§7");
+
+ TextBuilder builder = new TextBuilder<>();
+ builder.add(Color.SILVER, p -> Message.format("Coop", Color.LIME,
+ " " + target.translate("fog", "coop.request.click_to_accept",
+ "§a" + target.translate("fog", "coop.request.accept"))))
+ .click(ClickEvent.Action.RUN_COMMAND, p -> "/coopaccept " + requester.getRawName())
+ .hover(HoverEvent.Action.SHOW_TEXT, p -> "§7" + target.translate("fog", "coop.request.hover",
+ requester.getName(Name.RAW_COLORED) + "§7"));
+ builder.send(target);
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DifficultySelector.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DifficultySelector.java
new file mode 100644
index 000000000..7a9d6d63c
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DifficultySelector.java
@@ -0,0 +1,34 @@
+package com.orbitmines.minecraft.spigot.servers.fog.gui;
+
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.run.Difficulty;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Hologram-based difficulty picker. Like the RunSelector, each option shows a
+ * title (Normal / Hard / Hardcore) above the floating icon — descriptions are
+ * omitted.
+ */
+public class DifficultySelector {
+
+ private static final String[] NO_DESCRIPTION = new String[0];
+
+ public static void open(FoG server, FoGPlayer viewer) {
+ List> options = new ArrayList<>();
+ for (Difficulty d : Difficulty.values()) {
+ options.add(new HologramSelector.Option<>(
+ d.getIcon(),
+ d.getColoredName(viewer),
+ NO_DESCRIPTION,
+ d
+ ));
+ }
+
+ new HologramSelector<>(server, viewer, options,
+ (sel, difficulty) -> server.getRunManager().startNewRun(viewer, difficulty)
+ ).open();
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DroneFactoryGUI.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DroneFactoryGUI.java
new file mode 100644
index 000000000..165ecc8c6
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/DroneFactoryGUI.java
@@ -0,0 +1,60 @@
+package com.orbitmines.minecraft.spigot.servers.fog.gui;
+
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.item.ItemBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.item.mutable.MutableItemBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.guis.GUI;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.drone.Drone;
+import com.orbitmines.minecraft.spigot.servers.fog.drone.DroneFactory;
+import com.orbitmines.minecraft.spigot.servers.fog.drone.DroneModule;
+import com.orbitmines.minecraft.spigot.servers.fog.drone.ModuleType;
+
+public class DroneFactoryGUI extends GUI {
+
+ public DroneFactoryGUI(FoG server, FoGPlayer viewer) {
+ super(54, "§0§l" + viewer.translate("fog", "gui.drone_factory.title"), viewer);
+
+ int slot = 0;
+ for (Drone drone : viewer.getDrones()) {
+ final Drone finalDrone = drone;
+ set(slot++, new Item(() ->
+ new ItemBuilder(org.bukkit.Material.ALLAY_SPAWN_EGG, 1,
+ "§b§l" + viewer.translate("fog", "gui.drone_factory.drone_title", finalDrone.getId()))
+ .addLore(viewer.translate("fog", "gui.drone_factory.hp", (int) finalDrone.getHealth(), (int) finalDrone.getMaxHealth()))
+ .addLore(viewer.translate("fog", "gui.drone_factory.modules", finalDrone.getModules().size()))
+ .addLore(" ")
+ .addLore("§e§l" + viewer.translate("fog", "gui.drone_factory.click_to_repair", DroneFactory.repairCost(finalDrone))),
+ event -> {
+ DroneFactory.repair(finalDrone);
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.LIME, "fog", "drone.repaired", finalDrone.getId());
+ }
+ ));
+ if (slot >= 9) break;
+ }
+
+ slot = 18;
+ for (ModuleType module : ModuleType.values()) {
+ final ModuleType finalModule = module;
+ set(slot++, new Item(() ->
+ new ItemBuilder(finalModule.getIcon(), 1,
+ finalModule.getColor().getCc() + "§l" + finalModule.getDisplayName(viewer))
+ .addLore(" ")
+ .addLore("§e§l" + viewer.translate("fog", "gui.drone_factory.click_to_attach")),
+ event -> {
+ if (viewer.getDrones().isEmpty()) {
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED, "fog", "drone.none");
+ return;
+ }
+ Drone first = viewer.getDrones().get(0);
+ if (!first.addModule(new DroneModule(finalModule))) {
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.RED, "fog", "drone.modules_full");
+ } else {
+ viewer.sendMessage("FoG", com.orbitmines.archive.minecraft._2019.libs.Color.LIME, "fog", "drone.module_attached",
+ finalModule.getDisplayName(viewer), first.getId());
+ }
+ }
+ ));
+ }
+ }
+}
diff --git a/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/HologramSelector.java b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/HologramSelector.java
new file mode 100644
index 000000000..87eaabb7b
--- /dev/null
+++ b/@orbitmines/minecraft/remastered/spigot/src/main/java/com/orbitmines/minecraft/spigot/servers/fog/gui/HologramSelector.java
@@ -0,0 +1,205 @@
+package com.orbitmines.minecraft.spigot.servers.fog.gui;
+
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.builders.item.ItemBuilder;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.freezer.Freezer;
+import com.orbitmines.archive.minecraft.spigot._2019.utils.spigot.npcs.FloatingItem;
+import com.orbitmines.minecraft.spigot.servers.fog.FoG;
+import com.orbitmines.minecraft.spigot.servers.fog.FoGPlayer;
+import com.orbitmines.minecraft.spigot.servers.fog.util.HologramTag;
+import lombok.Getter;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.entity.Entity;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.util.Vector;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+
+/**
+ * Reusable hologram-based choice selector.
+ *
+ *