diff --git a/.gitignore b/.gitignore index 723ef36..21aeec0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ -.idea \ No newline at end of file +.idea +*.iml +target +dependency-reduced-pom.xml +# Live config holds MySQL credentials - never commit a populated one. +# The shipped template lives at src/main/resources/config.yml. +/config.yml + +# Python +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 233f9d4..6e129dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,84 @@ # BlockVault -BlockVault is a collaborative Minecraft plugin where players work together to collect every block in the game and display them. + +![img](https://i.imgur.com/DPcMoaq.png) + +BlockVault drives a purpose-built museum for a twelve-month, server-wide block +collection event. Players donate one of every obtainable block in Minecraft +Java **26.2**; each donation is consumed and recorded, a donor head appears on +the shelf, and six themed floors unlock over the season. + +## Requirements + +| | | +|---|---| +| Minecraft | Java Edition **26.2** (`api-version '26.2'`) | +| Server | Paper | +| Build JDK | **25** (paper-api 26.2 ships Java 25 bytecode) | +| Database | MySQL 8.0+ / MariaDB 10.5+ | +| Package | `dev.anchorlight.blockvault` | + +HikariCP and the MySQL driver are pulled at runtime via `libraries:` in +`plugin.yml` — no shading. + +## Setup + +1. **Apply the schema.** Run `src/main/resources/blockvault_schema.sql` against + your database before first start. +2. **Generate the data artefacts** from section 12 of the implementation brief: + ``` + python tools/gen_artifacts.py # reads tools/target_list.txt + ``` + This produces `src/main/resources/vault_items.yml`, + `src/main/resources/vault_slots.json` and `tools/spawn_frames.mcfunction`. + The plugin **disables itself** if `vault_slots.json` is missing — it never + regenerates the target list. +3. **Paste the schematic** `sky_vault.schem` at the world origin, then run + `spawn_frames.mcfunction` standing on that origin to spawn the empty frames. +4. **Configure** `config.yml` (git-ignored — holds DB credentials): + database connection, the world `origin:` the schematic sits at, the chapter + `opens-at` dates, and optionally a `discord.webhook-url`. +5. Start the server. Bootstrap seeds `bv_target` / `bv_chapter` from the manifest + and runs a three-way validation pass (manifest ↔ `vault_items.yml` ↔ + `bv_target`) plus new-block detection. +6. `/bvstart` opens the event. + +## How it works + +- **Submissions are transactional.** Order is always write → confirm commit → + consume the item. If the database is unreachable the block stays in the + player's hand. `bv_submission.material` is the primary key, so one-of-each is + enforced by the database and simultaneous submits race safely. +- **The head is the state indicator.** No separate colour-glass state block. + `/bvupdatestate` reconciles the world against the database and only ever writes + the manifest `head` cells — never structure — in a single summary line. +- **Chapters** open on their scheduled date *or* at 90% of the previous chapter, + whichever comes first. Earlier chapters never close. Unlock breaks one + `iron_bars` seal and runs a broadcast/title/fireworks/BossBar ceremony. +- **The target list is frozen per `edition`.** New blocks go in as a new edition, + never the running one. +- **Chapter advancements** ship as a bundled datapack, written into + `/datapacks/blockvault/` on load and granted by the plugin when a + chapter reaches 100%. A world reload may be needed the first time. + +## Commands + +| Command | Permission | Purpose | +|---|---|---| +| `/bvsubmit` | `blockvault.submit` | Donate the held block (in-region, no creative) | +| `/bvprogress` | `blockvault.progress` | Current-chapter bar + overall bar | +| `/bvleaderboard [month]` | `blockvault.leaderboard` | Top 10 all-time, or this month | +| `/bvfind ` | `blockvault.find` | Level, room, shelf coordinates | +| `/bvinfo ` | `blockvault.info` | Rarity, points, chapter, status | +| `/bvmissing [chapter] [page]` | `blockvault.missing` | Outstanding list, paginated | +| `/bvcheck` | `blockvault.check` | What in your inventory the vault needs | +| `/bvme` | `blockvault.me` | Your blocks, points, rank | +| `/bvhistory ` | `blockvault.history` | Who donated it and when | +| `/bvedition` | `blockvault.edition` | Frozen target-list version | +| `/bvstart [stop]` | `blockvault.start` | Open / close the event | +| `/bvupdatestate` | `blockvault.updatestate` | Reconcile world ↔ database (console-safe) | +| `/bvreload` | `blockvault.reload` | Reload config without restart | +| `/bvrevoke ` | `blockvault.revoke` | Reverse a submission, refund points, remove head | +| `/bvrepair` | `blockvault.repair` | Rebuild missing frames and heads from the manifest | +| `/bvbackup` | `blockvault.backup` | Timestamped `mysqldump` | + +`blockvault.build` bypasses in-region build/interaction protection. diff --git a/blockvault.iml b/blockvault.iml deleted file mode 100644 index bbeeb3e..0000000 --- a/blockvault.iml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - PAPER - ADVENTURE - - 1 - - - - \ No newline at end of file diff --git a/pom.xml b/pom.xml index a46b1a0..de78a7e 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - me.benrobson + dev.anchorlight blockvault 1.0.0 jar @@ -12,7 +12,8 @@ blockvault - 21 + 25 + 25 UTF-8 @@ -23,41 +24,30 @@ org.apache.maven.plugins maven-compiler-plugin 3.13.0 - - ${java.version} - ${java.version} - - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.3 - - - package - - shade - - - src/main/resources true + + datapack/** + + + + src/main/resources + false + + datapack/** + - - papermc-repo - https://repo.papermc.io/repository/maven-public/ - - sonatype - https://oss.sonatype.org/content/groups/public/ + papermc-repo + https://repo.papermc.io/repository/maven-public/ @@ -65,7 +55,20 @@ io.papermc.paper paper-api - 1.21.3-R0.1-SNAPSHOT + [26.2.build,) + provided + + + + com.zaxxer + HikariCP + 5.1.0 + provided + + + com.mysql + mysql-connector-j + 8.4.0 provided diff --git a/src/main/java/dev/anchorlight/blockvault/BlockVault.java b/src/main/java/dev/anchorlight/blockvault/BlockVault.java new file mode 100644 index 0000000..40c7562 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/BlockVault.java @@ -0,0 +1,221 @@ +package dev.anchorlight.blockvault; + +import dev.anchorlight.blockvault.advancement.AdvancementService; +import dev.anchorlight.blockvault.chapter.ChapterService; +import dev.anchorlight.blockvault.commands.*; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.display.DisplayService; +import dev.anchorlight.blockvault.listener.RegionProtectionListener; +import dev.anchorlight.blockvault.listener.PlayerGuidanceListener; +import dev.anchorlight.blockvault.model.Manifest; +import dev.anchorlight.blockvault.model.Region; +import dev.anchorlight.blockvault.util.FileUtil; +import dev.anchorlight.blockvault.util.ScheduleUtil; +import dev.anchorlight.blockvault.util.StartupValidation; +import dev.anchorlight.blockvault.util.VaultUtil; +import dev.anchorlight.blockvault.util.Webhook; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.file.Path; + +import static org.bukkit.Bukkit.getConsoleSender; + +public final class BlockVault extends JavaPlugin { + + private static BlockVault instance; + + private Database database; + private Manifest manifest; + private ChapterService chapters; + private DisplayService displays; + private AdvancementService advancements; + private Webhook webhook; + private Region region; + + public static BlockVault get() { + return instance; + } + + @Override + public void onLoad() { + instance = this; + // Datapack must land before worlds load so the game picks it up. + this.advancements = new AdvancementService(this); + this.advancements.installDatapack(); + } + + @Override + public void onEnable() { + instance = this; + saveDefaultConfig(); + + // The target list is a required artefact - never regenerated (brief section 7). + try { + Path override = getDataFolder().toPath().resolve("vault_slots.json"); + this.manifest = Manifest.load(override, getClassLoader()); + } catch (Exception e) { + getLogger().severe("Could not load vault_slots.json: " + e.getMessage()); + getLogger().severe("Generate it with tools/gen_artifacts.py and restart. Disabling."); + getServer().getPluginManager().disablePlugin(this); + return; + } + + // Bootstrap runs once at startup, before the server accepts players. All + // *runtime* database I/O goes through the async scheduler; this does not. + try { + this.database = new Database(this); + this.database.bootstrap(manifest); + } catch (Exception e) { + getLogger().severe("Database bootstrap failed: " + e.getMessage()); + getLogger().severe("Check config.yml credentials and that the schema is reachable. Disabling."); + getServer().getPluginManager().disablePlugin(this); + return; + } + + getConsoleSender().sendMessage(prefix() + "§aPlugin is now enabled!"); + + FileUtil fileUtil = new FileUtil(this); + VaultUtil vaultUtil = new VaultUtil(this); + + this.chapters = new ChapterService(this); + this.webhook = new Webhook(this); + this.displays = new DisplayService(this); + + StartupValidation.run(this); + + register("bvstart", new StartCommand(this)); + register("bvsubmit", new SubmitCommand(this)); + register("bvprogress", new ProgressCommand(this)); + register("bvleaderboard", new LeaderboardCommand(this)); + register("bvupdatestate", new UpdateStateCommand(this)); + + QueryCommand query = new QueryCommand(this); + for (String c : new String[]{"bvfind", "bvinfo", "bvmissing", "bvcheck", + "bvme", "bvhistory", "bvedition", "bvreload"}) { + register(c, query); + PluginCommand pc = getCommand(c); + if (pc != null) pc.setTabCompleter(query); + } + AdminCommand admin = new AdminCommand(this); + for (String c : new String[]{"bvrevoke", "bvrepair", "bvbackup"}) { + register(c, admin); + PluginCommand pc = getCommand(c); + if (pc != null) pc.setTabCompleter(admin); + } + + getServer().getPluginManager().registerEvents(new RegionProtectionListener(this), this); + PlayerGuidanceListener guidance = new PlayerGuidanceListener(this); + getServer().getPluginManager().registerEvents(guidance, this); + guidance.start(); + + forceLoadChunks(true); + chapters.start(); + displays.start(); + ScheduleUtil.scheduleVaultStateTask(this, vaultUtil, fileUtil); + } + + @Override + public void onDisable() { + if (displays != null) displays.stop(); + if (chapters != null) chapters.stop(); + forceLoadChunks(false); + if (database != null) database.close(); + getConsoleSender().sendMessage(prefix() + "§cPlugin is now disabled."); + } + + public ChapterService chapters() { + return chapters; + } + + public Webhook webhook() { + return webhook; + } + + public DisplayService displays() { + return displays; + } + + public AdvancementService advancements() { + return advancements; + } + + /** + * The protected volume, built lazily once the origin world is loaded. + * Rebuilt by {@link #invalidateRegion()} after a config reload. + */ + public Region region() { + if (region == null) { + World world = originWorld(); + if (world == null) return null; + int[] origin = { + getConfig().getInt("origin.x"), + getConfig().getInt("origin.y"), + getConfig().getInt("origin.z") + }; + region = new Region(world, origin, manifest.min(), manifest.max(), 4); + } + return region; + } + + public void invalidateRegion() { + region = null; + } + + private void forceLoadChunks(boolean load) { + Region r = region(); + if (r == null || r.world() == null) return; + for (int cx = r.minChunkX(); cx <= r.maxChunkX(); cx++) { + for (int cz = r.minChunkZ(); cz <= r.maxChunkZ(); cz++) { + r.world().setChunkForceLoaded(cx, cz, load); + } + } + getLogger().info((load ? "Force-loaded " : "Released ") + + "vault chunks in " + r.world().getName() + "."); + } + + public Database database() { + return database; + } + + public Manifest manifest() { + return manifest; + } + + /** The configured world the schematic origin sits in, or null if not loaded. */ + public World originWorld() { + return getServer().getWorld(getConfig().getString("origin.world", "world")); + } + + /** Resolve a manifest-relative coordinate to a live world location. */ + public Location resolve(int[] rel) { + if (rel == null) throw new IllegalArgumentException( + "manifest coordinate missing - check vault_slots.json (leader/seals block)"); + return new Location(originWorld(), + getConfig().getInt("origin.x") + rel[0], + getConfig().getInt("origin.y") + rel[1], + getConfig().getInt("origin.z") + rel[2]); + } + + public String prefix() { + return ChatColor.translateAlternateColorCodes('&', getConfig().getString("lang.prefix", "")); + } + + /** Send a player-facing message through the configured prefix. */ + public void tell(org.bukkit.command.CommandSender to, String message) { + to.sendMessage(prefix() + message); + } + + private void register(String name, CommandExecutor executor) { + PluginCommand command = getCommand(name); + if (command == null) { + getLogger().severe("Command '" + name + "' is missing from plugin.yml; skipping registration."); + return; + } + command.setExecutor(executor); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/advancement/AdvancementService.java b/src/main/java/dev/anchorlight/blockvault/advancement/AdvancementService.java new file mode 100644 index 0000000..21b5a0f --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/advancement/AdvancementService.java @@ -0,0 +1,116 @@ +package dev.anchorlight.blockvault.advancement; + +import dev.anchorlight.blockvault.BlockVault; +import org.bukkit.NamespacedKey; +import org.bukkit.advancement.Advancement; +import org.bukkit.entity.Player; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +/** + * Ships a bundled datapack ({@code src/main/resources/datapack}) with one + * advancement per chapter, granted by the plugin on completion. Paper has no + * runtime advancement-registration API, so the datapack is written into the + * world folder during {@link #installDatapack()} (called from onLoad, before + * worlds load) and picked up automatically. + */ +public final class AdvancementService { + + private static final int CHAPTERS = 6; + private static final String[] FILES = { + "pack.mcmeta", + "data/blockvault/advancement/chapter_1.json", + "data/blockvault/advancement/chapter_2.json", + "data/blockvault/advancement/chapter_3.json", + "data/blockvault/advancement/chapter_4.json", + "data/blockvault/advancement/chapter_5.json", + "data/blockvault/advancement/chapter_6.json", + }; + + private final BlockVault plugin; + + public AdvancementService(BlockVault plugin) { + this.plugin = plugin; + } + + /** Write the datapack into <worldContainer>/<level-name>/datapacks/blockvault. */ + public void installDatapack() { + try { + String level = levelName(); + Path root = plugin.getServer().getWorldContainer().toPath() + .resolve(level).resolve("datapacks").resolve("blockvault"); + String stamp = plugin.getPluginMeta().getVersion(); + Path marker = root.resolve(".plugin-version"); + + if (Files.isRegularFile(marker) && stamp.equals(Files.readString(marker).trim())) { + return; // already current + } + for (String rel : FILES) { + Path target = root.resolve(rel); + Files.createDirectories(target.getParent()); + try (InputStream in = plugin.getResource("datapack/" + rel)) { + if (in == null) { + plugin.getLogger().warning("Datapack resource missing: " + rel); + continue; + } + Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + Files.writeString(marker, stamp); + plugin.getLogger().info("Installed chapter-advancement datapack for level '" + level + + "'. A world reload may be needed on first install."); + } catch (Exception e) { + plugin.getLogger().warning("Could not install advancement datapack: " + e.getMessage()); + } + } + + private String levelName() { + File props = new File("server.properties"); + if (props.isFile()) { + try { + var p = new java.util.Properties(); + try (var r = Files.newBufferedReader(props.toPath())) { p.load(r); } + String name = p.getProperty("level-name"); + if (name != null && !name.isBlank()) return name.trim(); + } catch (Exception ignored) { + // fall through + } + } + return "world"; + } + + /** Award the chapter advancement to everyone online. Main thread. */ + public void grantAll(int chapter) { + Advancement adv = lookup(chapter); + if (adv == null) return; + for (Player p : plugin.getServer().getOnlinePlayers()) award(p, adv); + } + + /** On join, reconcile a player against the chapters already completed. */ + public void syncPlayer(Player player) { + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Set done = plugin.database().completedChapters(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + for (int ch : done) { + Advancement adv = lookup(ch); + if (adv != null) award(player, adv); + } + }); + }); + } + + private Advancement lookup(int chapter) { + if (chapter < 1 || chapter > CHAPTERS) return null; + return plugin.getServer().getAdvancement( + new NamespacedKey("blockvault", "chapter_" + chapter)); + } + + private void award(Player player, Advancement adv) { + var progress = player.getAdvancementProgress(adv); + if (!progress.isDone()) progress.awardCriteria("granted"); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/chapter/ChapterService.java b/src/main/java/dev/anchorlight/blockvault/chapter/ChapterService.java new file mode 100644 index 0000000..44fddcd --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/chapter/ChapterService.java @@ -0,0 +1,209 @@ +package dev.anchorlight.blockvault.chapter; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.model.TargetEntry; +import org.bukkit.Color; +import org.bukkit.FireworkEffect; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Firework; +import org.bukkit.inventory.meta.FireworkMeta; +import org.bukkit.scheduler.BukkitTask; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Owns which chapters are open. A chapter opens on its scheduled date OR when + * the previous chapter reaches 90%, whichever comes first. Earlier chapters + * never close - only the spotlight (the "current" chapter) advances. + */ +public final class ChapterService { + + private static final double EARLY_OPEN_FRACTION = 0.90; + + private final BlockVault plugin; + private final Set open = Collections.synchronizedSet(new HashSet<>()); + private final Set completed = Collections.synchronizedSet(new HashSet<>()); + private final java.util.concurrent.atomic.AtomicBoolean checking = + new java.util.concurrent.atomic.AtomicBoolean(false); + private BukkitTask task; + + public ChapterService(BlockVault plugin) { + this.plugin = plugin; + } + + public void start() { + // Chapter 1 is always open. + open.add(1); + long everyFiveMinutes = 20L * 60L * 5L; + this.task = plugin.getServer().getScheduler().runTaskTimer( + plugin, this::check, 20L * 20L, everyFiveMinutes); + } + + public void stop() { + if (task != null) task.cancel(); + } + + public boolean isOpen(int chapter) { + return chapter <= 1 || open.contains(chapter); + } + + /** Highest currently-open chapter - the spotlight. */ + public int current() { + int c = 1; + synchronized (open) { + for (int ch : open) c = Math.max(c, ch); + } + return c; + } + + /** Recompute open state and run any pending unlock ceremonies. Safe to call often. */ + public void check() { + // Collapse bursts (e.g. many submits in one tick) into a single pass. + if (!checking.compareAndSet(false, true)) return; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + final List rows; + final Set collected; + final Set doneInDb; + try { + rows = plugin.database().chapters(); + collected = plugin.database().collectedSnapshot(); + doneInDb = plugin.database().completedChapters(); + } finally { + checking.set(false); // DB round-trips done; allow the next pass + } + + plugin.getServer().getScheduler().runTask(plugin, () -> { + // Re-attempt advancement grants for chapters already complete in the + // database. award() is idempotent, and this covers the case where the + // bundled datapack was not yet loaded when the chapter first completed. + for (int ch : doneInDb) { + completed.add(ch); + plugin.advancements().grantAll(ch); + } + + long now = System.currentTimeMillis(); + for (Database.ChapterRow row : rows) { + int ch = row.chapter(); + if (row.openedAt() != null) open.add(ch); + + if (!isOpen(ch) && row.openedAt() == null) { + boolean dateReached = row.opensAt() != null + && now >= row.opensAt().getTime(); + boolean prevNearlyDone = fraction(ch - 1, collected) >= EARLY_OPEN_FRACTION; + if (dateReached || prevNearlyDone) { + unlock(row, prevNearlyDone && !dateReached); + } + } + + // Completion: earlier chapters never close, but 100% is worth marking. + // Requires the chapter to actually have targets - guards against a + // partial manifest reporting every empty chapter as "complete". + if (chapterSize(ch) > 0 && fraction(ch, collected) >= 1.0 && completed.add(ch)) { + onChapterComplete(row); + } + } + }); + }); + } + + private void onChapterComplete(Database.ChapterRow row) { + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, + () -> plugin.database().markChapterComplete(row.chapter(), null)); + String line = "§a§lChapter " + row.chapter() + " — " + row.title() + + " §r§ais complete! Every block on that floor has been given."; + plugin.getServer().broadcastMessage(plugin.prefix() + line); + plugin.getServer().getOnlinePlayers().forEach(p -> + p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.2f)); + plugin.webhook().chapterOpened(row.chapter(), row.title() + " (complete)"); + plugin.displays().refresh(); + plugin.advancements().grantAll(row.chapter()); + plugin.getLogger().info("Chapter " + row.chapter() + " reached 100%."); + } + + private double fraction(int chapter, Set collected) { + if (chapter < 1) return 1.0; // "previous of chapter 1" is trivially done + int total = 0, done = 0; + for (TargetEntry e : plugin.manifest().entries().values()) { + if (e.chapter() != chapter) continue; + total++; + if (collected.contains(e.material())) done++; + } + return total == 0 ? 1.0 : (double) done / total; + } + + private int chapterSize(int chapter) { + int n = 0; + for (TargetEntry e : plugin.manifest().entries().values()) { + if (e.chapter() == chapter) n++; + } + return n; + } + + private void unlock(Database.ChapterRow row, boolean early) { + int chapter = row.chapter(); + if (!open.add(chapter)) return; // someone beat us to it this tick + + // Break the iron-bar seal: one setBlock to air. + int[] seal = plugin.manifest().seal(chapter); + if (seal == null && row.sealX() != null) { + seal = new int[]{row.sealX(), row.sealY(), row.sealZ()}; + } + if (seal != null) { + Location loc = plugin.resolve(seal); + if (loc.getWorld() != null) loc.getBlock().setType(Material.AIR, false); + } + + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, + () -> plugin.database().markChapterOpened(chapter)); + + ceremony(row, early); + plugin.displays().refresh(); + } + + private void ceremony(Database.ChapterRow row, boolean early) { + String line = "§6§lChapter " + row.chapter() + " — " + row.title() + + " §r§7(" + row.room() + ")§r is now open!"; + plugin.getServer().broadcastMessage(plugin.prefix() + line); + if (early) { + plugin.getServer().broadcastMessage(plugin.prefix() + + "§7Unlocked early — the previous floor is nearly complete."); + } + plugin.webhook().chapterOpened(row.chapter(), row.title()); + + plugin.getServer().getOnlinePlayers().forEach(p -> { + p.sendTitle("§6Chapter " + row.chapter(), "§e" + row.title(), 10, 70, 20); + p.playSound(p.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f); + }); + + org.bukkit.boss.BossBar bar = plugin.getServer().createBossBar( + "§6Chapter " + row.chapter() + " — " + row.title() + " is open", + org.bukkit.boss.BarColor.YELLOW, org.bukkit.boss.BarStyle.SOLID); + bar.setProgress(1.0); + plugin.getServer().getOnlinePlayers().forEach(bar::addPlayer); + plugin.getServer().getScheduler().runTaskLater(plugin, bar::removeAll, 200L); + + // Fireworks at the leader panel, if the manifest defines it and the world is up. + int[] leaderHead = plugin.manifest().leader("head"); + Location at = leaderHead == null ? null : plugin.resolve(leaderHead); + if (at != null && at.getWorld() != null) { + for (int i = 0; i < 3; i++) { + Firework fw = at.getWorld().spawn(at.clone().add(0.5, 1, 0.5), Firework.class); + FireworkMeta meta = fw.getFireworkMeta(); + meta.addEffect(FireworkEffect.builder() + .withColor(Color.AQUA, Color.WHITE) + .with(FireworkEffect.Type.BALL_LARGE) + .withFlicker().withTrail().build()); + meta.setPower(1); + fw.setFireworkMeta(meta); + } + } + plugin.getLogger().info("Chapter " + row.chapter() + " opened" + + (early ? " (early, previous floor >= 90%)." : ".")); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/AdminCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/AdminCommand.java new file mode 100644 index 0000000..42338ad --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/AdminCommand.java @@ -0,0 +1,159 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.model.TargetEntry; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.ItemFrame; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** Admin dispatcher: {@code /bvrevoke /bvrepair /bvbackup}. */ +public final class AdminCommand implements CommandExecutor, TabCompleter { + private final BlockVault plugin; + + public AdminCommand(BlockVault plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + String name = command.getName().toLowerCase(); + if (!sender.hasPermission("blockvault." + name.substring(2))) { + plugin.tell(sender, "§cYou don't have permission to use this command."); + return true; + } + switch (name) { + case "bvrevoke" -> revoke(sender, args); + case "bvrepair" -> repair(sender); + case "bvbackup" -> backup(sender); + default -> plugin.tell(sender, "§cUnknown command."); + } + return true; + } + + private void revoke(CommandSender sender, String[] args) { + if (args.length == 0) { + plugin.tell(sender, "§cUsage: /bvrevoke "); + return; + } + String material = args[0].toLowerCase().replace("minecraft:", ""); + TargetEntry entry = plugin.manifest().entry(material); + if (entry == null) { + plugin.tell(sender, "§c'" + material + "' is not part of this collection."); + return; + } + java.util.UUID actor = (sender instanceof org.bukkit.entity.Player p) ? p.getUniqueId() : null; + + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Database.RevokeResult r = plugin.database().revoke(material, actor); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (!r.ok()) { + plugin.tell(sender, "§e" + material + " has no submission to revoke."); + return; + } + Location head = plugin.resolve(entry.head()); + if (head.getWorld() != null) head.getBlock().setType(Material.AIR, false); + String who = plugin.getServer().getOfflinePlayer(r.donor()).getName(); + plugin.tell(sender, "§aRevoked " + material + " from §f" + who + + "§a; refunded " + r.pointsRefunded() + " points."); + }); + }); + } + + private void repair(CommandSender sender) { + World world = plugin.originWorld(); + if (world == null) { + plugin.tell(sender, "§cOrigin world is not loaded."); + return; + } + int frames = 0; + for (TargetEntry e : plugin.manifest().entries().values()) { + Location frameLoc = plugin.resolve(e.frame()); + if (!hasItemFrame(world, frameLoc)) { + world.spawn(frameLoc, ItemFrame.class, f -> { + f.setFacingDirection(e.face(), true); + f.setFixed(true); + f.setInvulnerable(true); + f.setSilent(true); + f.setGlowing("rare".equals(e.rarity())); // highlight rare-tier shelves + }); + frames++; + } + } + // Heads are reconciled by the standard pass. + plugin.tell(sender, "§aRepair: respawned " + frames + " missing frames. " + + "Running head reconciliation…"); + new VaultUtil(plugin).updateVaultState(sender); + } + + private boolean hasItemFrame(World world, Location loc) { + for (var ent : world.getNearbyEntities(loc, 0.5, 0.5, 0.5)) { + if (ent instanceof ItemFrame) return true; + } + return false; + } + + private void backup(CommandSender sender) { + var cfg = plugin.getConfig(); + String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + File dir = new File(plugin.getDataFolder(), "backups"); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + File out = new File(dir, "blockvault-" + stamp + ".sql"); + + List cmd = new ArrayList<>(List.of( + "mysqldump", + "-h", cfg.getString("database.host", "localhost"), + "-P", String.valueOf(cfg.getInt("database.port", 3306)), + "-u", cfg.getString("database.user", "blockvault"), + "--databases", cfg.getString("database.name", "blockvault"))); + + plugin.tell(sender, "§7Starting backup to " + out.getName() + "…"); + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + boolean ok = false; + String detail; + try { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().put("MYSQL_PWD", cfg.getString("database.password", "")); + pb.redirectErrorStream(false); + pb.redirectOutput(out); + Process p = pb.start(); + int code = p.waitFor(); + ok = code == 0 && out.length() > 0; + detail = ok ? (out.length() / 1024) + " KiB" : "mysqldump exit " + code; + } catch (Exception ex) { + detail = ex.getMessage(); + } + final boolean done = ok; + final String msg = detail; + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (done) plugin.tell(sender, "§aBackup complete: " + out.getName() + " (" + msg + ")"); + else plugin.tell(sender, "§cBackup failed: " + msg + + " §7(is mysqldump on the server's PATH?)"); + }); + }); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!command.getName().equalsIgnoreCase("bvrevoke") || args.length != 1) return List.of(); + String prefix = args[0].toLowerCase(); + List out = new ArrayList<>(); + for (String m : plugin.database().collectedSnapshot()) { + if (m.startsWith(prefix)) out.add(m); + } + return out; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/LeaderboardCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/LeaderboardCommand.java new file mode 100644 index 0000000..73f87a7 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/LeaderboardCommand.java @@ -0,0 +1,66 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; + +/** Top 10 contributors, plus the viewer's own rank if they are outside it. */ +public class LeaderboardCommand implements CommandExecutor { + + private final BlockVault plugin; + private final VaultUtil vaultUtil; + + public LeaderboardCommand(BlockVault plugin) { + this.plugin = plugin; + this.vaultUtil = new VaultUtil(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("blockvault.leaderboard")) { + plugin.tell(sender, "§cYou don't have permission to use this command!"); + return true; + } + if (!vaultUtil.hasStarted()) { + plugin.tell(sender, "§cThe vault has not been opened yet."); + return true; + } + + boolean monthly = args.length > 0 && args[0].equalsIgnoreCase("month"); + String ym = java.time.YearMonth.now().toString(); // YYYY-MM + + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + List top = monthly + ? plugin.database().monthlyTop(ym, 10) + : plugin.database().topContributors(10); + int ownRank = (!monthly && sender instanceof Player p) + ? plugin.database().rankOf(p.getUniqueId()) : -1; + + plugin.getServer().getScheduler().runTask(plugin, () -> { + plugin.tell(sender, monthly ? "§aTop contributors — " + ym : "§aTop contributors — all time"); + if (top.isEmpty()) { + sender.sendMessage("§7 Nobody has donated a block yet."); + return; + } + int i = 1; + boolean sawViewer = false; + for (Database.LeaderRow r : top) { + if (sender instanceof Player p && p.getUniqueId().equals(r.uuid())) sawViewer = true; + sender.sendMessage(String.format("§e%2d. §f%-16s §7%d pts, %d blocks", + i++, r.name(), r.points(), r.blocks())); + } + if (!sawViewer && ownRank > 0) { + sender.sendMessage("§8 …"); + sender.sendMessage("§e" + ownRank + ". §f" + sender.getName() + " §7(you)"); + } + }); + }); + return true; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/ProgressCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/ProgressCommand.java new file mode 100644 index 0000000..dae7611 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/ProgressCommand.java @@ -0,0 +1,62 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.model.TargetEntry; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +import java.util.Set; + +/** Two bars: the current chapter, and the whole season. */ +public class ProgressCommand implements CommandExecutor { + private final BlockVault plugin; + private final VaultUtil vaultUtil; + + public ProgressCommand(BlockVault plugin) { + this.plugin = plugin; + this.vaultUtil = new VaultUtil(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("blockvault.progress")) { + plugin.tell(sender, "§cYou don't have permission to use this command!"); + return true; + } + if (!vaultUtil.hasStarted()) { + plugin.tell(sender, "§cThe vault has not been opened yet."); + return true; + } + + Set collected = plugin.database().collectedSnapshot(); + int chapter = plugin.chapters().current(); + + int chapTotal = 0, chapDone = 0, allTotal = 0, allDone = 0; + for (TargetEntry e : plugin.manifest().entries().values()) { + boolean done = collected.contains(e.material()); + allTotal++; + if (done) allDone++; + if (e.chapter() == chapter) { + chapTotal++; + if (done) chapDone++; + } + } + + plugin.tell(sender, "§aVault progress"); + sender.sendMessage(bar("Chapter " + chapter, chapDone, chapTotal)); + sender.sendMessage(bar("Overall", allDone, allTotal)); + return true; + } + + private static String bar(String labelText, int done, int total) { + int len = 30; + int filled = total == 0 ? 0 : (int) Math.round((double) done / total * len); + StringBuilder b = new StringBuilder("§f").append(labelText).append(" §8["); + for (int i = 0; i < len; i++) b.append(i < filled ? "§a|" : "§7|"); + int pct = total == 0 ? 0 : (int) Math.round((double) done / total * 100); + b.append("§8] §e").append(done).append('/').append(total).append(" §7(").append(pct).append("%)"); + return b.toString(); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/QueryCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/QueryCommand.java new file mode 100644 index 0000000..f4d9b81 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/QueryCommand.java @@ -0,0 +1,224 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.model.TargetEntry; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Read-only player commands, one dispatcher for all of them: + * {@code /bvfind /bvinfo /bvmissing /bvcheck /bvme /bvhistory /bvedition /bvreload}. + */ +public final class QueryCommand implements CommandExecutor, TabCompleter { + + private static final int PAGE = 10; + private static final DateTimeFormatter DATE = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final BlockVault plugin; + + public QueryCommand(BlockVault plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + String name = command.getName().toLowerCase(); + if (!sender.hasPermission("blockvault." + name.substring(2))) { + plugin.tell(sender, "§cYou don't have permission to use this command."); + return true; + } + switch (name) { + case "bvfind" -> find(sender, args); + case "bvinfo" -> info(sender, args); + case "bvmissing" -> missing(sender, args); + case "bvcheck" -> check(sender); + case "bvme" -> me(sender); + case "bvhistory" -> history(sender, args); + case "bvedition" -> edition(sender); + case "bvreload" -> reload(sender); + default -> plugin.tell(sender, "§cUnknown command."); + } + return true; + } + + private TargetEntry require(CommandSender sender, String[] args) { + if (args.length == 0) { + plugin.tell(sender, "§cUsage: / "); + return null; + } + String key = args[0].toLowerCase().replace("minecraft:", ""); + TargetEntry e = plugin.manifest().entry(key); + if (e == null) plugin.tell(sender, "§c'" + key + "' is not part of this collection."); + return e; + } + + private String pretty(TargetEntry e) { + Material m = Material.matchMaterial(e.material()); + return m != null ? VaultUtil.formatMaterialName(m) : e.material(); + } + + private void find(CommandSender sender, String[] args) { + TargetEntry e = require(sender, args); + if (e == null) return; + int[] s = e.sign(); + org.bukkit.Location loc = plugin.resolve(e.sign()); + String room = roomFor(e.chapter()); + plugin.tell(sender, "§6" + pretty(e) + + " §7— Level " + e.chapter() + ", " + room); + plugin.tell(sender, "§7 shelf at §f" + loc.getBlockX() + " " + loc.getBlockY() + " " + + loc.getBlockZ() + " §7(relative " + s[0] + " " + s[1] + " " + s[2] + ", facing " + e.facing() + ")"); + } + + private void info(CommandSender sender, String[] args) { + TargetEntry e = require(sender, args); + if (e == null) return; + int pts = plugin.getConfig().getInt("points." + e.rarity(), 1); + boolean have = plugin.database().isCollected(e.material()); + plugin.tell(sender, "§6" + pretty(e)); + plugin.tell(sender, "§7 rarity §f" + e.rarity() + " §7· points §f" + pts + + " §7· chapter §f" + e.chapter() + " §7· section §f" + e.section()); + plugin.tell(sender, have ? "§a already in the vault" : "§e still needed"); + } + + private void missing(CommandSender sender, String[] args) { + int chapter = args.length > 0 ? parseInt(args[0], plugin.chapters().current()) + : plugin.chapters().current(); + int page = args.length > 1 ? Math.max(1, parseInt(args[1], 1)) : 1; + + List out = new ArrayList<>(); + for (TargetEntry e : plugin.manifest().entries().values()) { + if (e.chapter() == chapter && !plugin.database().isCollected(e.material())) { + out.add(e.material()); + } + } + if (out.isEmpty()) { + plugin.tell(sender, "§aChapter " + chapter + " is complete!"); + return; + } + int pages = (out.size() + PAGE - 1) / PAGE; + page = Math.min(page, pages); + plugin.tell(sender, "§6Chapter " + chapter + " — " + out.size() + + " outstanding §7(page " + page + "/" + pages + ")"); + for (int i = (page - 1) * PAGE; i < Math.min(out.size(), page * PAGE); i++) { + sender.sendMessage("§7 · §f" + out.get(i)); + } + if (page < pages) plugin.tell(sender, "§7/bvmissing " + chapter + " " + (page + 1) + " for more"); + } + + private void check(CommandSender sender) { + if (!(sender instanceof Player player)) { + plugin.tell(sender, "§cPlayers only."); + return; + } + List needed = new ArrayList<>(); + for (ItemStack it : player.getInventory().getContents()) { + if (it == null || it.getType() == Material.AIR) continue; + String key = it.getType().getKey().getKey(); + TargetEntry e = plugin.manifest().entry(key); + if (e != null && !plugin.database().isCollected(key) && !needed.contains(key)) { + needed.add(key); + } + } + if (needed.isEmpty()) { + plugin.tell(sender, "§7Nothing in your inventory is currently needed."); + return; + } + plugin.tell(sender, "§aThe vault needs " + needed.size() + " block(s) you're carrying:"); + needed.forEach(n -> sender.sendMessage("§7 · §f" + n + " §8— /bvsubmit")); + } + + private void me(CommandSender sender) { + if (!(sender instanceof Player player)) { + plugin.tell(sender, "§cPlayers only."); + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Database.MyStats s = plugin.database().myStats(player.getUniqueId()); + plugin.getServer().getScheduler().runTask(plugin, () -> { + plugin.tell(player, "§6Your vault record"); + plugin.tell(player, "§7 blocks donated §f" + s.blocks() + + " §7· points §f" + s.points() + + " §7· rank §f" + (s.rank() > 0 ? "#" + s.rank() : "—")); + plugin.tell(player, "§7 every donation is a first — one of each block is allowed."); + }); + }); + } + + private void history(CommandSender sender, String[] args) { + TargetEntry e = require(sender, args); + if (e == null) return; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Database.SubmissionRow row = plugin.database().submission(e.material()); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (row == null) { + plugin.tell(sender, "§e" + e.material() + " has not been donated yet."); + return; + } + String when = row.submittedAt().toLocalDateTime().format(DATE); + plugin.tell(sender, "§6" + e.material() + " §7was donated by §f" + row.name() + + " §7on §f" + when + " §7(+" + row.points() + " pts)"); + }); + }); + } + + private void edition(CommandSender sender) { + plugin.tell(sender, "§6Edition §f" + plugin.database().edition() + + " §7· manifest version §f" + plugin.manifest().version() + + " §7· data version §f" + plugin.manifest().dataVersion()); + plugin.tell(sender, "§7 " + plugin.manifest().entries().size() + + " blocks, frozen for the season. New blocks require a new edition."); + } + + private void reload(CommandSender sender) { + plugin.reloadConfig(); + plugin.invalidateRegion(); + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, + () -> plugin.database().resyncChapterDates()); + plugin.tell(sender, "§aConfiguration reloaded."); + } + + // --- helpers ------------------------------------------------------- + + private String roomFor(int chapter) { + return switch (chapter) { + case 1 -> "The Undercroft"; + case 2 -> "The Conservatory"; + case 3 -> "The Deep"; + case 4 -> "The Gallery"; + case 5 -> "The Forge"; + case 6 -> "The Observatory"; + default -> "?"; + }; + } + + private static int parseInt(String s, int fallback) { + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return fallback; } + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + String name = command.getName().toLowerCase(); + boolean blockArg = (name.equals("bvfind") || name.equals("bvinfo") || name.equals("bvhistory")) + && args.length == 1; + if (!blockArg) return List.of(); + String prefix = args[0].toLowerCase(); + List out = new ArrayList<>(); + for (String m : plugin.manifest().entries().keySet()) { + if (m.startsWith(prefix)) out.add(m); + if (out.size() >= 50) break; + } + return out; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/StartCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/StartCommand.java new file mode 100644 index 0000000..53ff777 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/StartCommand.java @@ -0,0 +1,50 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.util.FileUtil; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +/** {@code /bvstart} opens the event; {@code /bvstart stop} closes it again. */ +public class StartCommand implements CommandExecutor { + + private final BlockVault plugin; + private final VaultUtil vaultUtil; + private final FileUtil fileUtil; + + public StartCommand(BlockVault plugin) { + this.plugin = plugin; + this.vaultUtil = new VaultUtil(plugin); + this.fileUtil = new FileUtil(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("blockvault.start")) { + plugin.tell(sender, "§cYou don't have permission to use this command!"); + return true; + } + + boolean stopping = args.length > 0 && args[0].equalsIgnoreCase("stop"); + + if (stopping) { + if (!vaultUtil.hasStarted()) { + plugin.tell(sender, "§cThe vault is not open."); + return true; + } + fileUtil.updateConfigValue("vault.started", false); + plugin.tell(sender, "§eThe vault is now closed. Submissions are paused."); + return true; + } + + if (vaultUtil.hasStarted()) { + plugin.tell(sender, "§cThe vault is already open."); + return true; + } + fileUtil.updateConfigValue("vault.started", true); + plugin.tell(sender, "§aThe vault is open! Players may now submit blocks."); + return true; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/SubmitCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/SubmitCommand.java new file mode 100644 index 0000000..c39bf6a --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/SubmitCommand.java @@ -0,0 +1,141 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import dev.anchorlight.blockvault.model.TargetEntry; +import dev.anchorlight.blockvault.util.HeadUtil; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Submit the held block. Order is always: write -> confirm commit -> consume. + * If the database is unreachable the item stays in the player's hand. + */ +public class SubmitCommand implements CommandExecutor { + private final BlockVault plugin; + private final VaultUtil vaultUtil; + + public SubmitCommand(BlockVault plugin) { + this.plugin = plugin; + this.vaultUtil = new VaultUtil(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.tell(sender, "§cOnly players can use this command!"); + return true; + } + if (!player.hasPermission("blockvault.submit")) { + plugin.tell(player, "§cYou don't have permission to submit blocks."); + return true; + } + if (!vaultUtil.hasStarted()) { + plugin.tell(player, "§cThe vault has not been opened yet."); + return true; + } + if (player.getGameMode() == GameMode.CREATIVE) { + plugin.tell(player, "§cSubmissions are not allowed in creative mode."); + return true; + } + var region = plugin.region(); + if (region == null || !region.contains(player.getLocation())) { + plugin.tell(player, "§cYou must be inside the vault to submit a block."); + return true; + } + + ItemStack held = player.getInventory().getItemInMainHand(); + if (held.getType() == Material.AIR || held.getAmount() < 1) { + plugin.tell(player, "§cYou are not holding anything."); + return true; + } + + Material type = held.getType(); + String material = type.getKey().getKey(); // lowercase registry id + String pretty = VaultUtil.formatMaterialName(type); + + TargetEntry entry = plugin.manifest().entry(material); + if (entry == null) { + plugin.tell(player, "§c" + pretty + " is not part of this collection."); + return true; + } + + Database db = plugin.database(); + if (db.isCollected(material)) { + plugin.tell(player, "§e" + pretty + " has already been donated."); + return true; + } + + int points = plugin.getConfig().getInt("points." + entry.rarity(), 1); + org.bukkit.profile.PlayerProfile profile = player.getPlayerProfile(); + String profileJson = HeadUtil.toJson(profile); + + plugin.tell(player, "§7Submitting " + pretty + "…"); + + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Database.SubmitOutcome outcome = + db.submit(material, player.getUniqueId(), player.getName(), points, profileJson); + + plugin.getServer().getScheduler().runTask(plugin, () -> { + switch (outcome) { + case ALREADY_TAKEN -> plugin.tell(player, + "§e" + pretty + " was donated by someone else first. Your block is untouched."); + case DB_ERROR -> plugin.tell(player, + "§cCould not record that right now - your block is still in your hand. Please retry."); + case OK -> { + // Write committed. Only now is it safe to consume the item. + consumeOne(player, type); + placeHead(entry, profile); + plugin.tell(player, "§aDonated " + pretty + "! §7(+" + points + + (points == 1 ? " point)" : " points)")); + celebrate(player, entry.rarity()); + if ("rare".equals(entry.rarity())) { + plugin.webhook().rareSubmission(material, player.getName(), points); + } + plugin.displays().refresh(); + plugin.chapters().check(); + } + } + }); + }); + return true; + } + + private void consumeOne(Player player, Material expected) { + // Remove one from anywhere in the inventory, not just the main hand: + // the write has committed and the block must be consumed (brief 1.1), + // even if the player shuffled it between the command and this tick. + var leftover = player.getInventory().removeItem(new ItemStack(expected, 1)); + if (!leftover.isEmpty()) { + plugin.getLogger().warning(player.getName() + " no longer had " + expected + + " when it was consumed; recorded but not removed from inventory."); + } + } + + private void placeHead(TargetEntry entry, org.bukkit.profile.PlayerProfile profile) { + // Submissions for a not-yet-open chapter are credited, but the head + // stays hidden until the floor opens (the reconcile pass adds it then). + if (!plugin.chapters().isOpen(entry.chapter())) return; + Location loc = plugin.resolve(entry.head()); + if (loc.getWorld() == null) return; + HeadUtil.placeHead(loc, entry.face(), profile); + } + + private void celebrate(Player player, String rarity) { + Sound sound = switch (rarity) { + case "rare" -> Sound.UI_TOAST_CHALLENGE_COMPLETE; + case "uncommon" -> Sound.ENTITY_PLAYER_LEVELUP; + default -> Sound.ENTITY_EXPERIENCE_ORB_PICKUP; + }; + player.playSound(player.getLocation(), sound, 1f, 1f); + } + +} diff --git a/src/main/java/dev/anchorlight/blockvault/commands/UpdateStateCommand.java b/src/main/java/dev/anchorlight/blockvault/commands/UpdateStateCommand.java new file mode 100644 index 0000000..8ef27da --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/commands/UpdateStateCommand.java @@ -0,0 +1,27 @@ +package dev.anchorlight.blockvault.commands; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.util.VaultUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +public class UpdateStateCommand implements CommandExecutor { + + private final VaultUtil vaultUtil; + + public UpdateStateCommand(BlockVault plugin) { + this.vaultUtil = new VaultUtil(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("blockvault.updatestate")) { + sender.sendMessage("§cYou don't have permission to use this command!"); + return true; + } + // Console-runnable by design. + vaultUtil.updateVaultState(sender); + return true; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/db/Database.java b/src/main/java/dev/anchorlight/blockvault/db/Database.java new file mode 100644 index 0000000..dc511fc --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/db/Database.java @@ -0,0 +1,629 @@ +package dev.anchorlight.blockvault.db; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.model.Manifest; +import dev.anchorlight.blockvault.model.TargetEntry; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.ByteBuffer; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Owns the connection pool and every SQL statement. All callers run the + * blocking methods here on the async scheduler - the main thread never enters. + * + *

