diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bc2f9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +*.jar +.maven-docker-cache/ diff --git a/README.md b/README.md index d5eb05d..93d4237 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,56 @@ # DevoteMe-Minecraft -The DevoteMe Minecraft module for the DevoteMe product suite. + +The DevoteMe Minecraft module is a Paper plugin that connects to your DevoteMe API and provides a daily "Verse of the Day" (VOTD) and a daily devotional experience directly in-game. + +## Features + +- **Verse of the Day (VOTD):** Automatically sent to players on login and viewable on-demand via `/votd`. +- **Daily Devotion:** Opens a rich, paginated book GUI using `/devotion`. +- **MiniMessage Formatting:** Full support for modern Minecraft text formatting, including gradients, bold text, and hover/click events. +- **Caching & Reliability:** Fetches content from the DevoteMe API and caches it in memory. If the API is down, the plugin continues to serve the last successful result. +- **Automated Refreshes:** Refreshes content every 12 hours (configurable) to keep the experience fresh. +- **Hologram Support:** Integrated with **DecentHolograms** to display the VOTD at multiple locations across your server. +- **Hologram Management:** In-game commands to create, list, and remove VOTD holograms without editing configuration files. + +## Installation + +1. Download the latest `DevoteMeMC.jar`. +2. Place the jar in your server's `plugins` folder. +3. Restart the server to generate the default configuration. +4. Edit `plugins/DevoteMeMC/config.yml` to set your `baseUrl` and optional `apiKey`. +5. (Optional) Install **DecentHolograms** if you wish to use the hologram features. +6. Run `/votd manage refresh` to fetch the initial content. + +## Commands + +### Player Commands +- `/votd` - View the current Verse of the Day. +- `/devotion` - Open today's devotion in a book GUI. + +### Admin Commands +- `/votd manage add ` - Create a VOTD hologram at your current location. +- `/votd manage remove ` - Remove a specific VOTD hologram. +- `/votd manage removeNearest [radius]` - Remove the closest VOTD hologram within a radius (default 5 blocks). +- `/votd manage list` - List all saved VOTD hologram locations. +- `/votd manage reset` - Delete all VOTD holograms and clear saved locations. +- `/votd manage refresh` - Manually force a refresh of the VOTD and Devotion from the API. + +## Permissions + +- `devoteme.votd` - Allows viewing the Verse of the Day (Default: true). +- `devoteme.devotion` - Allows viewing the daily devotion (Default: true). +- `devoteme.votd.manage` - Allows access to all `/votd manage` subcommands (Default: op). + +## Configuration + +The plugin uses **Adventure MiniMessage** for all messages. You can use tags like ``, ``, ``, and ``. + +### Placeholders +In `config.yml`, you can use the following placeholders in VOTD-related messages and holograms: +- `` - The scripture reference (e.g., John 3:16). +- `` - The text of the verse. +- `` - The display date for the content. +- `` - A URL to the full passage. + +## Data Storage +Hologram locations are stored in `plugins/DevoteMeMC/votd-locations.yml`. This file is managed automatically via commands, but can be manually edited if necessary. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f2d1470 --- /dev/null +++ b/pom.xml @@ -0,0 +1,57 @@ + + 4.0.0 + + com.devoteme + DevoteMeMC + 1.0.0 + DevoteMeMC + + + 17 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + + io.papermc.paper + paper-api + 1.20.6-R0.1-SNAPSHOT + provided + + + + com.google.code.gson + gson + 2.11.0 + provided + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${java.version} + + + + + diff --git a/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java b/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java new file mode 100644 index 0000000..c73e505 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java @@ -0,0 +1,118 @@ +package com.devoteme.minecraft; + +import com.devoteme.minecraft.api.DevoteMeApiClient; +import com.devoteme.minecraft.api.DevoteMeParser; +import com.devoteme.minecraft.cache.ContentCache; +import com.devoteme.minecraft.commands.DevotionCommand; +import com.devoteme.minecraft.commands.VotdCommand; +import com.devoteme.minecraft.holograms.DecentHologramsService; +import com.devoteme.minecraft.holograms.HologramService; +import com.devoteme.minecraft.listeners.LoginListener; +import com.devoteme.minecraft.model.Votd; +import com.devoteme.minecraft.storage.VotdLocationStore; +import com.devoteme.minecraft.util.Text; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.concurrent.CompletableFuture; + +public class DevoteMePlugin extends JavaPlugin { + + private DevoteMeApiClient api; + private ContentCache cache; + private VotdLocationStore locationStore; + private HologramService holograms; + + @Override + public void onEnable() { + saveDefaultConfig(); + + this.cache = new ContentCache(); + this.api = new DevoteMeApiClient( + getConfig().getString("devoteme.baseUrl", ""), + getConfig().getString("devoteme.apiKey", "") + ); + this.locationStore = new VotdLocationStore(this); + + // Holograms (soft) + this.holograms = new DecentHologramsService(this, cache, locationStore); + + // listeners + Bukkit.getPluginManager().registerEvents(new LoginListener(this), this); + + // commands + PluginCommand votd = getCommand("votd"); + if (votd != null) { + VotdCommand cmd = new VotdCommand(this, cache, locationStore, holograms); + votd.setExecutor(cmd); + votd.setTabCompleter(cmd); + } + + PluginCommand devotion = getCommand("devotion"); + if (devotion != null) { + DevotionCommand cmd = new DevotionCommand(this, cache); + devotion.setExecutor(cmd); + } + + // Load stored hologram locations + locationStore.load(); + + // Initial refresh now + refreshAllAsync().whenComplete((v, ex) -> { + if (ex != null) getLogger().warning("Initial refresh failed: " + ex.getMessage()); + Bukkit.getScheduler().runTask(this, () -> holograms.updateAllFromCache()); + }); + + // Schedule repeating refresh every refreshHours + long hours = getConfig().getLong("devoteme.refreshHours", 12); + if (hours < 1) hours = 12; + long ticks = hours * 60L * 60L * 20L; + + Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> { + refreshAllAsync().whenComplete((v, ex) -> { + if (ex != null) getLogger().warning("Scheduled refresh failed: " + ex.getMessage()); + Bukkit.getScheduler().runTask(this, () -> holograms.updateAllFromCache()); + }); + }, ticks, ticks); + } + + public CompletableFuture refreshAllAsync() { + CompletableFuture a = refreshVotdAsync(); + CompletableFuture b = refreshDevotionAsync(); + return CompletableFuture.allOf(a, b); + } + + public CompletableFuture refreshVotdAsync() { + return api.getJson("/votd/get") + .thenApply(DevoteMeParser::parseVotd) + .thenAccept(cache::setVotd); + } + + public CompletableFuture refreshDevotionAsync() { + return api.getJson("/devotion/get") + .thenApply(DevoteMeParser::parseDevotion) + .thenAccept(cache::setDevotion); + } + + public void sendVotdTo(Player p) { + Votd v = cache.getVotd(); + if (v == null) { + p.sendMessage(Text.render(getConfig().getString("messages.loading", "Loading..."))); + return; + } + + for (String line : getConfig().getStringList("votd.chatFormat")) { + p.sendMessage(Text.render(line, v)); + } + } + + public void sendNoPerm(org.bukkit.command.CommandSender sender) { + sender.sendMessage(Text.render(getConfig().getString("messages.noPermission", "No permission."))); + } + + public ContentCache getCache() { return cache; } + public VotdLocationStore getLocationStore() { return locationStore; } + public HologramService getHolograms() { return holograms; } +} diff --git a/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java b/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java new file mode 100644 index 0000000..a4718cd --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java @@ -0,0 +1,41 @@ +package com.devoteme.minecraft.api; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.CompletableFuture; + +public class DevoteMeApiClient { + private final HttpClient client = HttpClient.newHttpClient(); + private final String baseUrl; + private final String apiKeyOrNull; + + public DevoteMeApiClient(String baseUrl, String apiKey) { + String b = baseUrl == null ? "" : baseUrl.trim(); + this.baseUrl = b.endsWith("/") ? b.substring(0, b.length() - 1) : b; + this.apiKeyOrNull = (apiKey == null || apiKey.isBlank()) ? null : apiKey.trim(); + } + + public CompletableFuture getJson(String path) { + if (baseUrl.isEmpty()) { + return CompletableFuture.failedFuture(new IllegalStateException("devoteme.baseUrl is empty")); + } + + HttpRequest.Builder req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .GET() + .header("Accept", "application/json"); + + if (apiKeyOrNull != null) { + req.header("x-access-token", apiKeyOrNull); + } + + return client.sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenApply(resp -> { + int sc = resp.statusCode(); + if (sc < 200 || sc >= 300) throw new RuntimeException("HTTP " + sc + " for " + path); + return resp.body(); + }); + } +} diff --git a/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java new file mode 100644 index 0000000..e54c462 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java @@ -0,0 +1,79 @@ +package com.devoteme.minecraft.api; + +import com.google.gson.*; +import com.devoteme.minecraft.model.Devotion; +import com.devoteme.minecraft.model.Votd; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +public class DevoteMeParser { + public static Votd parseVotd(String json) { + JsonElement el = JsonParser.parseString(json); + JsonObject o = el.isJsonObject() ? el.getAsJsonObject() : new JsonObject(); + + String reference = pick(o, "reference", "ref", "scriptureReference", "title"); + String verse = pick(o, "verse", "content", "text", "scripture", "passage"); + String link = pick(o, "link", "url", "passageUrl"); + String date = pick(o, "date", "day", "displayDate"); + + if (date == null || date.isBlank()) { + date = LocalDate.now().toString(); + } + + if (reference == null) reference = "Verse of the Day"; + if (verse == null) verse = "(No verse content returned by API)"; + if (link == null) link = ""; + + return new Votd(reference, verse, link, date); + } + + public static Devotion parseDevotion(String json) { + JsonElement el = JsonParser.parseString(json); + JsonObject o = el.isJsonObject() ? el.getAsJsonObject() : new JsonObject(); + + String title = pick(o, "title", "heading"); + String reading = pick(o, "reading", "themeVerse", "verse", "scripture"); + String bible = pick(o, "bibleInOneYear", "soulFood", "bibleReadingPlan", "plan"); + + List content = new ArrayList<>(); + + // content as array of paragraphs + JsonElement c = o.get("content"); + if (c != null && c.isJsonArray()) { + for (JsonElement p : c.getAsJsonArray()) { + if (p != null && p.isJsonPrimitive()) content.add(clean(p.getAsString())); + } + } else if (c != null && c.isJsonPrimitive()) { + content.add(clean(c.getAsString())); + } else { + // fallback possible keys + String body = pick(o, "body", "text"); + if (body != null) content.add(body); + } + + if (title == null) title = "Today's Devotion"; + if (reading == null) reading = ""; + if (bible == null) bible = ""; + + return new Devotion(title, reading, content, bible); + } + + private static String pick(JsonObject o, String... keys) { + for (String k : keys) { + if (!o.has(k)) continue; + JsonElement e = o.get(k); + if (e == null || e.isJsonNull()) continue; + if (e.isJsonPrimitive()) return clean(e.getAsString()); + } + return null; + } + + private static String clean(String text) { + if (text == null) return ""; + // Strips common Discord-style timestamps that might be accidentally included + // e.g. [10:44 AM]Wednesday, 18 February 2026 10:44 AM + return text.replaceAll("\\[\\d{1,2}:\\d{2}\\s?[AP]M\\].*?(\\d{4}.*?\\d{1,2}:\\d{2}\\s?[AP]M|\\d{4})", "").trim(); + } +} diff --git a/src/main/java/com/devoteme/minecraft/cache/ContentCache.java b/src/main/java/com/devoteme/minecraft/cache/ContentCache.java new file mode 100644 index 0000000..0e77c70 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/cache/ContentCache.java @@ -0,0 +1,15 @@ +package com.devoteme.minecraft.cache; + +import com.devoteme.minecraft.model.Devotion; +import com.devoteme.minecraft.model.Votd; + +public class ContentCache { + private volatile Votd votd; + private volatile Devotion devotion; + + public Votd getVotd() { return votd; } + public Devotion getDevotion() { return devotion; } + + public void setVotd(Votd v) { this.votd = v; } + public void setDevotion(Devotion d) { this.devotion = d; } +} diff --git a/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java b/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java new file mode 100644 index 0000000..2cb97e0 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java @@ -0,0 +1,73 @@ +package com.devoteme.minecraft.commands; + +import com.devoteme.minecraft.DevoteMePlugin; +import com.devoteme.minecraft.model.Devotion; +import com.devoteme.minecraft.util.BookBuilder; +import com.devoteme.minecraft.util.Text; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; + +public class DevotionCommand implements CommandExecutor { + private final DevoteMePlugin plugin; + private final com.devoteme.minecraft.cache.ContentCache cache; + + public DevotionCommand(DevoteMePlugin plugin, com.devoteme.minecraft.cache.ContentCache cache) { + this.plugin = plugin; + this.cache = cache; + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player p)) return true; + if (!p.hasPermission("devoteme.devotion")) { + plugin.sendNoPerm(p); + return true; + } + + Devotion d = cache.getDevotion(); + if (d == null) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.loading", "Loading..."))); + return true; + } + + List body = new ArrayList<>(); + body.add(Component.text(d.title(), NamedTextColor.GOLD, TextDecoration.BOLD)); + if (d.reading() != null && !d.reading().isBlank()) { + body.add(Component.text(d.reading(), NamedTextColor.DARK_AQUA, TextDecoration.ITALIC)); + } + body.add(Component.empty()); + + for (String para : d.content()) { + if (para == null || para.isBlank()) continue; + body.add(Component.text(para, NamedTextColor.BLACK)); + } + + Component footer = null; + if (d.bibleInOneYear() != null && !d.bibleInOneYear().isBlank()) { + footer = Component.text() + .append(Component.text("Bible in One Year", NamedTextColor.GOLD, TextDecoration.BOLD)) + .append(Component.newline()) + .append(Component.newline()) + .append(Component.text(d.bibleInOneYear(), NamedTextColor.BLACK)) + .build(); + } + + BookBuilder.openBook( + p, + "Devotion", + "DevoteMe", + body, + footer + ); + + return true; + } +} diff --git a/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java new file mode 100644 index 0000000..bfd0af5 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java @@ -0,0 +1,224 @@ +package com.devoteme.minecraft.commands; + +import com.devoteme.minecraft.DevoteMePlugin; +import com.devoteme.minecraft.cache.ContentCache; +import com.devoteme.minecraft.holograms.HologramService; +import com.devoteme.minecraft.storage.VotdLocationStore; +import com.devoteme.minecraft.util.Text; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.*; +import org.bukkit.entity.Player; + +import java.util.*; +import java.util.stream.Collectors; + +public class VotdCommand implements CommandExecutor, TabCompleter { + + private final DevoteMePlugin plugin; + private final ContentCache cache; + private final VotdLocationStore store; + private final HologramService holograms; + + public VotdCommand(DevoteMePlugin plugin, ContentCache cache, VotdLocationStore store, HologramService holograms) { + this.plugin = plugin; + this.cache = cache; + this.store = store; + this.holograms = holograms; + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player p)) return true; + + if (!p.hasPermission("devoteme.votd")) { + plugin.sendNoPerm(p); + return true; + } + + if (args.length == 0) { + if (cache.getVotd() == null) { + plugin.refreshVotdAsync().whenComplete((v, ex) -> { + if (ex == null) { + Bukkit.getScheduler().runTask(plugin, () -> holograms.updateAllFromCache()); + } + }); + } + plugin.sendVotdTo(p); + return true; + } + + if (!args[0].equalsIgnoreCase("manage")) { + plugin.sendVotdTo(p); + return true; + } + + if (!p.hasPermission("devoteme.votd.manage")) { + plugin.sendNoPerm(p); + return true; + } + + if (args.length < 2) { + p.sendMessage(Text.render("Usage: /votd manage ")); + return true; + } + + String sub = args[1].toLowerCase(Locale.ROOT); + + switch (sub) { + case "add" -> { + if (!holograms.isEnabled()) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled"))); + return true; + } + if (args.length < 3) { + p.sendMessage(Text.render("Usage: /votd manage add ")); + return true; + } + String name = args[2].toLowerCase(Locale.ROOT); + + if (store.exists(name)) { + p.sendMessage(Text.render("That name already exists.")); + return true; + } + + org.bukkit.block.Block target = p.getTargetBlockExact(5); + Location loc; + if (target != null) { + loc = target.getLocation().add(0.5, 1.2, 0.5); + } else { + loc = p.getLocation().clone().add(p.getLocation().getDirection().multiply(2)); + loc.setY(loc.getY() + 1.2); + } + + store.put(name, loc); + store.save(); + + holograms.createOrUpdate(name, loc); + p.sendMessage(Text.render("Added VOTD hologram location: " + name + "")); + } + + case "remove" -> { + if (!holograms.isEnabled()) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled"))); + return true; + } + if (args.length < 3) { + p.sendMessage(Text.render("Usage: /votd manage remove ")); + return true; + } + String name = args[2].toLowerCase(Locale.ROOT); + + if (!store.exists(name)) { + p.sendMessage(Text.render("No such location: " + name + "")); + return true; + } + + holograms.delete(name); + store.remove(name); + store.save(); + + p.sendMessage(Text.render("Removed: " + name + "")); + } + + case "removenearest" -> { + if (!holograms.isEnabled()) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled"))); + return true; + } + double radius = 7.0; + if (args.length >= 3) { + try { radius = Double.parseDouble(args[2]); } catch (NumberFormatException ignored) {} + } + + String nearest = store.findNearestName(p.getLocation(), radius); + if (nearest == null) { + p.sendMessage(Text.render("No saved VOTD location within " + radius + " blocks.")); + return true; + } + + holograms.delete(nearest); + store.remove(nearest); + store.save(); + + p.sendMessage(Text.render("Removed nearest: " + nearest + "")); + } + + case "list" -> { + var all = store.getAll(); + if (all.isEmpty()) { + p.sendMessage(Text.render("No VOTD hologram locations saved.")); + return true; + } + + p.sendMessage(Text.render("VOTD Locations:")); + for (var e : all.entrySet()) { + Location l = e.getValue(); + p.sendMessage(Text.render("- " + e.getKey() + " (" + + l.getWorld().getName() + " " + + String.format(Locale.ROOT, "%.1f %.1f %.1f", l.getX(), l.getY(), l.getZ()) + ")")); + } + } + + case "reset" -> { + if (!holograms.isEnabled()) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled"))); + return true; + } + holograms.deleteAllManaged(); + store.clear(); + store.save(); + + p.sendMessage(Text.render("All VOTD hologram locations cleared.")); + } + + case "refresh" -> { + p.sendMessage(Text.render("Refreshing DevoteMe content...")); + plugin.refreshAllAsync().whenComplete((v, ex) -> { + Bukkit.getScheduler().runTask(plugin, () -> { + if (ex != null) { + p.sendMessage(Text.render("Refresh failed: " + ex.getMessage() + "")); + return; + } + holograms.updateAllFromCache(); + p.sendMessage(Text.render("Refreshed.")); + }); + }); + } + + default -> p.sendMessage(Text.render("Unknown subcommand.")); + } + + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String alias, String[] args) { + if (!(sender instanceof Player p)) return List.of(); + + if (args.length == 1) { + return startsWith(List.of("manage"), args[0]); + } + + if (args.length == 2 && args[0].equalsIgnoreCase("manage")) { + return startsWith(List.of("add", "remove", "removeNearest", "list", "reset", "refresh"), args[1]); + } + + if (args.length == 3 && args[0].equalsIgnoreCase("manage")) { + String sub = args[1].toLowerCase(Locale.ROOT); + if (sub.equals("remove")) { + return startsWith(new ArrayList<>(store.getAll().keySet()), args[2]); + } + } + + return List.of(); + } + + private List startsWith(List options, String token) { + String t = token == null ? "" : token.toLowerCase(Locale.ROOT); + return options.stream() + .filter(s -> s.toLowerCase(Locale.ROOT).startsWith(t)) + .sorted() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java new file mode 100644 index 0000000..c766f12 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java @@ -0,0 +1,163 @@ +package com.devoteme.minecraft.holograms; + +import com.devoteme.minecraft.cache.ContentCache; +import com.devoteme.minecraft.model.Votd; +import com.devoteme.minecraft.storage.VotdLocationStore; +import com.devoteme.minecraft.util.Text; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class DecentHologramsService implements HologramService { + + private final JavaPlugin plugin; + private final ContentCache cache; + private final VotdLocationStore store; + + private final boolean enabled; + + // Reflection handles + private Class dhapiClass; + private Method getHologram; + private Method createHologram; + private Method removeHologram; + private Method setHologramLines; + + public DecentHologramsService(JavaPlugin plugin, ContentCache cache, VotdLocationStore store) { + this.plugin = plugin; + this.cache = cache; + this.store = store; + + boolean cfg = plugin.getConfig().getBoolean("holograms.enabled", true); + Plugin dh = Bukkit.getPluginManager().getPlugin("DecentHolograms"); + + if (!cfg || dh == null || !dh.isEnabled()) { + this.enabled = false; + return; + } + + boolean success = false; + try { + this.dhapiClass = Class.forName("eu.decentsoftware.holograms.api.DHAPI"); + this.getHologram = dhapiClass.getMethod("getHologram", String.class); + this.createHologram = dhapiClass.getMethod("createHologram", String.class, Location.class); + this.removeHologram = dhapiClass.getMethod("removeHologram", String.class); + + Method tmpSet = null; + for (Method m : dhapiClass.getMethods()) { + if (!m.getName().equals("setHologramLines")) continue; + Class[] p = m.getParameterTypes(); + if (p.length == 2 && List.class.isAssignableFrom(p[1])) { + tmpSet = m; + break; + } + } + if (tmpSet == null) throw new NoSuchMethodException("DHAPI.setHologramLines not found"); + this.setHologramLines = tmpSet; + + success = true; + plugin.getLogger().info("DecentHolograms detected. VOTD holograms enabled."); + } catch (Throwable t) { + plugin.getLogger().warning("DecentHolograms found but API reflection failed. Disabling holograms: " + t.getMessage()); + } + this.enabled = success; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public void createOrUpdate(String name, Location loc) { + if (!enabled) return; + String id = id(name); + + try { + Object holo = getHologram.invoke(null, id); + if (holo == null) { + holo = createHologram.invoke(null, id, loc); + } else { + // Try to teleport if it moved + try { + Method teleport = dhapiClass.getMethod("teleportHologram", String.class, Location.class); + teleport.invoke(null, id, loc); + } catch (NoSuchMethodException ignored) { + // If teleport method not found, we might have to remove/recreate as fallback, + // but we try to avoid it. + } + } + + List lines = buildLines(); + setHologramLines.invoke(null, holo, lines); + } catch (Throwable t) { + plugin.getLogger().warning("Failed to create/update hologram " + id + ": " + t.getMessage()); + } + } + + @Override + public void delete(String name) { + if (!enabled) return; + String id = id(name); + try { + removeHologram.invoke(null, id); + } catch (Throwable t) { + // ignore + } + } + + @Override + public void deleteAllManaged() { + if (!enabled) return; + for (String name : store.getAll().keySet()) { + delete(name); + } + } + + @Override + public void updateAllFromCache() { + if (!enabled) return; + for (Map.Entry e : store.getAll().entrySet()) { + createOrUpdate(e.getKey(), e.getValue()); + } + } + + private String id(String name) { + return "devoteme_votd_" + name.toLowerCase(Locale.ROOT); + } + + private List buildLines() { + Votd v = cache.getVotd(); + List templates = plugin.getConfig().getStringList("holograms.lines"); + + List out = new ArrayList<>(); + if (v == null) { + out.add("§eLoading Verse of the Day..."); + return out; + } + + // Prepare a version of the verse without any accidental MiniMessage tags for the hologram + String plainVerse = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(v.verse())); + + for (String t : templates) { + if (t.contains("") || t.contains("{verse}")) { + List wrapped = Text.wrap(plainVerse, 30); // Narrower for holograms + for (String verseLine : wrapped) { + String lineTemplate = t.replace("", verseLine).replace("{verse}", verseLine); + out.add(Text.toLegacy(lineTemplate, v)); + } + } else { + out.add(Text.toLegacy(t, v)); + } + } + return out; + } +} diff --git a/src/main/java/com/devoteme/minecraft/holograms/HologramService.java b/src/main/java/com/devoteme/minecraft/holograms/HologramService.java new file mode 100644 index 0000000..dc74e56 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/holograms/HologramService.java @@ -0,0 +1,12 @@ +package com.devoteme.minecraft.holograms; + +import org.bukkit.Location; + +public interface HologramService { + boolean isEnabled(); + + void createOrUpdate(String name, Location loc); + void delete(String name); + void deleteAllManaged(); + void updateAllFromCache(); +} diff --git a/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java b/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java new file mode 100644 index 0000000..1ca0784 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java @@ -0,0 +1,37 @@ +package com.devoteme.minecraft.listeners; + +import com.devoteme.minecraft.DevoteMePlugin; +import com.devoteme.minecraft.model.Votd; +import com.devoteme.minecraft.util.Text; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +public class LoginListener implements Listener { + private final DevoteMePlugin plugin; + + public LoginListener(DevoteMePlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onJoin(PlayerJoinEvent e) { + if (!plugin.getConfig().getBoolean("login.sendVotd", true)) return; + + Player p = e.getPlayer(); + if (!p.hasPermission("devoteme.votd")) return; + + Bukkit.getScheduler().runTaskLater(plugin, () -> { + Votd v = plugin.getCache().getVotd(); + if (v == null) { + p.sendMessage(Text.render(plugin.getConfig().getString("messages.loading", "Loading..."))); + return; + } + for (String line : plugin.getConfig().getStringList("login.format")) { + p.sendMessage(Text.render(line, v)); + } + }, 2L); + } +} diff --git a/src/main/java/com/devoteme/minecraft/model/Devotion.java b/src/main/java/com/devoteme/minecraft/model/Devotion.java new file mode 100644 index 0000000..d3fe895 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/model/Devotion.java @@ -0,0 +1,5 @@ +package com.devoteme.minecraft.model; + +import java.util.List; + +public record Devotion(String title, String reading, List content, String bibleInOneYear) {} diff --git a/src/main/java/com/devoteme/minecraft/model/Votd.java b/src/main/java/com/devoteme/minecraft/model/Votd.java new file mode 100644 index 0000000..379ce26 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/model/Votd.java @@ -0,0 +1,3 @@ +package com.devoteme.minecraft.model; + +public record Votd(String reference, String verse, String link, String dateText) {} diff --git a/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java b/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java new file mode 100644 index 0000000..b363e7a --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java @@ -0,0 +1,106 @@ +package com.devoteme.minecraft.storage; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +public class VotdLocationStore { + private final JavaPlugin plugin; + private final File file; + private YamlConfiguration yml; + + private final Map locations = new HashMap<>(); + + public VotdLocationStore(JavaPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "votd-locations.yml"); + } + + public void load() { + if (!file.exists()) { + yml = new YamlConfiguration(); + yml.set("locations", new HashMap<>()); + save(); + } + yml = YamlConfiguration.loadConfiguration(file); + locations.clear(); + + var sec = yml.getConfigurationSection("locations"); + if (sec == null) return; + + for (String name : sec.getKeys(false)) { + String base = "locations." + name + "."; + String worldName = yml.getString(base + "world"); + World w = worldName == null ? null : Bukkit.getWorld(worldName); + if (w == null) continue; + + double x = yml.getDouble(base + "x"); + double y = yml.getDouble(base + "y"); + double z = yml.getDouble(base + "z"); + float yaw = (float) yml.getDouble(base + "yaw", 0.0); + float pitch = (float) yml.getDouble(base + "pitch", 0.0); + + Location loc = new Location(w, x, y, z, yaw, pitch); + locations.put(name.toLowerCase(Locale.ROOT), loc); + } + } + + public void save() { + if (yml == null) yml = new YamlConfiguration(); + yml.set("locations", null); + + for (var e : locations.entrySet()) { + String name = e.getKey(); + Location loc = e.getValue(); + String base = "locations." + name + "."; + yml.set(base + "world", loc.getWorld().getName()); + yml.set(base + "x", loc.getX()); + yml.set(base + "y", loc.getY()); + yml.set(base + "z", loc.getZ()); + yml.set(base + "yaw", loc.getYaw()); + yml.set(base + "pitch", loc.getPitch()); + } + + try { + yml.save(file); + } catch (IOException ex) { + plugin.getLogger().warning("Failed to save votd-locations.yml: " + ex.getMessage()); + } + } + + public boolean exists(String name) { return locations.containsKey(norm(name)); } + public Location get(String name) { return locations.get(norm(name)); } + public Map getAll() { return Collections.unmodifiableMap(locations); } + + public void put(String name, Location loc) { locations.put(norm(name), loc); } + public void remove(String name) { locations.remove(norm(name)); } + + public void clear() { locations.clear(); } + + public String findNearestName(Location at, double radius) { + if (at.getWorld() == null) return null; + double best = radius * radius; + String bestName = null; + + for (var e : locations.entrySet()) { + Location l = e.getValue(); + if (l.getWorld() == null || !l.getWorld().equals(at.getWorld())) continue; + double d2 = l.distanceSquared(at); + if (d2 <= best) { + best = d2; + bestName = e.getKey(); + } + } + return bestName; + } + + private String norm(String s) { + return s == null ? "" : s.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java new file mode 100644 index 0000000..77aebd1 --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java @@ -0,0 +1,91 @@ +package com.devoteme.minecraft.util; + +import net.kyori.adventure.inventory.Book; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; + +public class BookBuilder { + + private static final int PAGE_CHAR_LIMIT = 320; + + private static List splitComponent(Component part, String plain, int limit) { + List results = new ArrayList<>(); + String[] words = plain.split(" "); + StringBuilder sb = new StringBuilder(); + + for (String word : words) { + if (sb.length() + word.length() + 1 > limit && sb.length() > 0) { + String chunkText = sb.toString().trim(); + results.add(part.replaceText(config -> config.matchLiteral(plain).replacement(chunkText))); + sb = new StringBuilder(); + } + sb.append(word).append(" "); + } + if (sb.length() > 0) { + String chunkText = sb.toString().trim(); + results.add(part.replaceText(config -> config.matchLiteral(plain).replacement(chunkText))); + } + return results; + } + + public static void openBook(Player p, String title, String author, List body, Component footer) { + List pages = new ArrayList<>(); + Component currentPage = Component.empty(); + int charCount = 0; + + for (Component part : body) { + String plain = PlainTextComponentSerializer.plainText().serialize(part); + + // If this single part is longer than the entire page limit, we need to split it + if (plain.length() > PAGE_CHAR_LIMIT) { + // First, push current page if not empty + if (charCount > 0) { + pages.add(currentPage); + currentPage = Component.empty(); + charCount = 0; + } + + // Split the long part into smaller chunks + List chunks = splitComponent(part, plain, PAGE_CHAR_LIMIT); + for (int i = 0; i < chunks.size(); i++) { + Component chunk = chunks.get(i); + if (i == chunks.size() - 1) { + // Last chunk might fit with more stuff + currentPage = chunk.append(Component.newline()); + charCount = PlainTextComponentSerializer.plainText().serialize(chunk).length() + 1; + } else { + pages.add(chunk); + } + } + continue; + } + + if (charCount + plain.length() > PAGE_CHAR_LIMIT && charCount > 0) { + pages.add(currentPage); + currentPage = Component.empty(); + charCount = 0; + } + currentPage = currentPage.append(part).append(Component.newline()); + charCount += plain.length() + 1; + } + + if (charCount > 0) { + pages.add(currentPage); + } + + if (footer != null) { + pages.add(footer); + } + + if (pages.isEmpty()) { + pages.add(Component.text("(No content)")); + } + + Book book = Book.book(Component.text(title), Component.text(author), pages); + p.openBook(book); + } +} diff --git a/src/main/java/com/devoteme/minecraft/util/Text.java b/src/main/java/com/devoteme/minecraft/util/Text.java new file mode 100644 index 0000000..21d05ff --- /dev/null +++ b/src/main/java/com/devoteme/minecraft/util/Text.java @@ -0,0 +1,58 @@ +package com.devoteme.minecraft.util; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import com.devoteme.minecraft.model.Votd; + +import java.util.ArrayList; +import java.util.List; + +public class Text { + private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); + private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection(); + + public static Component render(String template, Votd v) { + if (template == null) return Component.empty(); + return MINI_MESSAGE.deserialize(template, + Placeholder.unparsed("reference", safe(v.reference())), + Placeholder.unparsed("verse", safe(v.verse())), + Placeholder.unparsed("link", safe(v.link())), + Placeholder.unparsed("date", safe(v.dateText())) + ); + } + + public static Component render(String text) { + if (text == null) return Component.empty(); + return MINI_MESSAGE.deserialize(text); + } + + public static String toLegacy(String template, Votd v) { + if (template == null) return ""; + return LEGACY_SERIALIZER.serialize(render(template, v)); + } + + public static List wrap(String text, int limit) { + List lines = new ArrayList<>(); + if (text == null || text.isBlank()) return lines; + + String[] words = text.split(" "); + StringBuilder sb = new StringBuilder(); + for (String word : words) { + if (sb.length() + word.length() + 1 > limit && sb.length() > 0) { + lines.add(sb.toString().trim()); + sb = new StringBuilder(); + } + sb.append(word).append(" "); + } + if (sb.length() > 0) { + lines.add(sb.toString().trim()); + } + return lines; + } + + private static String safe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..827de32 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,33 @@ +devoteme: + baseUrl: "https://your-devoteme-api-domain.com" + apiKey: "" # optional; set if you require x-access-token + refreshHours: 12 + +login: + sendVotd: true + format: + - "Verse of the Day" + - "" + - "" + - "" + - "Use /votd to view again." + +votd: + chatFormat: + - "Verse of the Day ()" + - "" + - "" + - "'>" + +holograms: + enabled: true + lines: + - "Verse of the Day" + - "" + - "" + - "" + +messages: + loading: "DevoteMe content is loading..." + noPermission: "No permission." + hologramsDisabled: "Holograms are disabled or DecentHolograms is missing." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..98b59c7 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,21 @@ +name: DevoteMeMC +version: 1.0.0 +main: com.devoteme.minecraft.DevoteMePlugin +api-version: "1.20" +softdepend: [DecentHolograms] + +commands: + votd: + description: View the Verse of the Day + usage: /votd [manage] + devotion: + description: Open today's devotion + usage: /devotion + +permissions: + devoteme.votd: + default: true + devoteme.devotion: + default: true + devoteme.votd.manage: + default: op diff --git a/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java new file mode 100644 index 0000000..ce5c34f --- /dev/null +++ b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java @@ -0,0 +1,42 @@ +package com.devoteme.minecraft.api; + +import com.devoteme.minecraft.model.Devotion; +import com.devoteme.minecraft.model.Votd; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class DevoteMeParserTest { + + @Test + public void testParseVotd() { + String json = "{\"reference\": \"John 3:16\", \"verse\": \"For God so loved the world...\", \"link\": \"https://bible.com/1\", \"date\": \"2024-05-20\"}"; + Votd votd = DevoteMeParser.parseVotd(json); + assertEquals("John 3:16", votd.reference()); + assertEquals("For God so loved the world...", votd.verse()); + assertEquals("https://bible.com/1", votd.link()); + assertEquals("2024-05-20", votd.dateText()); + } + + @Test + public void testParseDevotion() { + String json = "{\"title\": \"Today's Heading\", \"reading\": \"Psalm 23\", \"content\": [\"Para 1\", \"Para 2\"], \"bibleInOneYear\": \"Year 1\"}"; + Devotion devotion = DevoteMeParser.parseDevotion(json); + assertEquals("Today's Heading", devotion.title()); + assertEquals("Psalm 23", devotion.reading()); + assertEquals(2, devotion.content().size()); + assertEquals("Para 1", devotion.content().get(0)); + assertEquals("Year 1", devotion.bibleInOneYear()); + } + + @Test + public void testClean() { + String input = "[10:44 AM]Wednesday, 18 February 2026 10:44 AM Real Content"; + Votd votd = DevoteMeParser.parseVotd("{\"verse\": \"" + input + "\"}"); + assertEquals("Real Content", votd.verse()); + + String input2 = "[9:00 PM]Monday, 1 January 2024 Content here"; + Votd votd2 = DevoteMeParser.parseVotd("{\"verse\": \"" + input2 + "\"}"); + assertEquals("Content here", votd2.verse()); + } +} diff --git a/src/test/java/com/devoteme/minecraft/util/TextTest.java b/src/test/java/com/devoteme/minecraft/util/TextTest.java new file mode 100644 index 0000000..dfcb6a6 --- /dev/null +++ b/src/test/java/com/devoteme/minecraft/util/TextTest.java @@ -0,0 +1,25 @@ +package com.devoteme.minecraft.util; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +public class TextTest { + + @Test + public void testWrap() { + String text = "This is a long sentence that should be wrapped into multiple lines."; + List wrapped = Text.wrap(text, 20); + + assertFalse(wrapped.isEmpty()); + for (String line : wrapped) { + assertTrue(line.length() <= 20, "Line too long: " + line); + } + + assertEquals("This is a long", wrapped.get(0)); + assertEquals("sentence that", wrapped.get(1)); + assertEquals("should be wrapped", wrapped.get(2)); + assertEquals("into multiple", wrapped.get(3)); + assertEquals("lines.", wrapped.get(4)); + } +}