result = new ArrayList<>();
+ String lower = prefix.toLowerCase(Locale.ROOT);
+ for (String opt : options) {
+ if (opt.toLowerCase(Locale.ROOT).startsWith(lower)) result.add(opt);
+ }
+ return result;
+ }
+
+ private void broadcastAdmins(String miniMsg) {
+ Bukkit.getOnlinePlayers().stream()
+ .filter(p -> p.hasPermission("mclogger.consent.admin"))
+ .forEach(p -> p.sendMessage(MM.deserialize(miniMsg)));
+ }
+}
diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
new file mode 100644
index 0000000..91c05b2
--- /dev/null
+++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
@@ -0,0 +1,263 @@
+package de.simolzimol.mclogger.consent.database;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import de.simolzimol.mclogger.consent.ConsentConfig;
+import de.simolzimol.mclogger.consent.ConsentPlugin;
+
+import java.sql.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+
+/**
+ * HikariCP-backed database client for MCConsent.
+ *
+ * Tables managed by this class:
+ *
+ * - {@code player_consent} – stores accepted consents per UUID + version
+ * - {@code player_consent_declines} – audit trail of declined consents
+ *
+ *
+ * Both tables are created automatically on first connection.
+ * If {@code admin.log-to-mclogger-db} is {@code true} consent events are also
+ * inserted into the {@code server_events} table that MCLogger maintains.
+ *
+ * @author SimolZimol
+ */
+public class ConsentDatabase {
+
+ private final ConsentPlugin plugin;
+ private HikariDataSource dataSource;
+
+ public ConsentDatabase(ConsentPlugin plugin) {
+ this.plugin = plugin;
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Lifecycle
+ // ─────────────────────────────────────────────────────────
+
+ /**
+ * Opens the connection pool and ensures the required tables exist.
+ *
+ * @return {@code true} if the connection was established successfully.
+ */
+ public boolean connect() {
+ ConsentConfig cfg = plugin.getConsentConfig();
+
+ HikariConfig hk = new HikariConfig();
+ hk.setDriverClassName("de.simolzimol.mcconsent.lib.mariadb.jdbc.Driver");
+ hk.setJdbcUrl(String.format(
+ "jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
+ cfg.getDbHost(), cfg.getDbPort(), cfg.getDbDatabase(), cfg.isDbSsl()));
+ hk.setUsername(cfg.getDbUsername());
+ hk.setPassword(cfg.getDbPassword());
+ hk.setMaximumPoolSize(cfg.getDbPoolSize());
+ hk.setMinimumIdle(1);
+ hk.setConnectionTimeout(30_000);
+ hk.setIdleTimeout(600_000);
+ hk.setMaxLifetime(1_800_000);
+ hk.setPoolName("MCConsent");
+ hk.addDataSourceProperty("cachePrepStmts", "true");
+ hk.addDataSourceProperty("prepStmtCacheSize", "100");
+
+ try {
+ dataSource = new HikariDataSource(hk);
+ initTables();
+ return true;
+ } catch (Exception e) {
+ plugin.getLogger().log(Level.SEVERE, "[MCConsent] Database connection failed!", e);
+ return false;
+ }
+ }
+
+ /** Closes the connection pool. */
+ public void disconnect() {
+ if (dataSource != null && !dataSource.isClosed()) {
+ dataSource.close();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Table initialisation
+ // ─────────────────────────────────────────────────────────
+
+ private void initTables() throws SQLException {
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+
+ // Accepted consents - primary record of who agreed to what version
+ stmt.execute(
+ "CREATE TABLE IF NOT EXISTS player_consent (" +
+ " uuid VARCHAR(36) NOT NULL," +
+ " username VARCHAR(16) NOT NULL," +
+ " policy_version VARCHAR(64) NOT NULL," +
+ " consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
+ " ip_address VARCHAR(45) NULL," +
+ " PRIMARY KEY (uuid, policy_version)," +
+ " INDEX idx_consent_uuid (uuid)," +
+ " INDEX idx_consent_version (policy_version)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ // Decline audit trail - never deleted (important for GDPR accountability)
+ stmt.execute(
+ "CREATE TABLE IF NOT EXISTS player_consent_declines (" +
+ " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+ " uuid VARCHAR(36) NOT NULL," +
+ " username VARCHAR(16) NOT NULL," +
+ " policy_version VARCHAR(64) NOT NULL," +
+ " declined_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
+ " ip_address VARCHAR(45) NULL," +
+ " INDEX idx_decline_uuid (uuid)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Query helpers
+ // ─────────────────────────────────────────────────────────
+
+ /**
+ * Returns {@code true} if the player has accepted the given policy version.
+ * Runs synchronously; should be called from the main thread or a context
+ * where a brief pause is acceptable (e.g. {@code PlayerLoginEvent}).
+ */
+ public boolean hasConsented(String uuid, String policyVersion) {
+ final String sql =
+ "SELECT 1 FROM player_consent WHERE uuid = ? AND policy_version = ? LIMIT 1";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, uuid);
+ ps.setString(2, policyVersion);
+ try (ResultSet rs = ps.executeQuery()) {
+ return rs.next();
+ }
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] hasConsented() failed", e);
+ // Fail-open: if we can't check, allow the player in (avoids locking everyone out)
+ return true;
+ }
+ }
+
+ /**
+ * Records that the player has accepted the current policy version.
+ * Uses {@code INSERT IGNORE} so duplicate calls are safe.
+ */
+ public void recordConsent(String uuid, String username, String policyVersion, String ip) {
+ final String sql =
+ "INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" +
+ " VALUES (?, ?, ?, ?)";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, uuid);
+ ps.setString(2, username);
+ ps.setString(3, policyVersion);
+ ps.setString(4, ip);
+ ps.executeUpdate();
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
+ }
+ }
+
+ /**
+ * Records that the player declined the current policy version.
+ * Declines are appended to an audit table and are never deleted.
+ */
+ public void recordDecline(String uuid, String username, String policyVersion, String ip) {
+ final String sql =
+ "INSERT INTO player_consent_declines (uuid, username, policy_version, ip_address)" +
+ " VALUES (?, ?, ?, ?)";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, uuid);
+ ps.setString(2, username);
+ ps.setString(3, policyVersion);
+ ps.setString(4, ip);
+ ps.executeUpdate();
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
+ }
+ }
+
+ /**
+ * Removes a player's accepted consent for the given version.
+ * Used by {@code /consent admin revoke } to force re-acceptance.
+ *
+ * @param uuid Player UUID string
+ * @param policyVersion Version to revoke; {@code null} revokes ALL versions
+ */
+ public void revokeConsent(String uuid, String policyVersion) {
+ final String sql = policyVersion == null
+ ? "DELETE FROM player_consent WHERE uuid = ?"
+ : "DELETE FROM player_consent WHERE uuid = ? AND policy_version = ?";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, uuid);
+ if (policyVersion != null) ps.setString(2, policyVersion);
+ ps.executeUpdate();
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] revokeConsent() failed", e);
+ }
+ }
+
+ /**
+ * Returns a list of UUIDs (as strings) that have no consent record for
+ * {@code currentVersion}. Only considers players known to the
+ * {@code players} table (MCLogger integration); returns an empty list if
+ * that table does not exist.
+ */
+ public List getPendingPlayerUuids(String currentVersion) {
+ final String sql =
+ "SELECT p.uuid FROM players p" +
+ " WHERE NOT EXISTS (" +
+ " SELECT 1 FROM player_consent c" +
+ " WHERE c.uuid = p.uuid AND c.policy_version = ?" +
+ " ) LIMIT 100";
+ List result = new ArrayList<>();
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, currentVersion);
+ try (ResultSet rs = ps.executeQuery()) {
+ while (rs.next()) result.add(rs.getString("uuid"));
+ }
+ } catch (SQLException e) {
+ // players table may not exist in standalone mode
+ plugin.getLogger().fine("[MCConsent] getPendingPlayerUuids(): " + e.getMessage());
+ }
+ return result;
+ }
+
+ /**
+ * Optionally writes a consent event into the MCLogger {@code server_events}
+ * table so it shows up in the web panel audit stream.
+ * Silently ignored if the table does not exist or the insert fails.
+ *
+ * @param eventType {@code "consent_accepted"} or {@code "consent_declined"}
+ * @param playerName In-game name for the log message
+ * @param uuid Player UUID string
+ * @param version Policy version string
+ */
+ public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
+ if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
+
+ final String sql =
+ "INSERT INTO server_events (event_type, server_name, message, details)" +
+ " VALUES (?, ?, ?, ?)";
+ String serverName = plugin.getConsentConfig().getDbDatabase();
+ String message = playerName + " " + eventType.replace('_', ' ') + " (v" + version + ")";
+ // Build minimal JSON manually to avoid a Gson dependency
+ String details = "{\"uuid\":\"" + uuid + "\",\"policy_version\":\"" + version + "\"}";
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, eventType);
+ ps.setString(2, serverName);
+ ps.setString(3, message);
+ ps.setString(4, details);
+ ps.executeUpdate();
+ } catch (SQLException ignored) {
+ // Non-critical; server_events table may not exist in standalone mode
+ }
+ }
+}
diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
new file mode 100644
index 0000000..c9c5ee8
--- /dev/null
+++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
@@ -0,0 +1,269 @@
+package de.simolzimol.mclogger.consent.listeners;
+
+import de.simolzimol.mclogger.consent.ConsentConfig;
+import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
+import de.simolzimol.mclogger.consent.ConsentPlugin;
+import de.simolzimol.mclogger.consent.util.MessageUtil;
+import net.kyori.adventure.text.minimessage.MiniMessage;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.*;
+import org.bukkit.event.player.*;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Listens to player lifecycle events and enforces the configured consent mode.
+ *
+ * Three modes:
+ *
+ * - KICK – player is kicked at {@link PlayerLoginEvent} if no consent.
+ * - HOLD – player joins but movement/chat/commands are blocked until
+ * they type {@code /consent accept}. The grace-period timer
+ * runs as a scheduled task and kicks after the configured
+ * timeout.
+ * - REMIND- player joins normally; a repeating task sends reminder
+ * messages until they accept.
+ *
+ *
+ * @author SimolZimol
+ */
+public class ConsentListener implements Listener {
+
+ private final ConsentPlugin plugin;
+ /** Last time a move-blocked message was sent to a player (millis). */
+ private final Map lastMoveMsgTime = new ConcurrentHashMap<>();
+
+ public ConsentListener(ConsentPlugin plugin) {
+ this.plugin = plugin;
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // LoginEvent: KICK mode enforcement
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.HIGHEST)
+ public void onLogin(PlayerLoginEvent event) {
+ ConsentConfig cfg = plugin.getConsentConfig();
+ if (!cfg.isEnabled()) return;
+
+ Player player = event.getPlayer();
+ if (player.hasPermission(cfg.getBypassPermission())) return;
+
+ String uuid = player.getUniqueId().toString();
+ String version = cfg.getPolicyVersion();
+
+ // Only kick immediately in KICK mode; other modes handle it on join
+ if (cfg.getEnforcementMode() == EnforcementMode.KICK) {
+ boolean consented = plugin.getConsentDatabase().hasConsented(uuid, version);
+ if (!consented) {
+ String kickText = MessageUtil.replace(cfg.getMsgKick(), player, cfg);
+ event.disallow(PlayerLoginEvent.Result.KICK_OTHER, kickText);
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // JoinEvent: HOLD / REMIND mode setup
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onJoin(PlayerJoinEvent event) {
+ ConsentConfig cfg = plugin.getConsentConfig();
+ Player player = event.getPlayer();
+
+ if (!cfg.isEnabled()) return;
+ if (player.hasPermission(cfg.getBypassPermission())) return;
+ if (isExemptWorld(player, cfg)) return;
+
+ String uuid = player.getUniqueId().toString();
+ String version = cfg.getPolicyVersion();
+
+ if (plugin.getConsentDatabase().hasConsented(uuid, version)) return;
+
+ EnforcementMode mode = cfg.getEnforcementMode();
+
+ // Send the join prompt
+ String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
+ player.sendMessage(MiniMessage.miniMessage().deserialize(prompt));
+
+ if (mode == EnforcementMode.HOLD) {
+ plugin.getPendingConsent().add(player.getUniqueId());
+ scheduleGraceKick(player);
+ } else if (mode == EnforcementMode.REMIND) {
+ int interval = cfg.getReminderIntervalSeconds();
+ if (interval > 0) {
+ scheduleReminder(player);
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // QuitEvent: clean up
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onQuit(PlayerQuitEvent event) {
+ UUID uuid = event.getPlayer().getUniqueId();
+ plugin.getPendingConsent().remove(uuid);
+ lastMoveMsgTime.remove(uuid);
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // HOLD-mode: block movement
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onMove(PlayerMoveEvent event) {
+ if (!isHoldPending(event.getPlayer())) return;
+ // Block if the player moved to a different block (ignore head rotation)
+ if (event.getFrom().getBlockX() == event.getTo().getBlockX()
+ && event.getFrom().getBlockY() == event.getTo().getBlockY()
+ && event.getFrom().getBlockZ() == event.getTo().getBlockZ()) {
+ return;
+ }
+ event.setCancelled(true);
+ sendThrottled(event.getPlayer(),
+ plugin.getConsentConfig().getMsgHoldMoveBlocked());
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // HOLD-mode: block chat
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onChat(AsyncPlayerChatEvent event) {
+ if (!isHoldPending(event.getPlayer())) return;
+ event.setCancelled(true);
+ // Must run on main thread; schedule a tick delayed task
+ UUID uuid = event.getPlayer().getUniqueId();
+ Bukkit.getScheduler().runTask(plugin, () -> {
+ Player p = Bukkit.getPlayer(uuid);
+ if (p != null) {
+ String msg = MessageUtil.replace(
+ plugin.getConsentConfig().getMsgHoldChatBlocked(), p,
+ plugin.getConsentConfig());
+ p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
+ }
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // HOLD-mode: block commands (except /consent and /pp)
+ // ─────────────────────────────────────────────────────────
+
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onCommand(PlayerCommandPreprocessEvent event) {
+ if (!isHoldPending(event.getPlayer())) return;
+ String cmd = event.getMessage().toLowerCase();
+ // Allow /consent, /privacy, /pp (any of the registered aliases)
+ if (cmd.startsWith("/consent") || cmd.startsWith("/privacy") || cmd.startsWith("/pp")) {
+ return;
+ }
+ event.setCancelled(true);
+ String msg = MessageUtil.replace(
+ plugin.getConsentConfig().getMsgHoldCmdBlocked(),
+ event.getPlayer(), plugin.getConsentConfig());
+ event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Internal helpers
+ // ─────────────────────────────────────────────────────────
+
+ private boolean isHoldPending(Player player) {
+ ConsentConfig cfg = plugin.getConsentConfig();
+ if (!cfg.isEnabled()) return false;
+ if (cfg.getEnforcementMode() != EnforcementMode.HOLD) return false;
+ return plugin.getPendingConsent().contains(player.getUniqueId());
+ }
+
+ private boolean isExemptWorld(Player player, ConsentConfig cfg) {
+ String worldName = player.getWorld().getName();
+ return cfg.getExemptWorlds().contains(worldName);
+ }
+
+ /**
+ * Sends a MiniMessage-formatted message to the player with a cooldown to
+ * avoid flooding the chat (e.g. when the player spams movement).
+ */
+ private void sendThrottled(Player player, String miniMessageText) {
+ long now = System.currentTimeMillis();
+ long cooldown = plugin.getConsentConfig().getMoveMessageCooldownSeconds() * 1000L;
+ Long lastSent = lastMoveMsgTime.get(player.getUniqueId());
+ if (lastSent != null && (now - lastSent) < cooldown) return;
+
+ lastMoveMsgTime.put(player.getUniqueId(), now);
+ String msg = MessageUtil.replace(miniMessageText, player, plugin.getConsentConfig());
+ player.sendMessage(MiniMessage.miniMessage().deserialize(msg));
+ }
+
+ /**
+ * Schedules a task that kicks the player after the grace period if they
+ * still haven't consented. Silently cancelled if the player leaves first
+ * or accepts in time.
+ */
+ private void scheduleGraceKick(Player player) {
+ int graceSec = plugin.getConsentConfig().getGracePeriodSeconds();
+ if (graceSec <= 0) return; // infinite grace
+
+ UUID uuid = player.getUniqueId();
+
+ // Warning at half of the grace period
+ int warnTick = (graceSec / 2) * 20;
+ if (warnTick > 0) {
+ Bukkit.getScheduler().runTaskLater(plugin, () -> {
+ Player p = Bukkit.getPlayer(uuid);
+ if (p == null || !plugin.getPendingConsent().contains(uuid)) return;
+ String msg = MessageUtil.replace(
+ plugin.getConsentConfig().getMsgGraceWarning()
+ .replace("{seconds}", String.valueOf(graceSec / 2)),
+ p, plugin.getConsentConfig());
+ p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
+ }, warnTick);
+ }
+
+ // Final kick
+ Bukkit.getScheduler().runTaskLater(plugin, () -> {
+ Player p = Bukkit.getPlayer(uuid);
+ if (p == null) return;
+ if (!plugin.getPendingConsent().contains(uuid)) return;
+ plugin.getPendingConsent().remove(uuid);
+ String kickMsg = MessageUtil.replace(
+ plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
+ p.kick(MiniMessage.miniMessage().deserialize(kickMsg));
+ }, (long) graceSec * 20);
+ }
+
+ /**
+ * Schedules a repeating reminder task in REMIND mode.
+ * Cancels automatically when the player has consented or leaves.
+ */
+ private void scheduleReminder(Player player) {
+ int intervalSec = plugin.getConsentConfig().getReminderIntervalSeconds();
+ if (intervalSec <= 0) return;
+
+ UUID uuid = player.getUniqueId();
+ long intervalTicks = (long) intervalSec * 20;
+ final int[] taskId = {-1};
+
+ taskId[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
+ Player p = Bukkit.getPlayer(uuid);
+ if (p == null) {
+ Bukkit.getScheduler().cancelTask(taskId[0]);
+ return;
+ }
+ // Stop reminding once they've accepted
+ if (plugin.getConsentDatabase().hasConsented(
+ uuid.toString(), plugin.getConsentConfig().getPolicyVersion())) {
+ Bukkit.getScheduler().cancelTask(taskId[0]);
+ return;
+ }
+ String msg = MessageUtil.replace(
+ plugin.getConsentConfig().getMsgRemind(), p, plugin.getConsentConfig());
+ p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
+ }, intervalTicks, intervalTicks).getTaskId();
+ }
+}
diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
new file mode 100644
index 0000000..3cf7a2a
--- /dev/null
+++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
@@ -0,0 +1,30 @@
+package de.simolzimol.mclogger.consent.util;
+
+import de.simolzimol.mclogger.consent.ConsentConfig;
+import org.bukkit.entity.Player;
+
+/**
+ * Simple placeholder replacement helper.
+ */
+public final class MessageUtil {
+
+ private MessageUtil() {}
+
+ /**
+ * Replaces standard placeholders in a MiniMessage string.
+ *
+ * Supported placeholders:
+ *
+ * - {@code {player}} – player's display name
+ * - {@code {policy_url}} – configured policy URL
+ * - {@code {version}} – policy version string
+ *
+ */
+ public static String replace(String template, Player player, ConsentConfig cfg) {
+ if (template == null) return "";
+ return template
+ .replace("{player}", player.getName())
+ .replace("{policy_url}", cfg.getPolicyUrl())
+ .replace("{version}", cfg.getPolicyVersion());
+ }
+}
diff --git a/consent-plugin/src/main/resources/config.yml b/consent-plugin/src/main/resources/config.yml
new file mode 100644
index 0000000..d029191
--- /dev/null
+++ b/consent-plugin/src/main/resources/config.yml
@@ -0,0 +1,191 @@
+# ============================================================
+# MCConsent – Paper Plugin Configuration
+# Author: SimolZimol
+# Docs: https://github.com/SimolZimol/MCLogger
+# ============================================================
+
+# ── Global switch ────────────────────────────────────────────
+# Set to false to completely disable all enforcement (plugin stays
+# loaded but won't block any player).
+enabled: true
+
+# ── Database ─────────────────────────────────────────────────
+# Connection details for the MariaDB/MySQL database where consent
+# records are stored. You can point this at the same database as
+# the MCLogger plugin so consent events appear in the web panel.
+database:
+ host: "localhost"
+ port: 3306
+ database: "mclogger"
+ username: "mclogger"
+ password: "change_me_please"
+ ssl: false
+ # Maximum number of concurrent DB connections
+ pool-size: 5
+
+# ── Consent settings ─────────────────────────────────────────
+consent:
+ # ── Policy version ──────────────────────────────────────────
+ # Change this value every time you update your Privacy Policy.
+ # Players who accepted an older version will be prompted again.
+ # Tip: use the Document ID shown on your MCLogger web panel
+ # policy page (e.g. "ABC123") for automatic synchronisation.
+ policy-version: "1.0"
+
+ # ── Policy URL ──────────────────────────────────────────────
+ # Full URL players can visit to read the Privacy Policy.
+ # If you host it via the MCLogger web panel, the URL looks like:
+ # https://your-panel.example.com/policy/
+ policy-url: "https://example.com/privacy-policy"
+
+ # ── Enforcement mode ────────────────────────────────────────
+ # KICK – Kick the player immediately at login if no consent.
+ # Players see a disconnect screen with the policy URL.
+ # HOLD – Allow the player to join but freeze them in place.
+ # They cannot move, chat, or run commands until they
+ # accept (or the grace period expires and they get kicked).
+ # REMIND – Let the player join freely; send periodic reminders
+ # but never kick. Useful for soft opt-in scenarios.
+ enforcement-mode: HOLD
+
+ # ── Command name ────────────────────────────────────────────
+ # The command players type to interact with the consent system.
+ # NOTE: This only changes display text in prompts.
+ # The actual Bukkit command is always "/consent" (registered
+ # in plugin.yml with aliases /privacy and /pp).
+ command: "consent"
+
+ # ── Grace period (HOLD mode only) ───────────────────────────
+ # How many seconds a player has to accept before being kicked.
+ # Set to 0 to wait forever (player can take as long as they like).
+ grace-period-seconds: 120
+
+ # ── Reminder interval (REMIND mode only) ────────────────────
+ # How often (seconds) to resend the consent prompt to players
+ # who haven't responded. 0 = send once on join only.
+ reminder-interval-seconds: 300
+
+ # ── Move-message cooldown ────────────────────────────────────
+ # Minimum seconds between "please accept first" messages when a
+ # player tries to move in HOLD mode. Prevents chat spam.
+ move-message-cooldown-seconds: 5
+
+ # ── Exempt worlds ────────────────────────────────────────────
+ # Players in these worlds are not subject to consent enforcement.
+ # Useful if you have a lobby/hub world where players arrive before
+ # being redirected to a game world.
+ # Example: ["world_lobby", "world_hub"]
+ exempt-worlds: []
+
+ # ── Bypass permission ────────────────────────────────────────
+ # Players with this permission bypass all consent checks.
+ # Default: consent.bypass (assigned to ops by default)
+ bypass-permission: "consent.bypass"
+
+# ── Admin settings ───────────────────────────────────────────
+admin:
+ # Log consent accept/decline events to the server_events table in
+ # the MCLogger database. Requires database.database to point to
+ # the same DB as MCLogger.
+ log-to-mclogger-db: true
+
+ # Broadcast a message to online admins when a player
+ # accepts or declines the policy.
+ notify-admins: false
+
+ # Permission node required to use admin sub-commands:
+ # /consent admin reload
+ # /consent admin list
+ # /consent admin revoke
+ # /consent admin forceaccept
+ admin-permission: "consent.admin"
+
+# ── Messages ─────────────────────────────────────────────────
+# All messages support MiniMessage formatting tags:
+# , , , , ,
+# , , etc.
+# Available placeholders:
+# {url} – The policy URL (consent.policy-url)
+# {command} – The consent command (consent.command)
+# {player} – The player's name
+# {version} – The current policy version
+# {seconds} – Remaining grace period seconds (where applicable)
+messages:
+ # ── Prefix ──────────────────────────────────────────────────
+ # Prepended to every plugin message.
+ prefix: "[Privacy] "
+
+ # ── Join prompt ─────────────────────────────────────────────
+ # Sent to a player when they join and consent is required.
+ join-prompt: |
+ Privacy Policy Consent Required
+ Before you can play you must read and accept our Privacy Policy.
+ Click to open in browser