The set of already-collected materials is held in memory as the read path + * and updated only after a write commits. + */ +public final class Database { + + /** MySQL error code for a duplicate primary/unique key. */ + private static final int ER_DUP_ENTRY = 1062; + + private final BlockVault plugin; + private final String edition; + private final HikariDataSource ds; + private final Set collected = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + public enum SubmitOutcome { OK, ALREADY_TAKEN, DB_ERROR } + + public Database(BlockVault plugin) { + this.plugin = plugin; + this.edition = plugin.getConfig().getString("edition", "26.2"); + + var cfg = plugin.getConfig(); + String host = cfg.getString("database.host", "localhost"); + int port = cfg.getInt("database.port", 3306); + String name = cfg.getString("database.name", "blockvault"); + + HikariConfig hc = new HikariConfig(); + hc.setPoolName("BlockVault"); + hc.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + name + + "?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true"); + hc.setUsername(cfg.getString("database.user", "blockvault")); + hc.setPassword(cfg.getString("database.password", "")); + hc.setMaximumPoolSize(cfg.getInt("database.pool.maximum-pool-size", 6)); + hc.setMinimumIdle(cfg.getInt("database.pool.minimum-idle", 1)); + hc.setConnectionTimeout(cfg.getLong("database.pool.connection-timeout-ms", 8000)); + hc.setMaxLifetime(cfg.getLong("database.pool.max-lifetime-ms", 1_740_000)); + hc.setInitializationFailTimeout(-1); // don't crash enable; report on first use + + this.ds = new HikariDataSource(hc); + } + + public String edition() { + return edition; + } + + /** Apply the schema, seed the target list and chapters, warm the collected set. */ + public void bootstrap(Manifest manifest) throws SQLException { + try (Connection c = ds.getConnection()) { + runSchema(c); + seedTargets(c, manifest); + seedChapters(c, manifest); + syncChapterDates(c); + warmCollected(c); + } + plugin.getLogger().info("Database ready: " + collected.size() + + " blocks already collected for edition " + edition + "."); + } + + private void runSchema(Connection c) throws SQLException { + StringBuilder sql = new StringBuilder(); + try (InputStream in = plugin.getResource("blockvault_schema.sql")) { + if (in == null) throw new SQLException("blockvault_schema.sql missing from jar"); + sql.append(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } catch (java.io.IOException e) { + throw new SQLException("cannot read blockvault_schema.sql", e); + } + try (Statement st = c.createStatement()) { + for (String raw : sql.toString().split(";\\s*\\r?\\n")) { + String stmt = stripComments(raw).trim(); + if (!stmt.isEmpty()) st.execute(stmt); + } + } + } + + private static String stripComments(String block) { + StringBuilder out = new StringBuilder(); + for (String line : block.split("\\r?\\n")) { + if (line.stripLeading().startsWith("--")) continue; + out.append(line).append('\n'); + } + return out.toString(); + } + + private void seedTargets(Connection c, Manifest manifest) throws SQLException { + if (editionRowCount(c, "bv_target") > 0) return; + String q = "INSERT INTO bv_target (material,edition,chapter,rarity,section," + + "sign_x,sign_y,sign_z,frame_x,frame_y,frame_z,head_x,head_y,head_z,facing) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + try (PreparedStatement ps = c.prepareStatement(q)) { + for (TargetEntry e : manifest.entries().values()) { + ps.setString(1, e.material()); + ps.setString(2, edition); + ps.setInt(3, e.chapter()); + ps.setString(4, e.rarity()); + ps.setString(5, e.section()); + ps.setInt(6, e.sign()[0]); ps.setInt(7, e.sign()[1]); ps.setInt(8, e.sign()[2]); + ps.setInt(9, e.frame()[0]); ps.setInt(10, e.frame()[1]); ps.setInt(11, e.frame()[2]); + ps.setInt(12, e.head()[0]); ps.setInt(13, e.head()[1]); ps.setInt(14, e.head()[2]); + ps.setString(15, e.facing()); + ps.addBatch(); + } + ps.executeBatch(); + } + plugin.getLogger().info("Seeded " + manifest.entries().size() + + " target rows for edition " + edition + "."); + } + + private void seedChapters(Connection c, Manifest manifest) throws SQLException { + if (editionRowCount(c, "bv_chapter") > 0) return; + String[][] meta = { + {"1", "Foundations", "The Undercroft"}, + {"2", "Green & Growing", "The Conservatory"}, + {"3", "Into the Deep", "The Deep"}, + {"4", "Every Colour", "The Gallery"}, + {"5", "The Nether", "The Forge"}, + {"6", "The End", "The Observatory"}, + }; + String q = "INSERT INTO bv_chapter (chapter,edition,title,room,seal_x,seal_y,seal_z) " + + "VALUES (?,?,?,?,?,?,?)"; + try (PreparedStatement ps = c.prepareStatement(q)) { + for (String[] m : meta) { + int ch = Integer.parseInt(m[0]); + int[] seal = manifest.seal(ch); // null for chapter 1 + ps.setInt(1, ch); + ps.setString(2, edition); + ps.setString(3, m[1]); + ps.setString(4, m[2]); + if (seal == null) { + ps.setNull(5, java.sql.Types.INTEGER); + ps.setNull(6, java.sql.Types.INTEGER); + ps.setNull(7, java.sql.Types.INTEGER); + } else { + ps.setInt(5, seal[0]); ps.setInt(6, seal[1]); ps.setInt(7, seal[2]); + } + ps.addBatch(); + } + ps.executeBatch(); + } + } + + /** + * Push the configured open dates onto any chapter that has not opened yet. + * Runs every start so an admin can move a date mid-season by editing config. + */ + /** Public re-sync for /bvreload. Blocking - call async. */ + public void resyncChapterDates() { + try (Connection c = ds.getConnection()) { + syncChapterDates(c); + } catch (SQLException e) { + plugin.getLogger().severe("Chapter date re-sync failed: " + e.getMessage()); + } + } + + private void syncChapterDates(Connection c) throws SQLException { + var section = plugin.getConfig().getConfigurationSection("chapters"); + if (section == null) return; + try (PreparedStatement ps = c.prepareStatement( + "UPDATE bv_chapter SET opens_at = ? " + + "WHERE chapter = ? AND edition = ? AND opened_at IS NULL")) { + for (String key : section.getKeys(false)) { + String raw = section.getString(key + ".opens-at"); + if (raw == null || raw.isBlank()) continue; + java.sql.Timestamp ts; + try { + ts = java.sql.Timestamp.valueOf( + java.time.LocalDateTime.parse(raw)); + } catch (Exception e) { + plugin.getLogger().warning("chapters." + key + + ".opens-at is not a valid ISO date-time: " + raw); + continue; + } + ps.setTimestamp(1, ts); + ps.setInt(2, Integer.parseInt(key)); + ps.setString(3, edition); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private void warmCollected(Connection c) throws SQLException { + collected.clear(); + try (PreparedStatement ps = c.prepareStatement( + "SELECT material FROM bv_submission WHERE edition = ?")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) collected.add(rs.getString(1)); + } + } + } + + /** Row count for the current edition. Table must have an {@code edition} column. */ + private int editionRowCount(Connection c, String table) throws SQLException { + try (PreparedStatement ps = c.prepareStatement( + "SELECT COUNT(*) FROM " + table + " WHERE edition = ?")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt(1) : 0; + } + } + } + + // ----------------------------------------------------------------- reads + + /** In-memory read path. Never hits the database. */ + public boolean isCollected(String material) { + return collected.contains(material); + } + + public Set collectedSnapshot() { + return new HashSet<>(collected); + } + + public int collectedCount() { + return collected.size(); + } + + /** Every material recorded in bv_target for this edition. Blocking. */ + public java.util.Set targetMaterials() { + java.util.Set out = new java.util.HashSet<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT material FROM bv_target WHERE edition = ?")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(rs.getString(1)); + } + } catch (SQLException e) { + plugin.getLogger().severe("Target material query failed: " + e.getMessage()); + } + return out; + } + + /** material -> cached profile JSON (may be null) for this edition. Blocking. */ + public java.util.Map allProfiles() { + java.util.Map out = new java.util.HashMap<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT material, profile FROM bv_submission WHERE edition = ?")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.put(rs.getString(1), rs.getString(2)); + } + } catch (SQLException e) { + plugin.getLogger().severe("Could not load cached profiles: " + e.getMessage()); + } + return out; + } + + // ----------------------------------------------------------------- writes + + /** + * Record a submission. Blocking - call on the async scheduler. + * The item must NOT be consumed until this returns {@link SubmitOutcome#OK}. + * + * @param profileJson cached skin profile, or null + */ + public SubmitOutcome submit(String material, UUID uuid, String name, + int points, String profileJson) { + byte[] id = toBytes(uuid); + try (Connection c = ds.getConnection()) { + c.setAutoCommit(false); + try { + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO bv_contributor (uuid,last_name) VALUES (?,?) " + + "ON DUPLICATE KEY UPDATE last_name=VALUES(last_name), " + + "last_seen=CURRENT_TIMESTAMP")) { + ps.setBytes(1, id); + ps.setString(2, name); + ps.executeUpdate(); + } + + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO bv_submission (material,edition,uuid,points,profile) " + + "VALUES (?,?,?,?,?)")) { + ps.setString(1, material); + ps.setString(2, edition); + ps.setBytes(3, id); + ps.setInt(4, points); + if (profileJson == null) ps.setNull(5, java.sql.Types.VARCHAR); + else ps.setString(5, profileJson); + ps.executeUpdate(); + } catch (SQLException dup) { + if (dup.getErrorCode() == ER_DUP_ENTRY) { + c.rollback(); + collected.add(material); // reconcile the memory cache + return SubmitOutcome.ALREADY_TAKEN; + } + throw dup; + } + + try (PreparedStatement ps = c.prepareStatement( + "UPDATE bv_contributor SET points = points + ?, " + + "blocks_given = blocks_given + 1 WHERE uuid = ?")) { + ps.setInt(1, points); + ps.setBytes(2, id); + ps.executeUpdate(); + } + + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO bv_audit (actor,action,material,detail) " + + "VALUES (?,?,?,JSON_OBJECT('points',?))")) { + ps.setBytes(1, id); + ps.setString(2, "submit"); + ps.setString(3, material); + ps.setInt(4, points); + ps.executeUpdate(); + } + + c.commit(); + collected.add(material); + return SubmitOutcome.OK; + } catch (SQLException e) { + c.rollback(); + throw e; + } + } catch (SQLException e) { + plugin.getLogger().severe("Submission write failed for " + material + + " by " + name + ": " + e.getMessage()); + return SubmitOutcome.DB_ERROR; + } + } + + // ----------------------------------------------------------------- chapters + + public record ChapterRow(int chapter, String title, String room, + Integer sealX, Integer sealY, Integer sealZ, + java.sql.Timestamp opensAt, java.sql.Timestamp openedAt) {} + + /** All chapter rows for this edition, ordered. Blocking. */ + public java.util.List chapters() { + java.util.List rows = new java.util.ArrayList<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT chapter,title,room,seal_x,seal_y,seal_z,opens_at,opened_at " + + "FROM bv_chapter WHERE edition = ? ORDER BY chapter")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + rows.add(new ChapterRow( + rs.getInt("chapter"), rs.getString("title"), rs.getString("room"), + (Integer) rs.getObject("seal_x"), + (Integer) rs.getObject("seal_y"), + (Integer) rs.getObject("seal_z"), + rs.getTimestamp("opens_at"), rs.getTimestamp("opened_at"))); + } + } + } catch (SQLException e) { + plugin.getLogger().severe("Chapter query failed: " + e.getMessage()); + } + return rows; + } + + /** Stamp a chapter complete (only if not already). Blocking. Returns true if it changed. */ + public boolean markChapterComplete(int chapter, UUID by) { + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "UPDATE bv_chapter SET completed_at = CURRENT_TIMESTAMP, completed_by = ? " + + "WHERE chapter = ? AND edition = ? AND completed_at IS NULL")) { + if (by == null) ps.setNull(1, java.sql.Types.BINARY); + else ps.setBytes(1, toBytes(by)); + ps.setInt(2, chapter); + ps.setString(3, edition); + boolean changed = ps.executeUpdate() > 0; + if (changed) audit(by, "chapter_complete", null, "{\"chapter\":" + chapter + "}"); + return changed; + } catch (SQLException e) { + plugin.getLogger().severe("Could not mark chapter " + chapter + " complete: " + e.getMessage()); + return false; + } + } + + /** Chapters with a completed_at stamp for this edition. Blocking. */ + public java.util.Set completedChapters() { + java.util.Set out = new java.util.HashSet<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT chapter FROM bv_chapter WHERE edition = ? AND completed_at IS NOT NULL")) { + ps.setString(1, edition); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(rs.getInt(1)); + } + } catch (SQLException e) { + plugin.getLogger().severe("Completed-chapter query failed: " + e.getMessage()); + } + return out; + } + + /** Top contributors for a given calendar month (YYYY-MM). Blocking. */ + public java.util.List monthlyTop(String yearMonth, int limit) { + java.util.List rows = new java.util.ArrayList<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT c.uuid, c.last_name, SUM(s.points) AS pts, COUNT(*) AS blocks " + + "FROM bv_submission s JOIN bv_contributor c ON c.uuid = s.uuid " + + "WHERE s.edition = ? AND DATE_FORMAT(s.submitted_at, '%Y-%m') = ? " + + "GROUP BY c.uuid, c.last_name ORDER BY pts DESC, blocks DESC LIMIT ?")) { + ps.setString(1, edition); + ps.setString(2, yearMonth); + ps.setInt(3, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + rows.add(new LeaderRow(fromBytes(rs.getBytes(1)), rs.getString(2), + rs.getLong(3), rs.getLong(4))); + } + } + } catch (SQLException e) { + plugin.getLogger().severe("Monthly leaderboard query failed: " + e.getMessage()); + } + return rows; + } + + /** Stamp a chapter open (only if not already). Blocking. Returns true if it changed. */ + public boolean markChapterOpened(int chapter) { + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "UPDATE bv_chapter SET opened_at = CURRENT_TIMESTAMP " + + "WHERE chapter = ? AND edition = ? AND opened_at IS NULL")) { + ps.setInt(1, chapter); + ps.setString(2, edition); + boolean changed = ps.executeUpdate() > 0; + if (changed) audit(null, "chapter_open", null, "{\"chapter\":" + chapter + "}"); + return changed; + } catch (SQLException e) { + plugin.getLogger().severe("Could not mark chapter " + chapter + " open: " + e.getMessage()); + return false; + } + } + + /** Append an audit row. Blocking; {@code detailJson} must be valid JSON or null. */ + public void audit(UUID actor, String action, String material, String detailJson) { + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "INSERT INTO bv_audit (actor,action,material,detail) VALUES (?,?,?,?)")) { + if (actor == null) ps.setNull(1, java.sql.Types.BINARY); + else ps.setBytes(1, toBytes(actor)); + ps.setString(2, action); + ps.setString(3, material); + ps.setString(4, detailJson); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("Audit write failed (" + action + "): " + e.getMessage()); + } + } + + // ----------------------------------------------------------------- lookups + + public record SubmissionRow(String material, UUID uuid, String name, + int points, java.sql.Timestamp submittedAt) {} + + /** Who donated {@code material} and when, or null if still outstanding. Blocking. */ + public SubmissionRow submission(String material) { + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT s.material, s.uuid, c.last_name, s.points, s.submitted_at " + + "FROM bv_submission s JOIN bv_contributor c ON c.uuid = s.uuid " + + "WHERE s.material = ? AND s.edition = ?")) { + ps.setString(1, material); + ps.setString(2, edition); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new SubmissionRow(rs.getString(1), fromBytes(rs.getBytes(2)), + rs.getString(3), rs.getInt(4), rs.getTimestamp(5)); + } + } catch (SQLException e) { + plugin.getLogger().severe("Submission lookup failed: " + e.getMessage()); + return null; + } + } + + public record MyStats(long points, long blocks, int rank) {} + + /** Personal totals for {@code uuid}. Blocking. */ + public MyStats myStats(UUID uuid) { + long points = 0, blocks = 0; + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT points, blocks_given FROM bv_contributor WHERE uuid = ?")) { + ps.setBytes(1, toBytes(uuid)); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { points = rs.getLong(1); blocks = rs.getLong(2); } + } + } catch (SQLException e) { + plugin.getLogger().severe("Stats query failed: " + e.getMessage()); + } + return new MyStats(points, blocks, rankOf(uuid)); + } + + // ----------------------------------------------------------------- admin + + public record RevokeResult(boolean ok, UUID donor, int pointsRefunded) {} + + /** Reverse a submission: delete it, refund the donor, audit. Blocking. */ + public RevokeResult revoke(String material, UUID actor) { + try (Connection c = ds.getConnection()) { + c.setAutoCommit(false); + try { + UUID donor; + int points; + try (PreparedStatement ps = c.prepareStatement( + "SELECT uuid, points FROM bv_submission WHERE material = ? AND edition = ?")) { + ps.setString(1, material); + ps.setString(2, edition); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { c.rollback(); return new RevokeResult(false, null, 0); } + donor = fromBytes(rs.getBytes(1)); + points = rs.getInt(2); + } + } + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM bv_submission WHERE material = ? AND edition = ?")) { + ps.setString(1, material); + ps.setString(2, edition); + ps.executeUpdate(); + } + try (PreparedStatement ps = c.prepareStatement( + "UPDATE bv_contributor SET points = GREATEST(0, points - ?), " + + "blocks_given = GREATEST(0, blocks_given - 1) WHERE uuid = ?")) { + ps.setInt(1, points); + ps.setBytes(2, toBytes(donor)); + ps.executeUpdate(); + } + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO bv_audit (actor,action,material,detail) VALUES (?,?,?,?)")) { + ps.setBytes(1, toBytes(actor)); + ps.setString(2, "revoke"); + ps.setString(3, material); + ps.setString(4, "{\"points\":" + points + "}"); + ps.executeUpdate(); + } + c.commit(); + collected.remove(material); + return new RevokeResult(true, donor, points); + } catch (SQLException e) { + c.rollback(); + throw e; + } + } catch (SQLException e) { + plugin.getLogger().severe("Revoke failed for " + material + ": " + e.getMessage()); + return new RevokeResult(false, null, 0); + } + } + + public record LeaderRow(UUID uuid, String name, long points, long blocks) {} + + /** Top {@code limit} contributors, then the viewer's own row if outside it. Blocking. */ + public java.util.List topContributors(int limit) { + java.util.List rows = new java.util.ArrayList<>(); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement( + "SELECT uuid, last_name, points, blocks FROM bv_leaderboard LIMIT ?")) { + ps.setInt(1, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + rows.add(new LeaderRow(fromBytes(rs.getBytes(1)), rs.getString(2), + rs.getLong(3), rs.getLong(4))); + } + } + } catch (SQLException e) { + plugin.getLogger().severe("Leaderboard query failed: " + e.getMessage()); + } + return rows; + } + + /** 1-based rank of {@code uuid} on the leaderboard, or -1 if they have no row. Blocking. */ + public int rankOf(UUID uuid) { + byte[] id = toBytes(uuid); + try (Connection c = ds.getConnection()) { + try (PreparedStatement ps = c.prepareStatement( + "SELECT points FROM bv_contributor WHERE uuid = ?")) { + ps.setBytes(1, id); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return -1; // never contributed + long myPoints = rs.getLong(1); + try (PreparedStatement r = c.prepareStatement( + "SELECT COUNT(*) + 1 FROM bv_contributor WHERE points > ?")) { + r.setLong(1, myPoints); + try (ResultSet rr = r.executeQuery()) { + return rr.next() ? rr.getInt(1) : -1; + } + } + } + } + } catch (SQLException e) { + plugin.getLogger().severe("Rank query failed: " + e.getMessage()); + return -1; + } + } + + public void close() { + if (ds != null && !ds.isClosed()) ds.close(); + } + + static byte[] toBytes(UUID u) { + return ByteBuffer.allocate(16) + .putLong(u.getMostSignificantBits()) + .putLong(u.getLeastSignificantBits()) + .array(); + } + + static UUID fromBytes(byte[] b) { + ByteBuffer bb = ByteBuffer.wrap(b); + return new UUID(bb.getLong(), bb.getLong()); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/display/DisplayService.java b/src/main/java/dev/anchorlight/blockvault/display/DisplayService.java new file mode 100644 index 0000000..955fe27 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/display/DisplayService.java @@ -0,0 +1,128 @@ +package dev.anchorlight.blockvault.display; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.db.Database; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Sign; +import org.bukkit.block.Skull; +import org.bukkit.entity.Display; +import org.bukkit.entity.TextDisplay; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.scheduler.BukkitTask; + +import java.util.List; + +/** + * The vanilla display layer: the lobby leader panel (head + flanking signs) and + * a {@link TextDisplay} hologram with the standings. No third-party dependency - + * TextDisplay is vanilla since 1.19.4. + */ +public final class DisplayService { + + private final BlockVault plugin; + private final org.bukkit.NamespacedKey tagKey; + private BukkitTask task; + + public DisplayService(BlockVault plugin) { + this.plugin = plugin; + this.tagKey = new org.bukkit.NamespacedKey(plugin, "hologram"); + } + + public void start() { + this.task = plugin.getServer().getScheduler().runTaskTimer( + plugin, this::refresh, 20L * 15L, 20L * 60L); + } + + public void stop() { + if (task != null) task.cancel(); + } + + /** Rebuild the leader panel and hologram from the current standings. */ + public void refresh() { + if (plugin.originWorld() == null) return; + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + List top = plugin.database().topContributors(5); + int done = plugin.database().collectedCount(); + int total = plugin.manifest().entries().size(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + updateLeaderPanel(top.isEmpty() ? null : top.get(0)); + updateHologram(top, done, total); + }); + }); + } + + private void updateLeaderPanel(Database.LeaderRow leader) { + setSign(plugin.manifest().leader("name_sign"), + "§lLeader", leader == null ? "—" : leader.name(), "", ""); + setSign(plugin.manifest().leader("count_sign"), + "§lContribution", leader == null ? "—" : leader.points() + " pts", + leader == null ? "" : leader.blocks() + " blocks", ""); + setSign(plugin.manifest().leader("title"), + "§6The Vault", "Hall of the", "First Givers", ""); + + int[] headRel = plugin.manifest().leader("head"); + if (headRel == null || leader == null) return; + Location loc = plugin.resolve(headRel); + if (loc.getWorld() == null) return; + if (loc.getBlock().getType() != Material.PLAYER_HEAD + && loc.getBlock().getType() != Material.PLAYER_WALL_HEAD) { + loc.getBlock().setType(Material.PLAYER_HEAD, false); + } + if (loc.getBlock().getState() instanceof Skull skull) { + PlayerProfile profile = Bukkit.createPlayerProfile(leader.uuid()); + skull.setOwnerProfile(profile); + skull.update(true, false); + } + } + + private void setSign(int[] rel, String... lines) { + if (rel == null) return; + Location loc = plugin.resolve(rel); + if (loc.getWorld() == null) return; + if (!(loc.getBlock().getState() instanceof Sign sign)) return; // leave real structure alone + for (int i = 0; i < 4 && i < lines.length; i++) { + sign.setLine(i, lines[i]); + } + sign.update(true, false); + } + + private void updateHologram(List top, int done, int total) { + int[] rel = plugin.manifest().leader("head"); + if (rel == null) return; + Location at = plugin.resolve(rel).add(0.5, 2.2, 0.5); + if (at.getWorld() == null || !at.isChunkLoaded()) return; // don't spawn into an unloaded chunk + + StringBuilder text = new StringBuilder("§6§lThe Vault\n§7") + .append(done).append(" / ").append(total) + .append(" §7(").append(total == 0 ? 0 : Math.round(100.0 * done / total)).append("%)\n"); + int rank = 1; + for (Database.LeaderRow r : top) { + text.append("\n§e").append(rank++).append(". §f").append(r.name()) + .append(" §7").append(r.points()).append(" pts"); + } + if (top.isEmpty()) text.append("\n§7No donations yet"); + + TextDisplay display = findHologram(at); + if (display == null) { + display = at.getWorld().spawn(at, TextDisplay.class, d -> { + d.getPersistentDataContainer().set(tagKey, PersistentDataType.BYTE, (byte) 1); + d.setBillboard(Display.Billboard.CENTER); + d.setDefaultBackground(false); + }); + } + display.setText(text.toString()); + } + + private TextDisplay findHologram(Location near) { + for (var e : near.getWorld().getNearbyEntities(near, 3, 3, 3)) { + if (e instanceof TextDisplay td + && td.getPersistentDataContainer().has(tagKey, PersistentDataType.BYTE)) { + return td; + } + } + return null; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/listener/PlayerGuidanceListener.java b/src/main/java/dev/anchorlight/blockvault/listener/PlayerGuidanceListener.java new file mode 100644 index 0000000..89adb82 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/listener/PlayerGuidanceListener.java @@ -0,0 +1,57 @@ +package dev.anchorlight.blockvault.listener; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.util.VaultUtil; +import net.md_5.bungee.api.ChatMessageType; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.ItemStack; + +/** + * Nudges players toward blocks the vault still needs: an actionbar hint while + * holding one, and a progress line on join. Highest-value feature for actually + * finishing the collection. + */ +public final class PlayerGuidanceListener implements Listener { + private final BlockVault plugin; + private final VaultUtil vaultUtil; + + public PlayerGuidanceListener(BlockVault plugin) { + this.plugin = plugin; + this.vaultUtil = new VaultUtil(plugin); + } + + /** Called from onEnable: refresh the held-block actionbar hint on a short loop. */ + public void start() { + plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + if (!vaultUtil.hasStarted()) return; + for (Player p : plugin.getServer().getOnlinePlayers()) { + ItemStack held = p.getInventory().getItemInMainHand(); + Material type = held.getType(); + if (type == Material.AIR) continue; + String material = type.getKey().getKey(); + if (plugin.manifest().entry(material) == null) continue; + if (plugin.database().isCollected(material)) continue; + p.spigot().sendMessage(ChatMessageType.ACTION_BAR, + new TextComponent("§eThe vault still needs " + + VaultUtil.formatMaterialName(type) + " — §7/bvsubmit")); + } + }, 40L, 30L); + } + + @EventHandler + public void onJoin(PlayerJoinEvent e) { + plugin.advancements().syncPlayer(e.getPlayer()); + if (!vaultUtil.hasStarted()) return; + int done = plugin.database().collectedCount(); + int total = plugin.manifest().entries().size(); + int pct = total == 0 ? 0 : (int) Math.round(100.0 * done / total); + plugin.tell(e.getPlayer(), "§7Vault progress: §e" + done + "§7/§e" + total + + " §7(" + pct + "%). Chapter " + plugin.chapters().current() + + " is the current floor. §f/bvprogress"); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/listener/RegionProtectionListener.java b/src/main/java/dev/anchorlight/blockvault/listener/RegionProtectionListener.java new file mode 100644 index 0000000..7150c76 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/listener/RegionProtectionListener.java @@ -0,0 +1,162 @@ +package dev.anchorlight.blockvault.listener; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.model.Region; +import org.bukkit.entity.EntityType; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.hanging.HangingBreakByEntityEvent; +import org.bukkit.event.hanging.HangingBreakEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; + +/** + * Everything that keeps the display layer and the seals intact. The display is + * entity-based (item frames + heads), so without this a single creeper erases + * a wall of donations. + */ +public final class RegionProtectionListener implements Listener { + + private static final String BYPASS = "blockvault.build"; + + private final BlockVault plugin; + + public RegionProtectionListener(BlockVault plugin) { + this.plugin = plugin; + } + + private boolean inside(org.bukkit.Location loc) { + Region r = plugin.region(); + return r != null && r.contains(loc); + } + + // --- item frames ------------------------------------------------------- + + @EventHandler(ignoreCancelled = true) + public void onHangingBreak(HangingBreakEvent e) { + if (inside(e.getEntity().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onHangingBreakByEntity(HangingBreakByEntityEvent e) { + if (inside(e.getEntity().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onInteractEntity(PlayerInteractEntityEvent e) { + EntityType t = e.getRightClicked().getType(); + if ((t == EntityType.ITEM_FRAME || t == EntityType.GLOW_ITEM_FRAME) + && inside(e.getRightClicked().getLocation()) + && !e.getPlayer().hasPermission(BYPASS)) { + e.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true) + public void onEntityDamageByEntity(EntityDamageByEntityEvent e) { + EntityType t = e.getEntity().getType(); + if ((t == EntityType.ITEM_FRAME || t == EntityType.GLOW_ITEM_FRAME) + && inside(e.getEntity().getLocation())) { + e.setCancelled(true); + } + } + + // --- blocks ---------------------------------------------------------- + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onBreak(BlockBreakEvent e) { + if (inside(e.getBlock().getLocation()) && !e.getPlayer().hasPermission(BYPASS)) { + e.setCancelled(true); + plugin.tell(e.getPlayer(), "§cYou can't break blocks inside the vault."); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlace(BlockPlaceEvent e) { + if (inside(e.getBlock().getLocation()) && !e.getPlayer().hasPermission(BYPASS)) { + e.setCancelled(true); + plugin.tell(e.getPlayer(), "§cYou can't place blocks inside the vault."); + } + } + + @EventHandler(ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent e) { + e.blockList().removeIf(b -> inside(b.getLocation())); + } + + @EventHandler(ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent e) { + e.blockList().removeIf(b -> inside(b.getLocation())); + } + + // --- environmental griefing ------------------------------------------- + + @EventHandler(ignoreCancelled = true) + public void onEntityChangeBlock(EntityChangeBlockEvent e) { + // Endermen picking up blocks, silverfish infesting, sheep eating grass, + // falling blocks landing, etc. + if (inside(e.getBlock().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onPistonExtend(BlockPistonExtendEvent e) { + if (inside(e.getBlock().getLocation()) + || e.getBlocks().stream().anyMatch(b -> inside(b.getLocation()))) { + e.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true) + public void onPistonRetract(BlockPistonRetractEvent e) { + if (inside(e.getBlock().getLocation()) + || e.getBlocks().stream().anyMatch(b -> inside(b.getLocation()))) { + e.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true) + public void onBurn(BlockBurnEvent e) { + if (inside(e.getBlock().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onIgnite(BlockIgniteEvent e) { + if (inside(e.getBlock().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onSpread(BlockSpreadEvent e) { + // Fire spreading, mushrooms/vines growing across the build. + if (inside(e.getBlock().getLocation())) e.setCancelled(true); + } + + @EventHandler(ignoreCancelled = true) + public void onFlow(BlockFromToEvent e) { + // Water/lava flowing into the region. + if (inside(e.getToBlock().getLocation())) e.setCancelled(true); + } + + // --- mob spawning -------------------------------------------------- + + @EventHandler(ignoreCancelled = true) + public void onSpawn(CreatureSpawnEvent e) { + if (!inside(e.getLocation())) return; + switch (e.getSpawnReason()) { + case CUSTOM, COMMAND, SPAWNER_EGG, BREEDING, DISPENSE_EGG -> { /* allowed */ } + default -> e.setCancelled(true); + } + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/model/Manifest.java b/src/main/java/dev/anchorlight/blockvault/model/Manifest.java new file mode 100644 index 0000000..dc34400 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/model/Manifest.java @@ -0,0 +1,126 @@ +package dev.anchorlight.blockvault.model; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The parsed vault_slots.json manifest. Immutable for the season. + * + *

Loaded from the plugin data folder if present, otherwise the bundled + * resource. If neither exists the caller must fail loudly - the plugin never + * regenerates the target list (brief section 7). + */ +public final class Manifest { + + private final String version; + private final int dataVersion; + private final Map entries; + private final Map leader; + private final Map seals; + private final int[] min; + private final int[] max; + + private Manifest(String version, int dataVersion, Map entries, + Map leader, Map seals) { + this.version = version; + this.dataVersion = dataVersion; + this.entries = entries; + this.leader = leader; + this.seals = seals; + + int[] lo = {Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE}; + int[] hi = {Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE}; + for (TargetEntry e : entries.values()) { + for (int[] p : new int[][]{e.sign(), e.head()}) { + for (int i = 0; i < 3; i++) { + lo[i] = Math.min(lo[i], p[i]); + hi[i] = Math.max(hi[i], p[i]); + } + } + } + this.min = lo; + this.max = hi; + } + + /** Origin-relative bounding box of every shelf cell, inclusive. */ + public int[] min() { return min; } + public int[] max() { return max; } + + public String version() { return version; } + public int dataVersion() { return dataVersion; } + public Map entries() { return entries; } + public TargetEntry entry(String material) { return entries.get(material); } + public int[] leader(String key) { return leader.get(key); } + public int[] seal(int chapter) { return seals.get(chapter); } + + public static Manifest load(Path dataFolderFile, ClassLoader loader) throws IOException { + if (Files.isRegularFile(dataFolderFile)) { + try (Reader r = Files.newBufferedReader(dataFolderFile, StandardCharsets.UTF_8)) { + return parse(r); + } + } + try (InputStream in = loader.getResourceAsStream("vault_slots.json")) { + if (in == null) { + throw new IOException("vault_slots.json not found in " + dataFolderFile + + " or on the classpath - generate it with tools/gen_artifacts.py"); + } + try (Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) { + return parse(r); + } + } + } + + private static Manifest parse(Reader reader) { + Gson gson = new Gson(); + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + + String version = root.get("version").getAsString(); + int dataVersion = root.get("data_version").getAsInt(); + + Map entries = new LinkedHashMap<>(); + root.getAsJsonArray("entries").forEach(el -> { + JsonObject o = el.getAsJsonObject(); + String block = o.get("block").getAsString(); + entries.put(block, new TargetEntry( + block, + o.get("chapter").getAsInt(), + o.get("rarity").getAsString(), + o.get("section").getAsString(), + triple(o, "sign"), + triple(o, "frame"), + triple(o, "head"), + o.get("facing").getAsString() + )); + }); + + Map leader = new LinkedHashMap<>(); + JsonObject lo = root.getAsJsonObject("leader"); + for (String k : lo.keySet()) { + leader.put(k, gson.fromJson(lo.get(k), int[].class)); + } + + Map seals = new LinkedHashMap<>(); + JsonObject so = root.getAsJsonObject("seals"); + for (String k : so.keySet()) { + seals.put(Integer.parseInt(k), gson.fromJson(so.get(k), int[].class)); + } + + return new Manifest(version, dataVersion, entries, leader, seals); + } + + private static int[] triple(JsonObject o, String key) { + var arr = o.getAsJsonArray(key); + return new int[]{ arr.get(0).getAsInt(), arr.get(1).getAsInt(), arr.get(2).getAsInt() }; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/model/Region.java b/src/main/java/dev/anchorlight/blockvault/model/Region.java new file mode 100644 index 0000000..5fe5af5 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/model/Region.java @@ -0,0 +1,43 @@ +package dev.anchorlight.blockvault.model; + +import org.bukkit.Location; +import org.bukkit.World; + +/** + * The protected volume around the vault, in absolute world coordinates. + * Derived from the manifest bounding box plus a margin, offset by the + * configured origin. Used for build/hanging/spawn protection and chunk + * force-loading. + */ +public final class Region { + + private final World world; + private final int minX, minY, minZ, maxX, maxY, maxZ; + + public Region(World world, int[] origin, int[] relMin, int[] relMax, int margin) { + this.world = world; + this.minX = origin[0] + relMin[0] - margin; + this.minY = origin[1] + relMin[1] - margin; + this.minZ = origin[2] + relMin[2] - margin; + this.maxX = origin[0] + relMax[0] + margin; + this.maxY = origin[1] + relMax[1] + margin; + this.maxZ = origin[2] + relMax[2] + margin; + } + + public World world() { + return world; + } + + public boolean contains(Location loc) { + if (loc.getWorld() == null || !loc.getWorld().equals(world)) return false; + int x = loc.getBlockX(), y = loc.getBlockY(), z = loc.getBlockZ(); + return x >= minX && x <= maxX + && y >= minY && y <= maxY + && z >= minZ && z <= maxZ; + } + + public int minChunkX() { return minX >> 4; } + public int maxChunkX() { return maxX >> 4; } + public int minChunkZ() { return minZ >> 4; } + public int maxChunkZ() { return maxZ >> 4; } +} diff --git a/src/main/java/dev/anchorlight/blockvault/model/TargetEntry.java b/src/main/java/dev/anchorlight/blockvault/model/TargetEntry.java new file mode 100644 index 0000000..a4a5ac6 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/model/TargetEntry.java @@ -0,0 +1,28 @@ +package dev.anchorlight.blockvault.model; + +import org.bukkit.block.BlockFace; + +/** + * One row of the frozen target list. Coordinates are relative to the schematic + * origin; the runtime adds the configured world origin before touching blocks. + */ +public record TargetEntry( + String material, + int chapter, + String rarity, + String section, + int[] sign, + int[] frame, + int[] head, + String facing +) { + public BlockFace face() { + return switch (facing) { + case "north" -> BlockFace.NORTH; + case "south" -> BlockFace.SOUTH; + case "east" -> BlockFace.EAST; + case "west" -> BlockFace.WEST; + default -> throw new IllegalStateException("bad facing: " + facing); + }; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/util/FileUtil.java b/src/main/java/dev/anchorlight/blockvault/util/FileUtil.java new file mode 100644 index 0000000..35b6d5e --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/FileUtil.java @@ -0,0 +1,66 @@ +package dev.anchorlight.blockvault.util; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; + +import java.io.File; +import java.io.IOException; + +public class FileUtil { + + private final Plugin plugin; + private final File configFile; + private YamlConfiguration config; + + public FileUtil(Plugin plugin) { + this.plugin = plugin; + this.configFile = new File(plugin.getDataFolder(), "config.yml"); + reloadConfig(); // Load the configuration initially + } + + /** + * Returns the plugin's configuration. + * + * @return The plugin's configuration. + */ + public YamlConfiguration getConfig() { + return config; + } + + /** + * Saves the current state of the configuration to the file. + */ + public void saveConfig() { + try { + config.save(configFile); + } catch (IOException e) { + plugin.getLogger().severe("Could not save config.yml: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Reloads the configuration from the file. + */ + public void reloadConfig() { + if (!configFile.exists()) { + plugin.saveResource("config.yml", false); // Ensure the default config exists + } + this.config = YamlConfiguration.loadConfiguration(configFile); + } + + /** + * Updates a specific configuration value without erasing others. + * + * @param path The configuration path. + * @param value The value to set. + */ + public void updateConfigValue(String path, Object value) { + // Set the new value + getConfig().set(path, value); + // Save the config to the file + saveConfig(); + // Reload the config to reflect changes immediately + plugin.reloadConfig(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/anchorlight/blockvault/util/HeadUtil.java b/src/main/java/dev/anchorlight/blockvault/util/HeadUtil.java new file mode 100644 index 0000000..becaa4b --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/HeadUtil.java @@ -0,0 +1,66 @@ +package dev.anchorlight.blockvault.util; + +import com.google.gson.JsonObject; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.Skull; +import org.bukkit.block.data.Directional; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.profile.PlayerTextures; + +import java.net.URL; +import java.util.UUID; + +/** Places donor player heads and (de)serialises the cached skin profile JSON. */ +public final class HeadUtil { + + private HeadUtil() {} + + /** Place a wall-mounted player head at {@code loc} facing {@code aisle}, wearing {@code profile}. */ + public static void placeHead(Location loc, BlockFace aisle, PlayerProfile profile) { + Block block = loc.getBlock(); + block.setType(Material.PLAYER_WALL_HEAD, false); + if (block.getBlockData() instanceof Directional dir) { + dir.setFacing(aisle); + block.setBlockData(dir, false); + } + if (block.getState() instanceof Skull skull) { + skull.setOwnerProfile(profile); + skull.update(true, false); + } + } + + /** Minimal JSON snapshot of a profile, enough to rebuild the head years later. */ + public static String toJson(PlayerProfile profile) { + JsonObject o = new JsonObject(); + if (profile.getUniqueId() != null) o.addProperty("id", profile.getUniqueId().toString()); + if (profile.getName() != null) o.addProperty("name", profile.getName()); + URL skin = profile.getTextures().getSkin(); + if (skin != null) { + o.addProperty("skin", skin.toString()); + o.addProperty("model", profile.getTextures().getSkinModel().name()); + } + return o.toString(); + } + + /** Rebuild a profile from {@link #toJson}. Used by /bvrepair. */ + public static PlayerProfile fromJson(org.bukkit.Server server, JsonObject o) { + UUID id = o.has("id") ? UUID.fromString(o.get("id").getAsString()) : null; + String name = o.has("name") ? o.get("name").getAsString() : null; + PlayerProfile profile = server.createPlayerProfile(id, name); + if (o.has("skin")) { + try { + PlayerTextures tex = profile.getTextures(); + tex.setSkin(new URL(o.get("skin").getAsString()), + o.has("model") ? PlayerTextures.SkinModel.valueOf(o.get("model").getAsString()) + : PlayerTextures.SkinModel.CLASSIC); + profile.setTextures(tex); + } catch (Exception ignored) { + // fall back to name/id lookup + } + } + return profile; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/util/ScheduleUtil.java b/src/main/java/dev/anchorlight/blockvault/util/ScheduleUtil.java new file mode 100644 index 0000000..0756e71 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/ScheduleUtil.java @@ -0,0 +1,28 @@ +package dev.anchorlight.blockvault.util; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +public final class ScheduleUtil { + + private ScheduleUtil() {} + + public static void scheduleVaultStateTask(Plugin plugin, VaultUtil vaultUtil, FileUtil fileUtil) { + int intervalMinutes = fileUtil.getConfig().getInt("update.interval-minutes", 15); + if (intervalMinutes < 1) { + plugin.getLogger().warning("update.interval-minutes was " + intervalMinutes + + "; clamping to 1. A value of 0 would fail to schedule."); + intervalMinutes = 1; + } + int startupDelaySeconds = fileUtil.getConfig().getInt("update.startup-delay-seconds", 30); + if (startupDelaySeconds < 0) startupDelaySeconds = 0; + + long delayTicks = 20L * startupDelaySeconds; + long periodTicks = 20L * 60L * intervalMinutes; + + // Delay the first run so worlds are loaded before it touches blocks. + Bukkit.getScheduler().runTaskTimer(plugin, + () -> vaultUtil.updateVaultState(null), + delayTicks, periodTicks); + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/util/StartupValidation.java b/src/main/java/dev/anchorlight/blockvault/util/StartupValidation.java new file mode 100644 index 0000000..963ace5 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/StartupValidation.java @@ -0,0 +1,153 @@ +package dev.anchorlight.blockvault.util; + +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.model.TargetEntry; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.logging.Logger; + +/** + * Cross-checks the three sources of truth on startup: the manifest + * (vault_slots.json), the rarity list (vault_items.yml) and bv_target. + * Silent drift between them is how you discover in month eight that four + * blocks have no niche (brief section 8, Operations). + * + *

Logs findings; never aborts. Also flags blocks the running game has + * that the frozen edition does not cover. + */ +public final class StartupValidation { + + /** Blocks that exist in the registry but cannot be obtained in survival (brief section 9). */ + private static final Set UNOBTAINABLE = Set.of( + "air", "barrier", "bedrock", "budding_amethyst", "chain_command_block", "chorus_plant", + "command_block", "dirt_path", "end_portal_frame", "farmland", "frogspawn", "jigsaw", "light", + "infested_chiseled_stone_bricks", "infested_cobblestone", "infested_cracked_stone_bricks", + "infested_deepslate", "infested_mossy_stone_bricks", "infested_stone", "infested_stone_bricks", + "reinforced_deepslate", "repeating_command_block", "spawner", "structure_block", + "structure_void", "suspicious_gravel", "suspicious_sand", "test_block", + "test_instance_block", "trial_spawner", "vault"); + + private StartupValidation() {} + + public static void run(BlockVault plugin) { + Logger log = plugin.getLogger(); + + Map manifest = new TreeMap<>(); + for (TargetEntry e : plugin.manifest().entries().values()) { + manifest.put(e.material(), e.rarity()); + } + + Map items = loadItems(plugin); + Set target = plugin.database().targetMaterials(); + + int problems = 0; + + // Two blocks must never share a shelf cell - the second head would hide the first. + Map byCell = new java.util.HashMap<>(); + for (TargetEntry e : plugin.manifest().entries().values()) { + String cell = e.sign()[0] + "," + e.sign()[1] + "," + e.sign()[2]; + String prev = byCell.putIfAbsent(cell, e.material()); + if (prev != null) { + log.warning("[validation] shelf " + cell + " is used by both " + + prev + " and " + e.material() + " - one will be unreachable"); + problems++; + } + } + + // manifest <-> vault_items.yml + for (var entry : manifest.entrySet()) { + String r = items.get(entry.getKey()); + if (r == null) { + log.warning("[validation] " + entry.getKey() + " is in the manifest but missing from vault_items.yml"); + problems++; + } else if (!r.equalsIgnoreCase(entry.getValue())) { + log.warning("[validation] " + entry.getKey() + " rarity disagrees: manifest=" + + entry.getValue() + " vault_items.yml=" + r); + problems++; + } + } + for (String k : items.keySet()) { + if (!manifest.containsKey(k)) { + log.warning("[validation] " + k + " is in vault_items.yml but not the manifest"); + problems++; + } + } + + // manifest <-> bv_target + for (String k : manifest.keySet()) { + if (!target.contains(k)) { + log.warning("[validation] " + k + " is in the manifest but not bv_target (edition mismatch?)"); + problems++; + } + } + for (String k : target) { + if (!manifest.containsKey(k)) { + log.warning("[validation] bv_target has " + k + " which the manifest does not (stale edition row)"); + problems++; + } + } + + // manifest <-> live registry + Set newBlocks = new LinkedHashSet<>(); + for (Material m : Material.values()) { + if (m.isLegacy() || !m.isBlock() || !m.isItem()) continue; + String key = m.getKey().getKey(); + if (UNOBTAINABLE.contains(key)) continue; + if (!manifest.containsKey(key)) newBlocks.add(key); + } + for (String k : manifest.keySet()) { + Material m = Material.matchMaterial(k); + if (m == null) { + log.warning("[validation] manifest block " + k + " is not a Material on this server version"); + problems++; + } + } + if (!newBlocks.isEmpty()) { + log.warning("[validation] " + newBlocks.size() + " obtainable block(s) exist that edition " + + plugin.database().edition() + " does not cover - candidates for the next edition:"); + log.warning("[validation] " + String.join(", ", newBlocks)); + } + + if (problems == 0) { + log.info("[validation] " + manifest.size() + + " blocks matched across manifest, vault_items.yml and bv_target."); + } else { + log.warning("[validation] " + problems + " discrepancy/ies found - see above."); + } + } + + private static Map loadItems(BlockVault plugin) { + Map out = new TreeMap<>(); + java.io.File override = new java.io.File(plugin.getDataFolder(), "vault_items.yml"); + YamlConfiguration yaml; + if (override.isFile()) { + yaml = YamlConfiguration.loadConfiguration(override); + } else { + try (InputStream in = plugin.getResource("vault_items.yml")) { + if (in == null) { + plugin.getLogger().warning("[validation] vault_items.yml not found - skipping that check"); + return out; + } + yaml = YamlConfiguration.loadConfiguration( + new InputStreamReader(in, StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[validation] could not read vault_items.yml: " + e.getMessage()); + return out; + } + } + var section = yaml.getConfigurationSection("items"); + if (section == null) return out; + for (String k : section.getKeys(false)) { + out.put(k.toLowerCase(), String.valueOf(section.get(k))); + } + return out; + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/util/VaultUtil.java b/src/main/java/dev/anchorlight/blockvault/util/VaultUtil.java new file mode 100644 index 0000000..ae31ca7 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/VaultUtil.java @@ -0,0 +1,105 @@ +package dev.anchorlight.blockvault.util; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.anchorlight.blockvault.BlockVault; +import dev.anchorlight.blockvault.model.TargetEntry; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.command.CommandSender; +import org.bukkit.profile.PlayerProfile; + +import java.util.Map; + +public class VaultUtil { + + private final BlockVault plugin; + + public VaultUtil(BlockVault plugin) { + this.plugin = plugin; + } + + public boolean hasStarted() { + return plugin.getConfig().getBoolean("vault.started", false); + } + + /** + * Turns a Material into a display name: {@code cut_sandstone} -> "Cut Sandstone". + * Guards against empty tokens from a malformed key. + */ + public static String formatMaterialName(Material material) { + StringBuilder out = new StringBuilder(); + for (String word : material.getKey().getKey().split("_")) { + if (word.isEmpty()) continue; + out.append(Character.toUpperCase(word.charAt(0))) + .append(word.substring(1)) + .append(' '); + } + return out.toString().trim(); + } + + /** + * Reconcile the world display against the database. Non-destructive: only + * the manifest {@code head} cells are ever written, never structure. + * Emits exactly one summary line (brief section 7, acceptance criterion 1). + * + * @param sender optional command sender to echo the summary to + */ + public void updateVaultState(CommandSender sender) { + World world = plugin.originWorld(); + if (world == null) { + String msg = "BlockVault: origin world '" + + plugin.getConfig().getString("origin.world") + "' is not loaded; skipping."; + plugin.getLogger().warning(msg); + if (sender != null) plugin.tell(sender, "§c" + msg); + return; + } + + // Database read off the main thread; block edits applied back on it. + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Map profiles = plugin.database().allProfiles(); + + plugin.getServer().getScheduler().runTask(plugin, () -> { + int placed = 0, cleared = 0, ok = 0; + for (TargetEntry entry : plugin.manifest().entries().values()) { + Location loc = plugin.resolve(entry.head()); + Block block = loc.getBlock(); + // A head is shown only when the block is collected AND its + // chapter floor is open. Anything else means: no head. + boolean shouldShow = plugin.database().isCollected(entry.material()) + && plugin.chapters().isOpen(entry.chapter()); + boolean hasHead = block.getType() == Material.PLAYER_WALL_HEAD + || block.getType() == Material.PLAYER_HEAD; + + if (shouldShow && !hasHead) { + HeadUtil.placeHead(loc, entry.face(), + parseProfile(world, profiles.get(entry.material()))); + placed++; + } else if (!shouldShow && hasHead) { + block.setType(Material.AIR, false); + cleared++; + } else { + ok++; + } + } + String summary = String.format( + "BlockVault reconcile: %d in place, %d heads added, %d removed (%d targets).", + ok, placed, cleared, plugin.manifest().entries().size()); + plugin.getLogger().info(summary); + if (sender != null) plugin.tell(sender, "§a" + summary); + }); + }); + } + + private static PlayerProfile parseProfile(World world, String json) { + if (json == null || json.isBlank()) return null; + try { + JsonObject o = JsonParser.parseString(json).getAsJsonObject(); + return HeadUtil.fromJson(org.bukkit.Bukkit.getServer(), o); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/dev/anchorlight/blockvault/util/Webhook.java b/src/main/java/dev/anchorlight/blockvault/util/Webhook.java new file mode 100644 index 0000000..dd42199 --- /dev/null +++ b/src/main/java/dev/anchorlight/blockvault/util/Webhook.java @@ -0,0 +1,60 @@ +package dev.anchorlight.blockvault.util; + +import dev.anchorlight.blockvault.BlockVault; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * Fire-and-forget Discord webhook for rare submissions and chapter unlocks. + * Doubles as an off-server recovery log. No-op when no URL is configured. + */ +public final class Webhook { + + private final BlockVault plugin; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)).build(); + + public Webhook(BlockVault plugin) { + this.plugin = plugin; + } + + private String url() { + String u = plugin.getConfig().getString("discord.webhook-url", ""); + return (u == null || u.isBlank()) ? null : u; + } + + public void rareSubmission(String material, String player, int points) { + send("💎 **" + escape(player) + "** donated a rare block: `" + + escape(material) + "` (+" + points + " points)"); + } + + public void chapterOpened(int chapter, String title) { + send("🔓 **Chapter " + chapter + " — " + escape(title) + "** is now open!"); + } + + private void send(String content) { + String url = url(); + if (url == null) return; + String body = "{\"content\":\"" + content.replace("\"", "\\\"") + "\"," + + "\"allowed_mentions\":{\"parse\":[]}}"; + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + http.sendAsync(req, HttpResponse.BodyHandlers.discarding()) + .exceptionally(ex -> { + plugin.getLogger().warning("Discord webhook failed: " + ex.getMessage()); + return null; + }); + } + + /** Strip formatting/control characters so display names can't inject into the payload. */ + private static String escape(String s) { + return s.replaceAll("[\\p{Cntrl}`*_~|\\\\@]", ""); + } +} diff --git a/src/main/java/me/benrobson/blockvault/Blockvault.java b/src/main/java/me/benrobson/blockvault/Blockvault.java deleted file mode 100644 index 7aeacc8..0000000 --- a/src/main/java/me/benrobson/blockvault/Blockvault.java +++ /dev/null @@ -1,17 +0,0 @@ -package me.benrobson.blockvault; - -import org.bukkit.plugin.java.JavaPlugin; - -public final class Blockvault extends JavaPlugin { - - @Override - public void onEnable() { - // Plugin startup logic - - } - - @Override - public void onDisable() { - // Plugin shutdown logic - } -} diff --git a/src/main/resources/blockvault_schema.sql b/src/main/resources/blockvault_schema.sql new file mode 100644 index 0000000..dcb56d7 --- /dev/null +++ b/src/main/resources/blockvault_schema.sql @@ -0,0 +1,105 @@ +-- BlockVault - MySQL 8.0+ / MariaDB 10.5+ +-- Column sizes checked against the real 26.2 target list: +-- longest block name = waxed_weathered_copper_golem_statue (35 chars) +-- Minecraft usernames are max 16 chars +-- InnoDB throughout: the plugin CONSUMES the submitted item, so every write +-- must be transactional. A lost write means a permanently lost block. + +SET NAMES utf8mb4; + +-- ---------------------------------------------------------------- people +CREATE TABLE IF NOT EXISTS bv_contributor ( + uuid BINARY(16) NOT NULL, + last_name VARCHAR(16) NOT NULL, + points INT UNSIGNED NOT NULL DEFAULT 0, + blocks_given SMALLINT UNSIGNED NOT NULL DEFAULT 0, + first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (uuid), + KEY idx_points (points DESC), + KEY idx_name (last_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- the target list +-- One row per block the edition expects. Loaded from vault_items.yml + +-- vault_slots.json on first start, then treated as immutable for the season. +CREATE TABLE IF NOT EXISTS bv_target ( + material VARCHAR(64) NOT NULL, + edition VARCHAR(16) NOT NULL, + chapter TINYINT UNSIGNED NOT NULL, + rarity ENUM('common','uncommon','rare') NOT NULL, + section ENUM('building','coloured','functional','natural','redstone') NOT NULL, + sign_x INT NOT NULL, sign_y INT NOT NULL, sign_z INT NOT NULL, + frame_x INT NOT NULL, frame_y INT NOT NULL, frame_z INT NOT NULL, + head_x INT NOT NULL, head_y INT NOT NULL, head_z INT NOT NULL, + facing ENUM('north','south','east','west') NOT NULL, + PRIMARY KEY (material, edition), + KEY idx_chapter (edition, chapter) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- submissions +-- material is the primary key: the database itself enforces one-of-each. +-- Insert and catch the duplicate-key error instead of read-then-write. +CREATE TABLE IF NOT EXISTS bv_submission ( + material VARCHAR(64) NOT NULL, + edition VARCHAR(16) NOT NULL, + uuid BINARY(16) NOT NULL, + points SMALLINT UNSIGNED NOT NULL, + profile JSON NULL COMMENT 'cached skin profile for the head', + submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (material, edition), + KEY idx_uuid (uuid), + KEY idx_time (submitted_at), + CONSTRAINT fk_sub_target FOREIGN KEY (material, edition) + REFERENCES bv_target (material, edition), + CONSTRAINT fk_sub_person FOREIGN KEY (uuid) + REFERENCES bv_contributor (uuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- chapters +CREATE TABLE IF NOT EXISTS bv_chapter ( + chapter TINYINT UNSIGNED NOT NULL, + edition VARCHAR(16) NOT NULL, + title VARCHAR(32) NOT NULL, + room VARCHAR(32) NOT NULL, + seal_x INT NULL, seal_y INT NULL, seal_z INT NULL, + opens_at DATETIME NULL, + opened_at DATETIME NULL, + completed_at DATETIME NULL, + completed_by BINARY(16) NULL, + PRIMARY KEY (chapter, edition) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- audit +-- Every state change. This is the reconstruction log if anything is ever lost. +CREATE TABLE IF NOT EXISTS bv_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + actor BINARY(16) NULL, + action VARCHAR(32) NOT NULL, + material VARCHAR(64) NULL, + detail JSON NULL, + PRIMARY KEY (id), + KEY idx_ts (ts), + KEY idx_action (action) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- views +CREATE OR REPLACE VIEW bv_progress AS +SELECT t.edition, + t.chapter, + COUNT(*) AS total, + COUNT(s.material) AS collected, + ROUND(100 * COUNT(s.material) / COUNT(*), 1) AS pct +FROM bv_target t +LEFT JOIN bv_submission s + ON s.material = t.material AND s.edition = t.edition +GROUP BY t.edition, t.chapter; + +CREATE OR REPLACE VIEW bv_leaderboard AS +SELECT c.uuid, c.last_name, c.points, COUNT(s.material) AS blocks +FROM bv_contributor c +LEFT JOIN bv_submission s ON s.uuid = c.uuid +GROUP BY c.uuid, c.last_name, c.points +ORDER BY c.points DESC, blocks DESC; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..a53d469 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,63 @@ +# BlockVault configuration +# +# This file holds database credentials. It is git-ignored - never commit a +# populated copy. See src/main/resources/blockvault_schema.sql for the schema; +# apply it before first start. + +# Current target-list version. Blocks are frozen per edition (see brief section 1.3). +edition: '26.2' + +database: + host: localhost + port: 3306 + name: blockvault + user: blockvault + password: changeme + # Extra HikariCP tuning. Pool is small - the plugin is not write-heavy. + pool: + maximum-pool-size: 6 + minimum-idle: 1 + connection-timeout-ms: 8000 + # Keep below the server's wait_timeout. + max-lifetime-ms: 1740000 + +# Where the schematic origin sits in the live world. Every manifest coordinate +# is relative to this point and is added to it at runtime. +origin: + world: world + x: 0 + y: 100 + z: 0 + +# State reconciliation (/bvupdatestate and the scheduled pass). +update: + # Minutes between scheduled reconciliation passes. Must be > 0. + interval-minutes: 15 + # Seconds to wait after enable before the first pass, so worlds are loaded. + startup-delay-seconds: 30 + +# Points awarded per rarity. Lowercase keys - Bukkit paths are case-sensitive. +points: + common: 1 + uncommon: 5 + rare: 10 + +# Scheduled open dates per chapter (ISO local date-time). A chapter opens on +# this date OR when the previous chapter reaches 90%, whichever comes first. +# Re-read every start and applied to any chapter not yet open, so you can adjust +# a date mid-season. Chapter 1 opens with /bvstart regardless. +chapters: + '1': { opens-at: '2026-01-01T00:00:00' } + '2': { opens-at: '2026-02-15T00:00:00' } + '3': { opens-at: '2026-05-15T00:00:00' } + '4': { opens-at: '2026-08-01T00:00:00' } + '5': { opens-at: '2026-09-15T00:00:00' } + '6': { opens-at: '2026-12-01T00:00:00' } + +# Optional Discord webhook for rare submissions and chapter unlocks. +# Doubles as an off-server recovery log. Leave blank to disable. +discord: + webhook-url: "" + +lang: + prefix: "&8&l[&3BV&8&l] " diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_1.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_1.json new file mode 100644 index 0000000..9908c18 --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_1.json @@ -0,0 +1,14 @@ +{ + "display": { + "icon": { "id": "minecraft:chiseled_stone_bricks" }, + "title": { "text": "Foundations" }, + "description": { "text": "Complete Chapter 1 of the Vault — The Undercroft" }, + "background": "minecraft:textures/gui/advancements/backgrounds/stone.png", + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_2.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_2.json new file mode 100644 index 0000000..1638766 --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_2.json @@ -0,0 +1,14 @@ +{ + "parent": "blockvault:chapter_1", + "display": { + "icon": { "id": "minecraft:oak_sapling" }, + "title": { "text": "Green & Growing" }, + "description": { "text": "Complete Chapter 2 of the Vault — The Conservatory" }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_3.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_3.json new file mode 100644 index 0000000..83da52b --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_3.json @@ -0,0 +1,14 @@ +{ + "parent": "blockvault:chapter_2", + "display": { + "icon": { "id": "minecraft:deepslate_bricks" }, + "title": { "text": "Into the Deep" }, + "description": { "text": "Complete Chapter 3 of the Vault — The Deep" }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_4.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_4.json new file mode 100644 index 0000000..2dccab8 --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_4.json @@ -0,0 +1,14 @@ +{ + "parent": "blockvault:chapter_3", + "display": { + "icon": { "id": "minecraft:pink_wool" }, + "title": { "text": "Every Colour" }, + "description": { "text": "Complete Chapter 4 of the Vault — The Gallery" }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_5.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_5.json new file mode 100644 index 0000000..238667a --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_5.json @@ -0,0 +1,14 @@ +{ + "parent": "blockvault:chapter_4", + "display": { + "icon": { "id": "minecraft:nether_bricks" }, + "title": { "text": "The Nether" }, + "description": { "text": "Complete Chapter 5 of the Vault — The Forge" }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/data/blockvault/advancement/chapter_6.json b/src/main/resources/datapack/data/blockvault/advancement/chapter_6.json new file mode 100644 index 0000000..57e12a9 --- /dev/null +++ b/src/main/resources/datapack/data/blockvault/advancement/chapter_6.json @@ -0,0 +1,14 @@ +{ + "parent": "blockvault:chapter_5", + "display": { + "icon": { "id": "minecraft:purpur_block" }, + "title": { "text": "The End" }, + "description": { "text": "Complete Chapter 6 of the Vault — The Observatory" }, + "frame": "challenge", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { "granted": { "trigger": "minecraft:impossible" } }, + "requirements": [["granted"]] +} diff --git a/src/main/resources/datapack/pack.mcmeta b/src/main/resources/datapack/pack.mcmeta new file mode 100644 index 0000000..70db763 --- /dev/null +++ b/src/main/resources/datapack/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "BlockVault chapter advancements", + "pack_format": 88, + "supported_formats": { "min_inclusive": 57, "max_inclusive": 9999 } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index bacbd46..a37797b 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,4 +1,114 @@ -name: blockvault +name: BlockVault version: '1.0.0' -main: me.benrobson.blockvault.Blockvault -api-version: '1.21' +main: dev.anchorlight.blockvault.BlockVault +api-version: '26.2' +libraries: + - com.zaxxer:HikariCP:5.1.0 + - com.mysql:mysql-connector-j:8.4.0 +commands: + bvsubmit: + description: Submit the block you are holding to the vault. + usage: /bvsubmit + bvleaderboard: + description: Show the top contributors (add 'month' for this month only). + usage: /bvleaderboard [month] + bvprogress: + description: Show current chapter and overall progress. + usage: /bvprogress + bvstart: + description: Open (or, with 'stop', close) the collection event. + usage: /bvstart [stop] + permission: blockvault.start + bvupdatestate: + description: Reconcile the world display against the database. + usage: /bvupdatestate + permission: blockvault.updatestate + bvfind: + description: Where a block lives in the museum. + usage: /bvfind + bvinfo: + description: Rarity, points, chapter and status of a block. + usage: /bvinfo + bvmissing: + description: Outstanding blocks for a chapter. + usage: /bvmissing [chapter] [page] + bvcheck: + description: Scan your inventory for blocks the vault needs. + usage: /bvcheck + bvme: + description: Your donations, points and rank. + usage: /bvme + bvhistory: + description: Who donated a block and when. + usage: /bvhistory + bvedition: + description: Show the frozen target-list version. + usage: /bvedition + bvreload: + description: Reload config without a restart. + usage: /bvreload + permission: blockvault.reload + bvrevoke: + description: Reverse a submission and refund its points. + usage: /bvrevoke + permission: blockvault.revoke + bvrepair: + description: Rebuild missing frames and heads from the manifest. + usage: /bvrepair + permission: blockvault.repair + bvbackup: + description: Write a timestamped database dump. + usage: /bvbackup + permission: blockvault.backup +permissions: + blockvault.submit: + description: Allows submitting blocks to the vault. + default: true + blockvault.progress: + description: Allows viewing vault progress. + default: true + blockvault.leaderboard: + description: Allows viewing the leaderboard. + default: true + blockvault.find: + description: Allows looking up block locations. + default: true + blockvault.info: + description: Allows looking up block details. + default: true + blockvault.missing: + description: Allows listing outstanding blocks. + default: true + blockvault.check: + description: Allows scanning your inventory against the target list. + default: true + blockvault.me: + description: Allows viewing your own vault record. + default: true + blockvault.history: + description: Allows viewing who donated a block. + default: true + blockvault.edition: + description: Allows viewing the edition version. + default: true + blockvault.start: + description: Allows opening and closing the event. + default: op + blockvault.updatestate: + description: Allows reconciling the world display against the database. + default: op + blockvault.reload: + description: Allows reloading the configuration. + default: op + blockvault.revoke: + description: Allows reversing a submission. + default: op + blockvault.repair: + description: Allows rebuilding frames and heads. + default: op + blockvault.backup: + description: Allows writing database dumps. + default: op + blockvault.build: + description: Bypass vault build/interaction protection. + default: op diff --git a/tools/gen_artifacts.py b/tools/gen_artifacts.py new file mode 100644 index 0000000..3d6068b --- /dev/null +++ b/tools/gen_artifacts.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate BlockVault data artefacts from the frozen target list (brief section 12). + +Input: tools/target_list.txt - the raw section-12 block, one entry per line: + block chapter rarity section x y z facing + (lines outside that shape - fences, prose - are ignored.) + +Outputs: + src/main/resources/vault_items.yml items: mapping of block -> rarity, sorted + src/main/resources/vault_slots.json full manifest (entries + leader + seals) + tools/spawn_frames.mcfunction one summon per entry, run from schematic origin + +frame = sign + (0,1,0) head = sign + (0,2,0) (invariant across all entries) +""" +from __future__ import annotations +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" / "main" / "resources" +LIST = ROOT / "tools" / "target_list.txt" + +VERSION = "26.2" +DATA_VERSION = 4903 +FACING_BYTE = {"north": 2, "south": 3, "west": 4, "east": 5} + +LEADER = {"head": [0, 11, 5], "name_sign": [-1, 11, 5], + "count_sign": [1, 11, 5], "title": [0, 12, 5]} +SEALS = {"2": [0, 8, 1], "3": [0, 18, 1], "4": [0, 28, 1], + "5": [0, 38, 1], "6": [0, 48, 1]} + +RARITIES = {"common", "uncommon", "rare"} +SECTIONS = {"building", "coloured", "functional", "natural", "redstone"} +LINE = re.compile( + r"^([a-z0-9_]+)\s+(\d+)\s+(common|uncommon|rare)\s+" + r"(building|coloured|functional|natural|redstone)\s+" + r"(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(north|south|east|west)\s*$" +) + + +def parse(path: Path): + entries = [] + seen = set() + coords = {} + for n, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("```") or line.startswith("#"): + continue + m = LINE.match(line) + if not m: + # tolerate the header line "block chapter rarity section x y z facing" + if line.split()[:2] == ["block", "chapter"]: + continue + raise SystemExit(f"{path}:{n}: unparseable entry: {line!r}") + block, chap, rarity, section, x, y, z, facing = m.groups() + if block in seen: + raise SystemExit(f"{path}:{n}: duplicate block {block!r}") + seen.add(block) + sx, sy, sz = int(x), int(y), int(z) + key = (sx, sy, sz) + if key in coords: + raise SystemExit( + f"{path}:{n}: shelf {sx} {sy} {sz} already used by " + f"{coords[key]!r} - two blocks cannot share a slot") + coords[key] = block + entries.append({ + "block": block, "chapter": int(chap), "rarity": rarity, + "section": section, + "sign": [sx, sy, sz], + "frame": [sx, sy + 1, sz], + "head": [sx, sy + 2, sz], + "facing": facing, + }) + return entries + + +def main(): + if not LIST.exists(): + raise SystemExit(f"missing {LIST} - paste the brief section-12 block into it") + entries = parse(LIST) + + # vault_items.yml - sorted block -> rarity + items = "\n".join(f" {e['block']}: {e['rarity']}" + for e in sorted(entries, key=lambda e: e["block"])) + (SRC / "vault_items.yml").write_text( + "# Generated by tools/gen_artifacts.py - do not hand-edit.\n" + f"# edition {VERSION}, {len(entries)} blocks\n" + "items:\n" + items + "\n", encoding="utf-8") + + # vault_slots.json - manifest + manifest = { + "version": VERSION, "data_version": DATA_VERSION, + "entries": entries, + "leader": LEADER, "seals": SEALS, + } + (SRC / "vault_slots.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + # spawn_frames.mcfunction + lines = ["# Generated by tools/gen_artifacts.py - run standing on the schematic origin.", + f"# {len(entries)} item frames, edition {VERSION}"] + for e in entries: + fx, fy, fz = e["frame"] + b = FACING_BYTE[e["facing"]] + lines.append( + f"summon minecraft:item_frame ~{fx} ~{fy} ~{fz} " + f"{{Facing:{b}b,Fixed:1b,Invulnerable:1b,Silent:1b}}" + ) + (ROOT / "tools" / "spawn_frames.mcfunction").write_text( + "\n".join(lines) + "\n", encoding="utf-8") + + # summary + by_rarity = {r: 0 for r in RARITIES} + by_section = {s: 0 for s in SECTIONS} + for e in entries: + by_rarity[e["rarity"]] += 1 + by_section[e["section"]] += 1 + print(f"{len(entries)} entries") + print("rarity :", dict(sorted(by_rarity.items()))) + print("section:", dict(sorted(by_section.items()))) + pts = by_rarity["common"] * 1 + by_rarity["uncommon"] * 5 + by_rarity["rare"] * 10 + print("season points:", pts) + + +if __name__ == "__main__": + main()