#!/bin/bash
# services/minecraft.sh — Minecraft server (Fabric/Quilt/Paper/Vanilla/Forge),
# multi-instance, mod & datapack pickers, playit.gg tunnel, client-mods page.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
#   sudo bash minecraft.sh
# (Docker must already be installed when run standalone)
#
# Ported from the standalone setupminecraft.sh. Converted to the per-service
# folder model: each instance lives in its OWN folder under $DOCKER_DIR with its
# OWN standalone docker-compose.yml (no shared compose, no python insert logic).
#
#   default / first instance id "minecraft" -> $DOCKER_DIR/minecraft/
#   any other instance id "<slug>"          -> $DOCKER_DIR/<slug>/
#
# Helpers (log_*, prompt_yn, prompt_text, ensure_docker_dir_ownership, …) and
# globals (DOCKER_DIR, ACTUAL_USER, ACTUAL_HOME, DRY_RUN, UNATTENDED) come from
# lib/common.sh — do NOT redefine them here. No `set -e`: this file is sourced
# into a long-running dispatcher, so we use explicit checks + `|| return 1`.

# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }

    _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    _COMMON="$_SELF_DIR/../lib/common.sh"

    if [[ -f "$_COMMON" ]]; then
        # Full repo present — use the real helpers (picks up ~/docker/.config too)
        # shellcheck source=../lib/common.sh
        source "$_COMMON"
    else
        # One-off copy — inline minimal stubs so the script works without the repo
        log_info()    { echo -e "\033[0;34m[INFO]\033[0m $*"; }
        log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
        log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
        log_error()   { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }

        require_docker() {
            command -v docker &>/dev/null || {
                log_error "Docker not found. Install it first:"
                log_error "  curl -fsSL https://get.docker.com | sudo sh"
                return 1
            }
            docker compose version &>/dev/null || {
                log_error "Docker Compose plugin missing:"
                log_error "  sudo apt-get install -y docker-compose-plugin"
                return 1
            }
        }

        ensure_docker_dir_ownership() {
            chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
        }

        # Match common.sh's eval-based pattern so local vars in install_* are set correctly
        prompt_text() {
            local _q="$1" _def="$2" _var="$3" _r
            [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
            read -r -p "  $_q " _r
            eval "$_var='${_r:-$_def}'"
        }

        prompt_yn() {
            local _q="$1" _def="$2" _var="$3" _r
            [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
            read -r -p "  $_q " _r
            eval "$_var='${_r:-$_def}'"
        }
    fi

    # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
    # ($HOME under sudo is /root, not the real user's home)
    ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
    ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
    DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
    DRY_RUN="${DRY_RUN:-false}"
    UNATTENDED="${UNATTENDED:-false}"
    SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
    SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
    SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
    CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"

    register_service() { :; }   # no-op — no wizard to register into
    _RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────

register_service minecraft gaming "Minecraft server (Fabric/Quilt/Paper, multi-instance)" 25565

install_minecraft() {
    require_docker || return 1

    # ── DRY-RUN: summarise and bail BEFORE any prompting / curl / docker ───────
    if [ "$DRY_RUN" = true ]; then
        echo "[DRY-RUN] Would set up a Minecraft server instance:"
        echo "  • Create an instance folder under $DOCKER_DIR (default: $DOCKER_DIR/minecraft)"
        echo "  • Write a standalone docker-compose.yml building the itzg/minecraft-server image"
        echo "  • Optionally download selected mods (Modrinth) and a client-mods web page"
        echo "  • Optionally add a playit.gg tunnel service to the instance compose"
        echo "  • Generate MINECRAFT_NETWORKING.md and CLIENT_MODS.md in the instance folder"
        return 0
    fi

    echo ""
    echo "╔═══════════════════════════════════════════════════════╗"
    echo "║   Minecraft Server Setup                              ║"
    echo "║   Fabric · Mods · Vanilla Tweaks · Networking         ║"
    echo "╚═══════════════════════════════════════════════════════╝"
    echo ""

    # ── Vanilla Tweaks pre-packaged ZIPs detection ──────────────────────────────
    local VT_DATAPACK_ZIP="" VT_CRAFTING_ZIP="" VT_DP_VER="" VT_CT_VER=""
    local USE_VT_ZIPS=false
    local _VT_EXTRAS_DIR="${HERE}/extras/datapacks"
    if [ -d "$_VT_EXTRAS_DIR" ]; then
        local _dp_zips _ct_zips
        shopt -s nullglob nocaseglob
        _dp_zips=("$_VT_EXTRAS_DIR"/*datapack*.zip)
        _ct_zips=("$_VT_EXTRAS_DIR"/*craft*.zip)
        shopt -u nullglob nocaseglob
        if [ ${#_dp_zips[@]} -gt 0 ]; then
            VT_DATAPACK_ZIP="${_dp_zips[0]}"
            VT_DP_VER=$(basename "$VT_DATAPACK_ZIP" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
        fi
        if [ ${#_ct_zips[@]} -gt 0 ]; then
            VT_CRAFTING_ZIP="${_ct_zips[0]}"
            VT_CT_VER=$(basename "$VT_CRAFTING_ZIP" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
        fi
    fi
    if [ -n "$VT_DATAPACK_ZIP" ] || [ -n "$VT_CRAFTING_ZIP" ]; then
        log_info "Vanilla Tweaks pre-packaged files found in extras/datapacks/:"
        [ -n "$VT_DATAPACK_ZIP" ] && echo "    Datapacks:       $(basename "$VT_DATAPACK_ZIP")${VT_DP_VER:+  (v${VT_DP_VER})}"
        [ -n "$VT_CRAFTING_ZIP" ] && echo "    Crafting tweaks: $(basename "$VT_CRAFTING_ZIP")${VT_CT_VER:+  (v${VT_CT_VER})}"
        echo "  These will be offered for auto-install in the datapacks step."
    else
        echo "  Tip: place Vanilla Tweaks ZIPs in extras/datapacks/ before running"
        echo "  for automatic install. Names should contain 'datapack' or 'craft'."
        echo "  Example: datapacks_1.21.zip  crafting_tweaks_1.21.zip"
    fi
    echo ""

    # ── Minecraft version ──────────────────────────────────────────────────────
    log_info "Fetching recent Minecraft versions..."
    local _MC_JSON
    _MC_JSON=$(curl -sf --max-time 10 "https://launchermeta.mojang.com/mc/game/version_manifest.json" 2>/dev/null)
    local RECENT_VERSIONS=()
    mapfile -t RECENT_VERSIONS < <(echo "$_MC_JSON" | python3 -c "
import sys, json
d = json.load(sys.stdin)
releases = [v['id'] for v in d['versions'] if v['type'] == 'release']
for v in releases[:3]:
    print(v)
" 2>/dev/null)
    local LATEST_SNAPSHOT
    LATEST_SNAPSHOT=$(echo "$_MC_JSON" | python3 -c "
import sys, json
d = json.load(sys.stdin)
snaps = [v['id'] for v in d['versions'] if v['type'] == 'snapshot']
print(snaps[0] if snaps else '')
" 2>/dev/null)

    if [ ${#RECENT_VERSIONS[@]} -eq 0 ]; then
        RECENT_VERSIONS=("1.21.4" "1.21.3" "1.21.1")
        log_warning "Could not fetch version list — using defaults"
    fi

    # ── Server flavour ─────────────────────────────────────────────────────────
    echo ""
    log_info "Server Flavour"
    echo ""
    echo "  1) Fabric (default) — Lightweight, best mod ecosystem, required for"
    echo "                        all mods in this setup. Recommended."
    echo ""
    echo "  2) Quilt            — Fabric fork, compatible with most Fabric mods."
    echo "                        More experimental; use if you need Quilt-only mods."
    echo ""
    echo "  3) Paper            — High-performance Spigot fork. Best for plugin-based"
    echo "                        servers. Does NOT support Fabric mods."
    echo ""
    echo "  4) Vanilla          — Pure Mojang server. No mods or plugins. Simplest"
    echo "                        setup but no mod support — mods below won't apply."
    echo ""
    echo "  5) Forge            — Heavy modpack loader (FTB, Technic etc). Needs a"
    echo "                        matching Forge client. Not compatible with Fabric mods."
    echo ""
    local FLAVOUR_CHOICE=""
    prompt_text "Flavour [1]:" "1" FLAVOUR_CHOICE

    local FLAVOUR FLAVOUR_NAME
    case $FLAVOUR_CHOICE in
        1) FLAVOUR="FABRIC";  FLAVOUR_NAME="Fabric"  ;;
        2) FLAVOUR="QUILT";   FLAVOUR_NAME="Quilt"   ;;
        3) FLAVOUR="PAPER";   FLAVOUR_NAME="Paper"   ;;
        4) FLAVOUR="VANILLA"; FLAVOUR_NAME="Vanilla" ;;
        5) FLAVOUR="FORGE";   FLAVOUR_NAME="Forge"   ;;
        *) FLAVOUR="FABRIC";  FLAVOUR_NAME="Fabric"  ;;
    esac
    log_success "$FLAVOUR_NAME selected"

    local SUPPORTS_FABRIC_MODS=false
    [[ "$FLAVOUR" == "FABRIC" || "$FLAVOUR" == "QUILT" ]] && SUPPORTS_FABRIC_MODS=true

    if [ "$SUPPORTS_FABRIC_MODS" = false ]; then
        log_warning "$FLAVOUR_NAME does not support Fabric mods — mod and datapack selection will be skipped."
    fi

    # ── Basic server config ────────────────────────────────────────────────────
    echo ""
    log_info "Server Configuration"
    local SERVER_NAME=""
    prompt_text "Server name [My Minecraft Server]:" "My Minecraft Server" SERVER_NAME

    # ── Instance id ────────────────────────────────────────────────────────────
    # Each instance gets its own folder, container name, compose service and port,
    # so you can run several servers side by side. The first server defaults to
    # 'minecraft'; pick a unique id for more.
    slugify() { echo "$1" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-' | sed 's/--*/-/g; s/^-//; s/-$//'; }
    local _def_slug
    _def_slug="$(slugify "$SERVER_NAME")"; [ -z "$_def_slug" ] && _def_slug="minecraft"
    # If no minecraft instance folder exists yet, default to 'minecraft'.
    if [ ! -d "$DOCKER_DIR/minecraft" ]; then
        _def_slug="minecraft"
    fi
    local MC_NAME=""
    prompt_text "Instance id (folder + container name) [${_def_slug}]:" "$_def_slug" MC_NAME
    MC_NAME="$(slugify "${MC_NAME:-$_def_slug}")"; [ -z "$MC_NAME" ] && MC_NAME="minecraft"
    local MC_DIR="$DOCKER_DIR/$MC_NAME"
    if [ -d "$MC_DIR" ]; then
        log_warning "An instance folder '${MC_NAME}' already exists at ${MC_DIR}."
        log_warning "Re-running will update its files; the existing world data is left as-is."
    fi
    log_info "Instance: ${MC_NAME}  →  ${MC_DIR}"

    local MAX_PLAYERS=""
    prompt_text "Max players [20]:" "20" MAX_PLAYERS

    local DIFFICULTY=""
    prompt_text "Difficulty (peaceful/easy/normal/hard) [normal]:" "normal" DIFFICULTY

    local GAMEMODE=""
    prompt_text "Game mode (survival/creative/adventure) [survival]:" "survival" GAMEMODE

    local MC_SEED=""
    if [ -d "$MC_DIR/data/world" ]; then
        log_info "Existing world detected at $MC_DIR/data/world — seed ignored (applies to new worlds only)."
    else
        echo ""
        echo "  World seed — controls terrain generation. Leave blank for a random seed."
        echo "  Examples:  8675309   -3965137388347648926   minecraft"
        prompt_text "  World seed [random]:" "" MC_SEED
        [ -n "$MC_SEED" ] && log_info "  Seed set: $MC_SEED"
    fi

    local WHITELIST=""
    prompt_yn "Enable whitelist? (y/n) [n]:" "n" WHITELIST
    local WHITELIST_ENABLED=false
    [[ $WHITELIST =~ ^[Yy]$ ]] && WHITELIST_ENABLED=true || WHITELIST_ENABLED=false

    local WHITELIST_PLAYERS=()
    declare -A WHITELIST_PRELOADED   # name → uuid, already resolved from existing files
    if [ "$WHITELIST_ENABLED" = true ] && [ "$UNATTENDED" != true ]; then
        echo ""
        log_info "Whitelist Players"

        # Collect whitelist sources: live instance data + any saved backup files
        local -a _WL_SRC_FILES _WL_SRC_LABELS _WL_SRC_LETTERS
        local _WL_LETTERS=(a b c d e f g h i j k l m n o p q r s t u v w x y z)

        if [ -f "$MC_DIR/data/whitelist.json" ]; then
            _WL_SRC_FILES+=("$MC_DIR/data/whitelist.json")
            _WL_SRC_LABELS+=("${MC_NAME} (current)")
        fi
        local _BACKUP_WL="$DOCKER_DIR/whitelist-${MC_NAME}.json"
        if [ -f "$_BACKUP_WL" ]; then
            _WL_SRC_FILES+=("$_BACKUP_WL")
            _WL_SRC_LABELS+=("${MC_NAME}-backup")
        fi
        # Also pick up whitelist backups from other instances
        local _wlf
        while IFS= read -r _wlf; do
            [ "$_wlf" = "$_BACKUP_WL" ] && continue
            _WL_SRC_FILES+=("$_wlf")
            _WL_SRC_LABELS+=("$(basename "$_wlf" .json | sed 's/^whitelist-//')")
        done < <(compgen -G "$DOCKER_DIR/whitelist-*.json" 2>/dev/null | sort)

        if [ ${#_WL_SRC_FILES[@]} -gt 0 ]; then
            local _i
            for _i in "${!_WL_SRC_FILES[@]}"; do
                _WL_SRC_LETTERS+=("${_WL_LETTERS[$_i]}")
            done

            # Read all sources, dedup by lowercase name
            local -a _WL_ROWS
            mapfile -t _WL_ROWS < <(python3 - "${_WL_SRC_LETTERS[@]}" "---" "${_WL_SRC_FILES[@]}" << 'PYEOF'
import json, sys
args = sys.argv[1:]
sep = args.index('---')
letters, files = args[:sep], args[sep+1:]
seen = set()
for letter, path in zip(letters, files):
    try:
        for p in json.load(open(path)):
            n = p.get('name','').strip()
            if n and n.lower() not in seen:
                seen.add(n.lower())
                print(f"{letter}\t{n}\t{p.get('uuid','')}")
    except: pass
PYEOF
)

            if [ ${#_WL_ROWS[@]} -gt 0 ]; then
                local -a _EX_NAMES _EX_UUIDS _EX_SRC
                local _row _s _n _u
                for _row in "${_WL_ROWS[@]}"; do
                    IFS=$'\t' read -r _s _n _u <<< "$_row"
                    _EX_NAMES+=("$_n"); _EX_UUIDS+=("$_u"); _EX_SRC+=("$_s")
                done

                echo ""
                echo "  Available whitelist players:"
                local _NUM=1 _LAST_SRC="" _si _SRC_LABEL
                for _i in "${!_EX_NAMES[@]}"; do
                    if [ "${_EX_SRC[$_i]}" != "$_LAST_SRC" ]; then
                        [ -n "$_LAST_SRC" ] && echo ""
                        for _si in "${!_WL_SRC_LETTERS[@]}"; do
                            [ "${_WL_SRC_LETTERS[$_si]}" = "${_EX_SRC[$_i]}" ] \
                                && _SRC_LABEL="${_WL_SRC_LABELS[$_si]}" && break
                        done
                        echo -e "  \033[1;33m── [${_EX_SRC[$_i]}] ${_SRC_LABEL}\033[0m"
                        _LAST_SRC="${_EX_SRC[$_i]}"
                    fi
                    printf "    %2d) %s\n" "$_NUM" "${_EX_NAMES[$_i]}"
                    _NUM=$((_NUM+1))
                done

                local _EX_FIRST="${_WL_SRC_LETTERS[0]}"
                local _EX_SECOND="${_WL_SRC_LETTERS[1]:-}"
                local _EX_STR="$_EX_FIRST"
                [ -n "$_EX_SECOND" ] && _EX_STR="${_EX_FIRST},3   or   ${_EX_SECOND},1,4   or   ${_EX_FIRST},${_EX_SECOND}"

                echo ""
                echo "  Letter = all players from that source.  Number = specific player."
                echo "  Combine freely with commas.  0 = everyone.  Enter = skip."
                echo "  Example: ${_EX_STR}   or   0"
                local _WL_IMPORT=""
                read -p "  Selection: " _WL_IMPORT
                if [ -n "$_WL_IMPORT" ]; then
                    local -a _TOKENS
                    IFS=',' read -ra _TOKENS <<< "$_WL_IMPORT"
                    local _tok _idx _matched
                    for _tok in "${_TOKENS[@]}"; do
                        _tok="${_tok// /}"
                        if [ "$_tok" = "0" ]; then
                            for _i in "${!_EX_NAMES[@]}"; do
                                WHITELIST_PRELOADED["${_EX_NAMES[$_i]}"]="${_EX_UUIDS[$_i]}"
                            done
                            log_success "  Imported all ${#_EX_NAMES[@]} player(s)"; break
                        elif [[ "$_tok" =~ ^[0-9]+$ ]]; then
                            _idx=$((_tok-1))
                            if [ "$_idx" -ge 0 ] && [ "$_idx" -lt "${#_EX_NAMES[@]}" ]; then
                                WHITELIST_PRELOADED["${_EX_NAMES[$_idx]}"]="${_EX_UUIDS[$_idx]}"
                                log_info "    Imported: ${_EX_NAMES[$_idx]}"
                            fi
                        elif [[ "$_tok" =~ ^[a-z]$ ]]; then
                            _matched=false
                            for _i in "${!_EX_NAMES[@]}"; do
                                if [ "${_EX_SRC[$_i]}" = "$_tok" ]; then
                                    WHITELIST_PRELOADED["${_EX_NAMES[$_i]}"]="${_EX_UUIDS[$_i]}"
                                    _matched=true
                                fi
                            done
                            if [ "$_matched" = true ]; then
                                for _si in "${!_WL_SRC_LETTERS[@]}"; do
                                    [ "${_WL_SRC_LETTERS[$_si]}" = "$_tok" ] \
                                        && log_success "  Imported all from [${_tok}] ${_WL_SRC_LABELS[$_si]}" && break
                                done
                            else
                                log_warning "  [${_tok}] not found in the list above"
                            fi
                        else
                            log_warning "  '${_tok}' not recognised — use a letter or player number"
                        fi
                    done
                fi
            fi
        fi

        echo ""
        echo "  Enter additional gamertags to add. UUIDs looked up automatically."
        echo "  Press Enter alone when done."
        echo ""
        while true; do
            local _GT=""
            read -p "  Gamertag (Enter to finish): " _GT
            [ -z "$_GT" ] && break
            WHITELIST_PLAYERS+=("$_GT")
            log_info "    Added: $_GT"
        done
        local _WL_TOTAL=$(( ${#WHITELIST_PLAYERS[@]} + ${#WHITELIST_PRELOADED[@]} ))
        if [ "$_WL_TOTAL" -gt 0 ]; then
            log_success "  $_WL_TOTAL player(s) queued for whitelist"
        else
            log_info "  No players entered — whitelist will be empty until you add players manually"
        fi
    fi

    # Port auto-bump: default 25565; if an existing instance compose already
    # maps :25565 and this is not the default instance, default to 25566.
    local _def_port=25565
    if [ "$MC_NAME" != "minecraft" ] \
       && grep -qs ':25565"' "$DOCKER_DIR"/*/docker-compose.yml 2>/dev/null; then
        _def_port=25566
    fi
    local MC_PORT=""
    prompt_text "Minecraft server port [${_def_port}]:" "$_def_port" MC_PORT
    if grep -qs "\"${MC_PORT}:25565\"" "$DOCKER_DIR"/*/docker-compose.yml 2>/dev/null; then
        log_warning "Port ${MC_PORT} is already mapped by another instance — pick a unique host port."
    fi

    local MC_RAM=""
    prompt_text "Memory allocation in GB [4]:" "4" MC_RAM

    # ── Mod selection (Fabric/Quilt only) ──────────────────────────────────────
    local SELECTED_MODS=()
    local SELECTED_DATAPACKS=()
    local MC_VERSION=""

    declare -A MODS
    declare -A MOD_DESC
    declare -A MOD_DEFAULT
    declare -A MOD_MODRINTH_ID
    local MOD_ORDER=()

    if [ "$SUPPORTS_FABRIC_MODS" = true ]; then
        echo ""
        log_info "Mod Selection"
        echo "Toggle mods on/off. Performance mods are pre-selected (recommended)."
        echo ""

        MODS["nochatreports"]="NoChatReports"
        MOD_DESC["nochatreports"]="Removes Mojang's chat reporting system server-side."
        MOD_DEFAULT["nochatreports"]="y"
        MOD_MODRINTH_ID["nochatreports"]="qQyHxfxd"

        MODS["essentials"]="Essentials for Fabric"
        MOD_DESC["essentials"]="Homes, warps, TPA, /back, /spawn, /heal, /fly, admin tools."
        MOD_DEFAULT["essentials"]="y"
        MOD_MODRINTH_ID["essentials"]="fessentials"

        MODS["luckperms"]="LuckPerms"
        MOD_DESC["luckperms"]="Permissions system. Pre-configured with default/mod/admin groups."
        MOD_DEFAULT["luckperms"]="y"
        MOD_MODRINTH_ID["luckperms"]="luckperms"

        MODS["lithium"]="Lithium"
        MOD_DESC["lithium"]="Server performance — optimises mob AI, physics, block ticking. 30-50% faster TPS."
        MOD_DEFAULT["lithium"]="y"
        MOD_MODRINTH_ID["lithium"]="lithium"

        MODS["ferritecore"]="FerriteCore"
        MOD_DESC["ferritecore"]="Reduces server memory usage significantly."
        MOD_DEFAULT["ferritecore"]="y"
        MOD_MODRINTH_ID["ferritecore"]="ferritecore"

        MODS["starlight"]="Starlight"
        MOD_DESC["starlight"]="Rewrites the lighting engine — big reduction in lag spikes."
        MOD_DEFAULT["starlight"]="y"
        MOD_MODRINTH_ID["starlight"]="starlight"

        MODS["chunky"]="Chunky"
        MOD_DESC["chunky"]="Pre-generates chunks so players don't cause lag exploring new areas. Essential for elytra flyers."
        MOD_DEFAULT["chunky"]="y"
        MOD_MODRINTH_ID["chunky"]="chunky"

        MODS["c2me"]="C2ME (Concurrent Chunk Management)"
        MOD_DESC["c2me"]="Multithreads chunk generation. Alpha-quality — can crash on startup. Only enable if you need it."
        MOD_DEFAULT["c2me"]="n"
        MOD_MODRINTH_ID["c2me"]="c2me-fabric"

        MODS["spark"]="Spark"
        MOD_DESC["spark"]="Server profiler — diagnose lag, TPS drops, memory issues."
        MOD_DEFAULT["spark"]="y"
        MOD_MODRINTH_ID["spark"]="spark"

        MODS["carpet"]="Carpet"
        MOD_DESC["carpet"]="Technical Minecraft features, mob spawning tweaks, debug tools."
        MOD_DEFAULT["carpet"]="y"
        MOD_MODRINTH_ID["carpet"]="carpet"

        MODS["ledger"]="Ledger"
        MOD_DESC["ledger"]="Block change logging and grief tracking. Query who broke/placed what."
        MOD_DEFAULT["ledger"]="y"
        MOD_MODRINTH_ID["ledger"]="ledger"

        MODS["serverreplay"]="ServerReplay"
        MOD_DESC["serverreplay"]="Records server-side replays viewable with ReplayMod on client."
        MOD_DEFAULT["serverreplay"]="n"
        MOD_MODRINTH_ID["serverreplay"]="server-replay"

        MOD_ORDER=("nochatreports" "essentials" "luckperms" "lithium" "ferritecore"
                   "starlight" "chunky" "c2me" "spark" "carpet" "ledger" "serverreplay")

        # ── Version picker with mod availability table ─────────────────────────
        echo ""
        log_info "Checking mod availability across recent versions (this takes a few seconds)..."
        echo ""

        local COL_MOD=22
        local COL_VER=12

        printf "  %-${COL_MOD}s" "Mod"
        local ver
        for ver in "${RECENT_VERSIONS[@]}"; do
            printf "  %-${COL_VER}s" "$ver"
        done
        echo ""
        printf "  %-${COL_MOD}s" "$(printf '%0.s─' $(seq 1 $COL_MOD))"
        for ver in "${RECENT_VERSIONS[@]}"; do
            printf "  %-${COL_VER}s" "$(printf '%0.s─' $(seq 1 $COL_VER))"
        done
        echo ""

        declare -A MOD_AVAIL  # key: "mod:ver" → "yes"/"no"
        local mod slug label result
        for mod in "${MOD_ORDER[@]}"; do
            slug="${MOD_MODRINTH_ID[$mod]}"
            label="${MODS[$mod]}"
            printf "  %-${COL_MOD}s" "${label:0:$COL_MOD}"
            for ver in "${RECENT_VERSIONS[@]}"; do
                result=$(curl -sf --max-time 8 \
                    "https://api.modrinth.com/v2/project/${slug}/version?game_versions=%5B%22${ver}%22%5D&loaders=%5B%22fabric%22%5D" \
                    | python3 -c "import sys,json; v=json.load(sys.stdin); print('yes' if v else 'no')" 2>/dev/null || echo "?")
                MOD_AVAIL["${mod}:${ver}"]="$result"
                if [ "$result" = "yes" ]; then
                    printf "  \033[0;32m%-${COL_VER}s\033[0m" "✓"
                elif [ "$result" = "no" ]; then
                    printf "  \033[0;31m%-${COL_VER}s\033[0m" "✗ not yet"
                else
                    printf "  %-${COL_VER}s" "?"
                fi
            done
            echo ""
        done
        echo ""

        # Find the version with the best mod availability to use as the default.
        local _BEST_VER_IDX=0
        local _BEST_VER_COUNT=-1
        local i _count
        for i in "${!RECENT_VERSIONS[@]}"; do
            _count=0
            for mod in "${MOD_ORDER[@]}"; do
                [ "${MOD_AVAIL[${mod}:${RECENT_VERSIONS[$i]}]}" = "yes" ] && _count=$((_count+1))
            done
            if [ $_count -gt $_BEST_VER_COUNT ]; then
                _BEST_VER_COUNT=$_count
                _BEST_VER_IDX=$i
            fi
        done
        local _DEFAULT_VER_NUM=$((_BEST_VER_IDX+1))

        echo "  Which version do you want to use?"
        local _ver _suffix
        for i in "${!RECENT_VERSIONS[@]}"; do
            _ver="${RECENT_VERSIONS[$i]}"
            _count=0
            for mod in "${MOD_ORDER[@]}"; do
                [ "${MOD_AVAIL[${mod}:${_ver}]}" = "yes" ] && _count=$((_count+1))
            done
            _suffix="(${_count}/${#MOD_ORDER[@]} mods available)"
            [ "$i" -eq "$_BEST_VER_IDX" ] && _suffix="$_suffix  ← Recommended"
            if [[ "$_ver" =~ ^[2-9][0-9]\. ]] && [ "$_count" -eq 0 ]; then
                _suffix="$_suffix  ⚠ new versioning — mods not yet compatible"
            fi
            echo "  $((i+1))) $_ver  $_suffix"
        done
        local _SNAP_OPT="" _SNAP_NUM _MAN_NUM
        if [ -n "$LATEST_SNAPSHOT" ]; then
            _SNAP_NUM=$(( ${#RECENT_VERSIONS[@]} + 1 ))
            _MAN_NUM=$(( ${#RECENT_VERSIONS[@]} + 2 ))
            echo "  ${_SNAP_NUM}) ${LATEST_SNAPSHOT}  ⚠ snapshot — mods may not be available yet"
            _SNAP_OPT="$LATEST_SNAPSHOT"
        else
            _MAN_NUM=$(( ${#RECENT_VERSIONS[@]} + 1 ))
        fi
        echo "  ${_MAN_NUM}) Enter manually"
        echo ""
        local _VER_CHOICE=""
        read -p "Choice [${_DEFAULT_VER_NUM}]: " _VER_CHOICE
        _VER_CHOICE="${_VER_CHOICE:-${_DEFAULT_VER_NUM}}"

        if [ "$_VER_CHOICE" -le "${#RECENT_VERSIONS[@]}" ] 2>/dev/null; then
            MC_VERSION="${RECENT_VERSIONS[$((_VER_CHOICE-1))]}"
            local _picked_count=0
            for mod in "${MOD_ORDER[@]}"; do
                [ "${MOD_AVAIL[${mod}:${MC_VERSION}]}" = "yes" ] && _picked_count=$((_picked_count+1))
            done
            if [ "$_picked_count" -eq 0 ] && [ "$_BEST_VER_COUNT" -gt 0 ]; then
                echo ""
                log_warning "No mods are available for $MC_VERSION yet!"
                log_warning "Server will crash on startup — Fabric rejects mods built for a different version string."
                log_warning "Recommended: use ${RECENT_VERSIONS[$_BEST_VER_IDX]} where all mods are available."
                local _switch=""
                read -p "Switch to ${RECENT_VERSIONS[$_BEST_VER_IDX]} instead? (y/n) [y]: " -n 1 -r _switch; echo
                if [[ ${_switch:-y} =~ ^[Yy]$ ]]; then
                    MC_VERSION="${RECENT_VERSIONS[$_BEST_VER_IDX]}"
                    log_success "Switched to $MC_VERSION"
                else
                    log_warning "Continuing with $MC_VERSION — mods will be skipped (server runs vanilla)"
                    SELECTED_MODS=()
                fi
            fi
        elif [ -n "$_SNAP_OPT" ] && [ "$_VER_CHOICE" = "$_SNAP_NUM" ] 2>/dev/null; then
            MC_VERSION="$_SNAP_OPT"
            log_warning "Snapshot selected — Fabric and mods may not support this version yet"
        else
            read -p "Enter Minecraft version: " MC_VERSION
            if ! [[ "$MC_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
                MC_VERSION="${RECENT_VERSIONS[$_BEST_VER_IDX]}"
                log_warning "Invalid version — defaulting to $MC_VERSION"
            fi
        fi
        log_success "Using Minecraft $MC_VERSION"

        # Select defaults, auto-skipping only mods KNOWN to be incompatible.
        for mod in "${MOD_ORDER[@]}"; do
            if [[ "${MOD_DEFAULT[$mod]}" == "y" ]]; then
                if [[ "${MOD_AVAIL[${mod}:${MC_VERSION}]}" == "no" ]]; then
                    log_info "  Auto-skipping ${MODS[$mod]} (not available for $MC_VERSION)"
                else
                    SELECTED_MODS+=("$mod")
                fi
            fi
        done

        local choice key marker all_num done_num
        while true; do
            echo ""
            for i in "${!MOD_ORDER[@]}"; do
                key="${MOD_ORDER[$i]}"
                marker=" "
                [[ " ${SELECTED_MODS[*]} " =~ " ${key} " ]] && marker="✓"
                if [[ "${MOD_AVAIL[${key}:${MC_VERSION}]}" == "no" ]]; then
                    printf "  %2d) %s %-22s %s" \
                        "$((i+1))" "$marker" "${MODS[$key]}" "${MOD_DESC[$key]}"
                    echo -e " \033[0;31m[not available for $MC_VERSION]\033[0m"
                else
                    printf "  %2d) %s %-22s %s\n" \
                        "$((i+1))" "$marker" "${MODS[$key]}" "${MOD_DESC[$key]}"
                fi
            done
            echo ""
            echo "  $(( ${#MOD_ORDER[@]} + 1 ))) Select All"
            echo "  $(( ${#MOD_ORDER[@]} + 2 ))) Done"
            echo ""
            read -p "Selection: " choice

            all_num=$(( ${#MOD_ORDER[@]} + 1 ))
            done_num=$(( ${#MOD_ORDER[@]} + 2 ))

            if [ "$choice" = "$all_num" ]; then
                SELECTED_MODS=("${MOD_ORDER[@]}"); break
            elif [ "$choice" = "$done_num" ] || [ "$choice" = "done" ]; then
                break
            elif [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && \
                 [ "$choice" -le "${#MOD_ORDER[@]}" ]; then
                key="${MOD_ORDER[$((choice-1))]}"
                if [[ " ${SELECTED_MODS[*]} " =~ " ${key} " ]]; then
                    SELECTED_MODS=("${SELECTED_MODS[@]/$key}")
                    SELECTED_MODS=(${SELECTED_MODS[@]})
                else
                    SELECTED_MODS+=("$key")
                fi
            else
                log_warning "Invalid selection"
            fi
        done

        # ── Vanilla Tweaks datapacks ───────────────────────────────────────────
        echo ""
        log_info "Vanilla Tweaks Datapacks"
        echo ""

        if [ -n "$VT_DATAPACK_ZIP" ] || [ -n "$VT_CRAFTING_ZIP" ]; then
            local _MC_VT_MAJOR; _MC_VT_MAJOR=$(echo "$MC_VERSION" | grep -oE '^[0-9]+\.[0-9]+')
            [ -n "$VT_DATAPACK_ZIP" ] && echo "    Datapacks ZIP:       $(basename "$VT_DATAPACK_ZIP")${VT_DP_VER:+  (v${VT_DP_VER})}"
            [ -n "$VT_CRAFTING_ZIP" ] && echo "    Crafting tweaks ZIP: $(basename "$VT_CRAFTING_ZIP")${VT_CT_VER:+  (v${VT_CT_VER})}"
            echo ""
            if [ -n "$VT_DP_VER" ]; then
                local _dp_major; _dp_major=$(echo "$VT_DP_VER" | grep -oE '^[0-9]+\.[0-9]+')
                if [ "$_dp_major" != "$_MC_VT_MAJOR" ]; then
                    log_warning "Datapack v${VT_DP_VER} vs MC ${MC_VERSION} — major versions differ, verify compatibility."
                else
                    log_success "Datapack v${VT_DP_VER} matches MC ${MC_VERSION}."
                fi
            fi
            if [ -n "$VT_CT_VER" ]; then
                local _ct_major; _ct_major=$(echo "$VT_CT_VER" | grep -oE '^[0-9]+\.[0-9]+')
                if [ "$_ct_major" != "$_MC_VT_MAJOR" ]; then
                    log_warning "Crafting tweaks v${VT_CT_VER} vs MC ${MC_VERSION} — major versions differ, verify compatibility."
                else
                    log_success "Crafting tweaks v${VT_CT_VER} matches MC ${MC_VERSION}."
                fi
            fi
            echo ""
            local _use_pkgs=""
            prompt_yn "  Use pre-packaged VT files from extras/datapacks/? (y/n):" "y" _use_pkgs
            [[ "$_use_pkgs" =~ ^[Yy]$ ]] && USE_VT_ZIPS=true
        fi

        if [ "$USE_VT_ZIPS" = false ]; then
            echo "Toggle datapacks. Recommended defaults are pre-selected."
            echo ""

        declare -A DPACKS
        declare -A DPACK_DESC
        declare -A DPACK_DEFAULT
        declare -A DPACK_VT_ID
        declare -A DPACK_CAT

        # ── Decorative / Cosmetic ──────────────────────────────────────────────
        DPACKS["armor_statues"]="Armor Statues"
        DPACK_DESC["armor_statues"]="Book to pose and customise armor stands."
        DPACK_DEFAULT["armor_statues"]="y"; DPACK_CAT["armor_statues"]="Decorative/Cosmetic"
        DPACK_VT_ID["armor_statues"]="armorStatues"

        DPACKS["custom_nether_portals"]="Custom Nether Portals"
        DPACK_DESC["custom_nether_portals"]="Build nether portals in any shape using crying obsidian."
        DPACK_DEFAULT["custom_nether_portals"]="n"; DPACK_CAT["custom_nether_portals"]="Decorative/Cosmetic"
        DPACK_VT_ID["custom_nether_portals"]="customNetherPortals"

        DPACKS["mini_blocks"]="Mini Blocks"
        DPACK_DESC["mini_blocks"]="Craft 1/8-scale decorative versions of most blocks."
        DPACK_DEFAULT["mini_blocks"]="n"; DPACK_CAT["mini_blocks"]="Decorative/Cosmetic"
        DPACK_VT_ID["mini_blocks"]="miniBlocks"

        DPACKS["more_mob_heads"]="More Mob Heads"
        DPACK_DESC["more_mob_heads"]="Mobs have a chance to drop their head on death."
        DPACK_DEFAULT["more_mob_heads"]="y"; DPACK_CAT["more_mob_heads"]="Decorative/Cosmetic"
        DPACK_VT_ID["more_mob_heads"]="moreMobHeads"

        DPACKS["name_colors"]="Name Colors"
        DPACK_DESC["name_colors"]="Players set their own name color using a trigger."
        DPACK_DEFAULT["name_colors"]="n"; DPACK_CAT["name_colors"]="Decorative/Cosmetic"
        DPACK_VT_ID["name_colors"]="nameColors"

        DPACKS["player_head_drops"]="Player Head Drops"
        DPACK_DESC["player_head_drops"]="Players drop their head when killed by another player."
        DPACK_DEFAULT["player_head_drops"]="y"; DPACK_CAT["player_head_drops"]="Decorative/Cosmetic"
        DPACK_VT_ID["player_head_drops"]="playerHeadDrops"

        DPACKS["silence_mobs"]="Silence Mobs"
        DPACK_DESC["silence_mobs"]="Name a mob 'silence_me' to mute it permanently."
        DPACK_DEFAULT["silence_mobs"]="y"; DPACK_CAT["silence_mobs"]="Decorative/Cosmetic"
        DPACK_VT_ID["silence_mobs"]="silenceMobs"

        DPACKS["wandering_trades"]="Wandering Trades"
        DPACK_DESC["wandering_trades"]="Wandering trader sells mini blocks."
        DPACK_DEFAULT["wandering_trades"]="n"; DPACK_CAT["wandering_trades"]="Decorative/Cosmetic"
        DPACK_VT_ID["wandering_trades"]="wanderingTrades"

        DPACKS["wandering_trades_hermit"]="Wandering Trades (Hermit Edition)"
        DPACK_DESC["wandering_trades_hermit"]="Wandering trader sells Hermitcraft player heads."
        DPACK_DEFAULT["wandering_trades_hermit"]="n"; DPACK_CAT["wandering_trades_hermit"]="Decorative/Cosmetic"
        DPACK_VT_ID["wandering_trades_hermit"]="wanderingTradesHermitEdition"

        # ── Convenience ─────────────────────────────────────────────────────────
        DPACKS["cauldron_concrete"]="Cauldron Concrete"
        DPACK_DESC["cauldron_concrete"]="Dip concrete powder in a water cauldron to make concrete."
        DPACK_DEFAULT["cauldron_concrete"]="n"; DPACK_CAT["cauldron_concrete"]="Convenience"
        DPACK_VT_ID["cauldron_concrete"]="cauldronConcrete"

        DPACKS["cauldron_mud"]="Cauldron Mud"
        DPACK_DESC["cauldron_mud"]="Add water to a dirt-filled cauldron to make mud."
        DPACK_DEFAULT["cauldron_mud"]="n"; DPACK_CAT["cauldron_mud"]="Convenience"
        DPACK_VT_ID["cauldron_mud"]="cauldronMud"

        DPACKS["chunk_loaders"]="Chunk Loaders"
        DPACK_DESC["chunk_loaders"]="Craftable item that keeps chunks loaded when you're offline."
        DPACK_DEFAULT["chunk_loaders"]="n"; DPACK_CAT["chunk_loaders"]="Convenience"
        DPACK_VT_ID["chunk_loaders"]="chunkLoaders"

        DPACKS["double_shulker_shells"]="Double Shulker Shells"
        DPACK_DESC["double_shulker_shells"]="Shulkers always drop 2 shells."
        DPACK_DEFAULT["double_shulker_shells"]="y"; DPACK_CAT["double_shulker_shells"]="Convenience"
        DPACK_VT_ID["double_shulker_shells"]="doubleShulkerShells"

        DPACKS["dragon_drops"]="Dragon Drops"
        DPACK_DESC["dragon_drops"]="Ender Dragon drops an elytra and dragon egg on first kill."
        DPACK_DEFAULT["dragon_drops"]="y"; DPACK_CAT["dragon_drops"]="Convenience"
        DPACK_VT_ID["dragon_drops"]="dragonDrops"

        DPACKS["elevators"]="Elevators"
        DPACK_DESC["elevators"]="Craft elevator blocks that teleport players vertically."
        DPACK_DEFAULT["elevators"]="n"; DPACK_CAT["elevators"]="Convenience"
        DPACK_VT_ID["elevators"]="elevators"

        DPACKS["ender_chest_drops"]="Ender Chest Drops"
        DPACK_DESC["ender_chest_drops"]="Ender chest drops 8 obsidian + eye of ender when broken."
        DPACK_DEFAULT["ender_chest_drops"]="n"; DPACK_CAT["ender_chest_drops"]="Convenience"
        DPACK_VT_ID["ender_chest_drops"]="enderChestDrops"

        DPACKS["fast_leaf_decay"]="Fast Leaf Decay"
        DPACK_DESC["fast_leaf_decay"]="Leaves decay much faster after a tree is felled."
        DPACK_DEFAULT["fast_leaf_decay"]="y"; DPACK_CAT["fast_leaf_decay"]="Convenience"
        DPACK_VT_ID["fast_leaf_decay"]="fastLeafDecay"

        DPACKS["glass_always_drops"]="Glass Always Drops"
        DPACK_DESC["glass_always_drops"]="Breaking glass without Silk Touch still returns the block."
        DPACK_DEFAULT["glass_always_drops"]="n"; DPACK_CAT["glass_always_drops"]="Convenience"
        DPACK_VT_ID["glass_always_drops"]="glassAlwaysDrops"

        DPACKS["more_effective_tools"]="More Effective Tools"
        DPACK_DESC["more_effective_tools"]="Axes/pickaxes/shovels also break nearby matching blocks."
        DPACK_DEFAULT["more_effective_tools"]="n"; DPACK_CAT["more_effective_tools"]="Convenience"
        DPACK_VT_ID["more_effective_tools"]="moreEffectiveTools"

        DPACKS["multiplayer_sleep"]="Multiplayer Sleep"
        DPACK_DESC["multiplayer_sleep"]="Only one player needs to sleep to skip the night."
        DPACK_DEFAULT["multiplayer_sleep"]="y"; DPACK_CAT["multiplayer_sleep"]="Convenience"
        DPACK_VT_ID["multiplayer_sleep"]="multiplayerSleep"

        DPACKS["painting_picker"]="Painting Picker"
        DPACK_DESC["painting_picker"]="Cycle through painting variants when placing via a trigger."
        DPACK_DEFAULT["painting_picker"]="n"; DPACK_CAT["painting_picker"]="Convenience"
        DPACK_VT_ID["painting_picker"]="paintingPicker"

        DPACKS["redstone_rotation_wrench"]="Redstone Rotation Wrench"
        DPACK_DESC["redstone_rotation_wrench"]="Craft a wrench to rotate redstone components in place."
        DPACK_DEFAULT["redstone_rotation_wrench"]="n"; DPACK_CAT["redstone_rotation_wrench"]="Convenience"
        DPACK_VT_ID["redstone_rotation_wrench"]="redstoneRotationWrench"

        DPACKS["spectator_conduit_power"]="Spectator Conduit Power"
        DPACK_DESC["spectator_conduit_power"]="Spectators get conduit power effects (useful for builders)."
        DPACK_DEFAULT["spectator_conduit_power"]="n"; DPACK_CAT["spectator_conduit_power"]="Convenience"
        DPACK_VT_ID["spectator_conduit_power"]="spectatorConduitPower"

        DPACKS["spectator_night_vision"]="Spectator Night Vision"
        DPACK_DESC["spectator_night_vision"]="Night vision is automatically applied in spectator mode."
        DPACK_DEFAULT["spectator_night_vision"]="n"; DPACK_CAT["spectator_night_vision"]="Convenience"
        DPACK_VT_ID["spectator_night_vision"]="spectatorNightVision"

        DPACKS["storm_channeling"]="Storm Channeling"
        DPACK_DESC["storm_channeling"]="Trident Channeling works in rain, not just thunderstorms."
        DPACK_DEFAULT["storm_channeling"]="n"; DPACK_CAT["storm_channeling"]="Convenience"
        DPACK_VT_ID["storm_channeling"]="stormChanneling"

        DPACKS["terracotta_rotation_wrench"]="Terracotta Rotation Wrench"
        DPACK_DESC["terracotta_rotation_wrench"]="Craft a wrench to rotate glazed terracotta in place."
        DPACK_DEFAULT["terracotta_rotation_wrench"]="n"; DPACK_CAT["terracotta_rotation_wrench"]="Convenience"
        DPACK_VT_ID["terracotta_rotation_wrench"]="terracottaRotationWrench"

        DPACKS["timber"]="Timber"
        DPACK_DESC["timber"]="Chop the bottom log to fell an entire tree instantly."
        DPACK_DEFAULT["timber"]="n"; DPACK_CAT["timber"]="Convenience"
        DPACK_VT_ID["timber"]="timber"

        DPACKS["unlock_all_recipes"]="Unlock All Recipes"
        DPACK_DESC["unlock_all_recipes"]="All crafting recipes unlocked for all players from the start."
        DPACK_DEFAULT["unlock_all_recipes"]="n"; DPACK_CAT["unlock_all_recipes"]="Convenience"
        DPACK_VT_ID["unlock_all_recipes"]="unlockAllRecipes"

        DPACKS["weed_stripper"]="Weed Stripper"
        DPACK_DESC["weed_stripper"]="Hoe clears grass, flowers and shrubs in a wider area."
        DPACK_DEFAULT["weed_stripper"]="n"; DPACK_CAT["weed_stripper"]="Convenience"
        DPACK_VT_ID["weed_stripper"]="weedStripper"

        # ── Gameplay Changes ────────────────────────────────────────────────────
        DPACKS["anti_creeper_grief"]="Anti Creeper Grief"
        DPACK_DESC["anti_creeper_grief"]="Creeper explosions don't destroy blocks."
        DPACK_DEFAULT["anti_creeper_grief"]="n"; DPACK_CAT["anti_creeper_grief"]="Gameplay Changes"
        DPACK_VT_ID["anti_creeper_grief"]="antiCreeperGrief"

        DPACKS["anti_enderman_grief"]="Anti Enderman Grief"
        DPACK_DESC["anti_enderman_grief"]="Endermen can't pick up blocks — stops world griefing."
        DPACK_DEFAULT["anti_enderman_grief"]="y"; DPACK_CAT["anti_enderman_grief"]="Gameplay Changes"
        DPACK_VT_ID["anti_enderman_grief"]="antiEndermanGrief"

        DPACKS["anti_ghast_grief"]="Anti Ghast Grief"
        DPACK_DESC["anti_ghast_grief"]="Ghast fireballs don't destroy Nether blocks."
        DPACK_DEFAULT["anti_ghast_grief"]="n"; DPACK_CAT["anti_ghast_grief"]="Gameplay Changes"
        DPACK_VT_ID["anti_ghast_grief"]="antiGhastGrief"

        DPACKS["armored_elytra"]="Armored Elytra"
        DPACK_DESC["armored_elytra"]="Combine elytra and chestplate to wear both at once."
        DPACK_DEFAULT["armored_elytra"]="n"; DPACK_CAT["armored_elytra"]="Gameplay Changes"
        DPACK_VT_ID["armored_elytra"]="armoredElytra"

        DPACKS["bat_membranes"]="Bat Membranes"
        DPACK_DESC["bat_membranes"]="Bats drop membranes used to craft a gliding cape."
        DPACK_DEFAULT["bat_membranes"]="n"; DPACK_CAT["bat_membranes"]="Gameplay Changes"
        DPACK_VT_ID["bat_membranes"]="batMembranes"

        DPACKS["classic_fishing"]="Classic Fishing Lure"
        DPACK_DESC["classic_fishing"]="Restore pre-1.16 fishing — treasure loot in any open water."
        DPACK_DEFAULT["classic_fishing"]="n"; DPACK_CAT["classic_fishing"]="Gameplay Changes"
        DPACK_VT_ID["classic_fishing"]="classicFishingLure"

        DPACKS["confetti_creepers"]="Confetti Creepers"
        DPACK_DESC["confetti_creepers"]="Creepers explode into colourful fireworks — cosmetic only."
        DPACK_DEFAULT["confetti_creepers"]="n"; DPACK_CAT["confetti_creepers"]="Gameplay Changes"
        DPACK_VT_ID["confetti_creepers"]="confettiCreepers"

        DPACKS["graves"]="Graves"
        DPACK_DESC["graves"]="Creates a grave on death that stores your items."
        DPACK_DEFAULT["graves"]="y"; DPACK_CAT["graves"]="Gameplay Changes"
        DPACK_VT_ID["graves"]="graves"

        DPACKS["husks_drop_sand"]="Husks Drop Sand"
        DPACK_DESC["husks_drop_sand"]="Husks drop sand when killed — makes sand renewable."
        DPACK_DEFAULT["husks_drop_sand"]="n"; DPACK_CAT["husks_drop_sand"]="Gameplay Changes"
        DPACK_VT_ID["husks_drop_sand"]="husksDropSand"

        DPACKS["silk_touch_amethyst"]="Silk Touch Building Amethyst"
        DPACK_DESC["silk_touch_amethyst"]="Silk Touch lets you mine amethyst clusters as placeable blocks."
        DPACK_DEFAULT["silk_touch_amethyst"]="n"; DPACK_CAT["silk_touch_amethyst"]="Gameplay Changes"
        DPACK_VT_ID["silk_touch_amethyst"]="silkTouchBuildingAmethyst"

        DPACKS["xp_bottling"]="XP Bottling"
        DPACK_DESC["xp_bottling"]="Store your XP in bottles of enchanting at a grindstone."
        DPACK_DEFAULT["xp_bottling"]="n"; DPACK_CAT["xp_bottling"]="Gameplay Changes"
        DPACK_VT_ID["xp_bottling"]="xpBottling"

        # ── Informative ───────────────────────────────────────────────────────
        DPACKS["afk_display"]="AFK Display"
        DPACK_DESC["afk_display"]="Shows [AFK] next to player names when idle."
        DPACK_DEFAULT["afk_display"]="y"; DPACK_CAT["afk_display"]="Informative"
        DPACK_VT_ID["afk_display"]="afkDisplay"

        DPACKS["coords_hud"]="Coordinates HUD"
        DPACK_DESC["coords_hud"]="Players toggle coordinate display in the actionbar."
        DPACK_DEFAULT["coords_hud"]="y"; DPACK_CAT["coords_hud"]="Informative"
        DPACK_VT_ID["coords_hud"]="coordinatesHud"

        DPACKS["durability_ping"]="Durability Ping"
        DPACK_DESC["durability_ping"]="Sound + actionbar alert when tool/armor durability gets low."
        DPACK_DEFAULT["durability_ping"]="y"; DPACK_CAT["durability_ping"]="Informative"
        DPACK_VT_ID["durability_ping"]="durabilityPing"

        DPACKS["nether_portal_coords"]="Nether Portal Coords"
        DPACK_DESC["nether_portal_coords"]="Chat shows overworld↔nether coordinate conversion on entry."
        DPACK_DEFAULT["nether_portal_coords"]="y"; DPACK_CAT["nether_portal_coords"]="Informative"
        DPACK_VT_ID["nether_portal_coords"]="netherPortalCoords"

        DPACKS["real_time_clock"]="Real Time Clock"
        DPACK_DESC["real_time_clock"]="Shows real-world time in actionbar via a trigger."
        DPACK_DEFAULT["real_time_clock"]="n"; DPACK_CAT["real_time_clock"]="Informative"
        DPACK_VT_ID["real_time_clock"]="realTimeClock"

        DPACKS["spawning_spheres"]="Spawning Spheres"
        DPACK_DESC["spawning_spheres"]="Visualise the mob spawn radius around a block."
        DPACK_DEFAULT["spawning_spheres"]="n"; DPACK_CAT["spawning_spheres"]="Informative"
        DPACK_VT_ID["spawning_spheres"]="spawningSpheres"

        DPACKS["track_raw_statistics"]="Track Raw Statistics"
        DPACK_DESC["track_raw_statistics"]="Scoreboards tracking raw stat values (distance, damage, etc.)."
        DPACK_DEFAULT["track_raw_statistics"]="n"; DPACK_CAT["track_raw_statistics"]="Informative"
        DPACK_VT_ID["track_raw_statistics"]="trackRawStatistics"

        DPACKS["track_statistics"]="Track Statistics"
        DPACK_DESC["track_statistics"]="Scoreboards for deaths, mob kills, playtime."
        DPACK_DEFAULT["track_statistics"]="y"; DPACK_CAT["track_statistics"]="Informative"
        DPACK_VT_ID["track_statistics"]="trackStatistics"

        DPACKS["village_death_messages"]="Village Death Messages"
        DPACK_DESC["village_death_messages"]="Chat alert when a villager is killed nearby."
        DPACK_DEFAULT["village_death_messages"]="y"; DPACK_CAT["village_death_messages"]="Informative"
        DPACK_VT_ID["village_death_messages"]="villageDeathMessages"

        DPACKS["workstation_highlights"]="Workstation Highlights"
        DPACK_DESC["workstation_highlights"]="Particles show which workstation a villager is linked to."
        DPACK_DEFAULT["workstation_highlights"]="y"; DPACK_CAT["workstation_highlights"]="Informative"
        DPACK_VT_ID["workstation_highlights"]="workstationHighlights"

        DPACKS["wandering_trader_ann"]="Wandering Trader Announcements"
        DPACK_DESC["wandering_trader_ann"]="Chat message when a Wandering Trader appears near spawn."
        DPACK_DEFAULT["wandering_trader_ann"]="n"; DPACK_CAT["wandering_trader_ann"]="Informative"
        DPACK_VT_ID["wandering_trader_ann"]="wanderingTraderAnnouncements"

        # ── Teleport Commands ───────────────────────────────────────────────────
        DPACKS["tp_back"]="Back"
        DPACK_DESC["tp_back"]="Return to last death or teleport location via trigger."
        DPACK_DEFAULT["tp_back"]="n"; DPACK_CAT["tp_back"]="Teleport Commands"
        DPACK_VT_ID["tp_back"]="back"

        DPACKS["homes"]="Homes"
        DPACK_DESC["homes"]="Set and teleport to named home locations via trigger."
        DPACK_DEFAULT["homes"]="n"; DPACK_CAT["homes"]="Teleport Commands"
        DPACK_VT_ID["homes"]="homes"

        DPACKS["spawn"]="Spawn"
        DPACK_DESC["spawn"]="Set and return to a global spawn point via trigger."
        DPACK_DEFAULT["spawn"]="n"; DPACK_CAT["spawn"]="Teleport Commands"
        DPACK_VT_ID["spawn"]="spawn"

        DPACKS["tpa"]="TPA"
        DPACK_DESC["tpa"]="Teleport-request system via trigger."
        DPACK_DEFAULT["tpa"]="n"; DPACK_CAT["tpa"]="Teleport Commands"
        DPACK_VT_ID["tpa"]="tpa"

        # ── Admin Tools ─────────────────────────────────────────────────────────
        DPACKS["custom_villager_shops"]="Custom Villager Shops"
        DPACK_DESC["custom_villager_shops"]="Build custom villager trade shops using a book."
        DPACK_DEFAULT["custom_villager_shops"]="n"; DPACK_CAT["custom_villager_shops"]="Admin Tools"
        DPACK_VT_ID["custom_villager_shops"]="customVillagerShops"

        DPACKS["kill_empty_boats"]="Kill Empty Boats"
        DPACK_DESC["kill_empty_boats"]="Periodically removes riderless boats to cut entity lag."
        DPACK_DEFAULT["kill_empty_boats"]="n"; DPACK_CAT["kill_empty_boats"]="Admin Tools"
        DPACK_VT_ID["kill_empty_boats"]="killEmptyBoats"

        local DPACK_ORDER=(
            # Decorative / Cosmetic
            "armor_statues" "custom_nether_portals" "mini_blocks" "more_mob_heads"
            "name_colors" "player_head_drops" "silence_mobs"
            "wandering_trades" "wandering_trades_hermit"
            # Convenience
            "cauldron_concrete" "cauldron_mud" "chunk_loaders" "double_shulker_shells"
            "dragon_drops" "elevators" "ender_chest_drops" "fast_leaf_decay"
            "glass_always_drops" "more_effective_tools" "multiplayer_sleep"
            "painting_picker" "redstone_rotation_wrench" "spectator_conduit_power"
            "spectator_night_vision" "storm_channeling" "terracotta_rotation_wrench"
            "timber" "unlock_all_recipes" "weed_stripper"
            # Gameplay Changes
            "anti_creeper_grief" "anti_enderman_grief" "anti_ghast_grief"
            "armored_elytra" "bat_membranes" "classic_fishing" "confetti_creepers"
            "graves" "husks_drop_sand" "silk_touch_amethyst" "xp_bottling"
            # Informative
            "afk_display" "coords_hud" "durability_ping" "nether_portal_coords"
            "real_time_clock" "spawning_spheres" "track_raw_statistics"
            "track_statistics" "village_death_messages" "workstation_highlights"
            "wandering_trader_ann"
            # Teleport Commands
            "tp_back" "homes" "spawn" "tpa"
            # Admin Tools
            "custom_villager_shops" "kill_empty_boats"
        )

        local dp
        for dp in "${DPACK_ORDER[@]}"; do
            [[ "${DPACK_DEFAULT[$dp]}" == "y" ]] && SELECTED_DATAPACKS+=("$dp")
        done

        echo ""
        log_info "Vanilla Tweaks datapacks"
        echo "  ✓ = pre-selected (recommended defaults)"
        echo "  Only the recommended datapacks are shown — toggle numbers then press Done."
        echo "  Pick 'Show all datapacks' to browse the full Vanilla Tweaks catalogue."
        echo ""

        local SHOW_ALL=false
        local VISIBLE=() _LAST_CAT _cat _marker _n _dp_choice _dp_toggle _dp_all _dp_done _dp_key
        while true; do
            VISIBLE=()
            for dp in "${DPACK_ORDER[@]}"; do
                if [ "$SHOW_ALL" = true ] || [[ "${DPACK_DEFAULT[$dp]}" == "y" ]]; then
                    VISIBLE+=("$dp")
                fi
            done

            _LAST_CAT=""
            for i in "${!VISIBLE[@]}"; do
                dp="${VISIBLE[$i]}"
                _cat="${DPACK_CAT[$dp]}"
                if [ "$_cat" != "$_LAST_CAT" ]; then
                    echo ""
                    echo -e "  \033[1;33m── ${_cat}\033[0m"
                    _LAST_CAT="$_cat"
                fi
                _marker=" "
                [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] && _marker="✓"
                printf "  %2d) %s %-34s %s\n" "$((i+1))" "$_marker" "${DPACKS[$dp]}" "${DPACK_DESC[$dp]}"
            done
            echo ""
            _n=${#VISIBLE[@]}
            if [ "$SHOW_ALL" = true ]; then
                echo "  $(( _n + 1 ))) Show recommended only"
            else
                echo "  $(( _n + 1 ))) Show all datapacks (${#DPACK_ORDER[@]} total)"
            fi
            echo "  $(( _n + 2 ))) Select All"
            echo "  $(( _n + 3 ))) Done"
            echo ""
            read -p "Selection: " _dp_choice

            _dp_toggle=$(( _n + 1 ))
            _dp_all=$(( _n + 2 ))
            _dp_done=$(( _n + 3 ))

            if [ "$_dp_choice" = "$_dp_toggle" ]; then
                [ "$SHOW_ALL" = true ] && SHOW_ALL=false || SHOW_ALL=true
            elif [ "$_dp_choice" = "$_dp_all" ]; then
                for dp in "${VISIBLE[@]}"; do
                    [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] || SELECTED_DATAPACKS+=("$dp")
                done
                break
            elif [ "$_dp_choice" = "$_dp_done" ] || [ "$_dp_choice" = "done" ]; then
                break
            elif [[ "$_dp_choice" =~ ^[0-9]+$ ]] && [ "$_dp_choice" -ge 1 ] && \
                 [ "$_dp_choice" -le "$_n" ]; then
                _dp_key="${VISIBLE[$((_dp_choice-1))]}"
                if [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${_dp_key} " ]]; then
                    SELECTED_DATAPACKS=("${SELECTED_DATAPACKS[@]/$_dp_key}")
                    SELECTED_DATAPACKS=(${SELECTED_DATAPACKS[@]})
                else
                    SELECTED_DATAPACKS+=("$_dp_key")
                fi
            else
                log_warning "Invalid selection"
            fi
        done
        fi  # end USE_VT_ZIPS=false datapack picker
    fi

    # Vanilla/Paper/Forge: still need a concrete version (no picker ran above).
    if [ -z "$MC_VERSION" ]; then
        prompt_text "Minecraft version [${RECENT_VERSIONS[0]}]:" "${RECENT_VERSIONS[0]}" MC_VERSION
    fi

    # ── Chunky pre-generation config ────────────────────────────────────────────
    local PREGEN_RADIUS=5000
    local USE_BORDER=true
    local BORDER_SIZE=10000
    if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
        echo ""
        log_info "Chunky Pre-Generation"
        echo "Chunky pre-generates chunks around spawn so players exploring new areas"
        echo "won't cause lag spikes. Run once before opening the server to players."
        echo ""
        prompt_text "Pre-generation radius in blocks [5000]:" "5000" PREGEN_RADIUS
        BORDER_SIZE=$(( PREGEN_RADIUS * 2 ))
        echo ""
        local _border=""
        prompt_yn "Set world border at ${BORDER_SIZE} blocks (2× radius)? (y/n) [n]:" "n" _border
        [[ ${_border:-n} =~ ^[Yy]$ ]] && USE_BORDER=true || USE_BORDER=false
    fi

    # ── Networking ──────────────────────────────────────────────────────────────
    echo ""
    log_info "Networking / Remote Access"
    echo ""
    echo "How will players connect from outside your network?"
    echo ""
    echo "  1) Port forward + DNS (recommended)"
    echo "     Forward your Minecraft port on your router, add DNS records."
    echo "     Players connect to mc.yourdomain.com — no port number needed."
    echo "     Full step-by-step instructions generated in MINECRAFT_NETWORKING.md"
    echo ""
    echo "  2) playit.gg tunnel (fallback — use if you cannot port forward)"
    echo "     Free tunnel, no router access needed, works behind CGNAT."
    echo "     All player traffic routes through playit.gg's servers."
    echo "     See their privacy policy: https://playit.gg/privacy-policy/"
    echo ""
    echo "  3) Local only (no external access)"
    echo ""
    local NET_CHOICE=""
    prompt_text "Networking choice [1]:" "1" NET_CHOICE

    local USE_PLAYIT=false
    local USE_PORTFORWARD=false
    case $NET_CHOICE in
        1) USE_PORTFORWARD=true ;;
        2) USE_PLAYIT=true ;;
        3) ;;
    esac

    local MC_DOMAIN=""
    local BASE_DOMAIN="${SITE_DOMAIN:-}"
    if [ "$USE_PLAYIT" = true ] || [ "$USE_PORTFORWARD" = true ]; then
        if [ -n "$BASE_DOMAIN" ]; then
            local _PREFIX=""
            prompt_text "Subdomain prefix for Minecraft [mc].${BASE_DOMAIN}:" "mc" _PREFIX
            MC_DOMAIN="${_PREFIX}.${BASE_DOMAIN}"
            echo "  → ${MC_DOMAIN}"
        else
            prompt_text "Domain for Minecraft (e.g. mc.yourdomain.com) [leave blank to skip]:" "" MC_DOMAIN
        fi
    fi

    # ── Create directory structure ──────────────────────────────────────────────
    mkdir -p "$MC_DIR"/{data,mods-download,datapacks-download,config}
    ensure_docker_dir_ownership "$MC_DIR"
    # The minecraft server runs as uid=1000; pre-create writable dirs so mods
    # (C2ME, Lithium) can write their config files on first start.
    chown -R 1000:1000 "$MC_DIR/data" "$MC_DIR/config" 2>/dev/null \
        || log_warning "Could not chown minecraft dirs to uid 1000 — if C2ME/Lithium crash on start, run: sudo chown -R 1000:1000 $MC_DIR/data $MC_DIR/config"
    cd "$MC_DIR" || return 1

    # ── Whitelist pre-population ────────────────────────────────────────────────
    local _WL_NEED_WRITE=false
    [ "$WHITELIST_ENABLED" = true ] && \
        [ $(( ${#WHITELIST_PLAYERS[@]} + ${#WHITELIST_PRELOADED[@]} )) -gt 0 ] && \
        _WL_NEED_WRITE=true

    if [ "$_WL_NEED_WRITE" = true ]; then
        log_info "Building whitelist.json..."
        local _WL_JSON="["
        local _WL_FIRST=true
        local _WL_COUNT=0

        # Preloaded entries — UUIDs already known, no API call needed
        local _wl_name _uuid
        for _wl_name in "${!WHITELIST_PRELOADED[@]}"; do
            _uuid="${WHITELIST_PRELOADED[$_wl_name]}"
            log_success "  $_wl_name → $_uuid (from existing whitelist)"
            [ "$_WL_FIRST" = true ] || _WL_JSON+=","
            _WL_FIRST=false
            _WL_COUNT=$((_WL_COUNT + 1))
            _WL_JSON+="
  {\"uuid\": \"$_uuid\", \"name\": \"$_wl_name\"}"
        done

        # New gamertags — look up via Mojang API
        if [ ${#WHITELIST_PLAYERS[@]} -gt 0 ]; then
            log_info "  Looking up UUIDs via Mojang API..."
            local _player _resp _name
            for _player in "${WHITELIST_PLAYERS[@]}"; do
                _resp=$(curl -sf --max-time 10 \
                    "https://api.mojang.com/users/profiles/minecraft/${_player}" 2>/dev/null || echo "")
                if [ -z "$_resp" ]; then
                    log_warning "  '$_player' not found — skipping (account may not exist)"
                    continue
                fi
                _uuid=$(echo "$_resp" | python3 -c "
import sys, json
d = json.load(sys.stdin)
uid = d['id']
print(f'{uid[:8]}-{uid[8:12]}-{uid[12:16]}-{uid[16:20]}-{uid[20:]}')
" 2>/dev/null || echo "")
                _name=$(echo "$_resp" | python3 -c "
import sys, json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null || echo "$_player")
                if [ -z "$_uuid" ]; then
                    log_warning "  Could not parse UUID for '$_player' — skipping"
                    continue
                fi
                log_success "  $_name → $_uuid"
                [ "$_WL_FIRST" = true ] || _WL_JSON+=","
                _WL_FIRST=false
                _WL_COUNT=$((_WL_COUNT + 1))
                _WL_JSON+="
  {\"uuid\": \"$_uuid\", \"name\": \"$_name\"}"
            done
        fi

        _WL_JSON+="
]"
        echo "$_WL_JSON" > "$MC_DIR/data/whitelist.json"
        chown 1000:1000 "$MC_DIR/data/whitelist.json" 2>/dev/null || true
        log_success "whitelist.json written with $_WL_COUNT player(s)"
    fi

    # ── Fetch mod JARs from Modrinth ────────────────────────────────────────────
    download_modrinth_mod() {
        local slug="$1"
        local label="$2"
        local mc_ver="$3"
        local loader="${4:-fabric}"

        local api_url="https://api.modrinth.com/v2/project/${slug}/version"
        local query="?game_versions=%5B%22${mc_ver}%22%5D&loaders=%5B%22${loader}%22%5D"

        local jar_url
        jar_url=$(curl -sf "${api_url}${query}" \
            | python3 -c "
import sys, json
versions = json.load(sys.stdin)
for v in versions:
    for f in v.get('files', []):
        if f.get('primary'):
            print(f['url'])
            sys.exit(0)
" 2>/dev/null || echo "")

        if [ -z "$jar_url" ]; then
            log_warning "  $label not available for MC $mc_ver yet — skipping (check https://modrinth.com/mod/${slug} for updates)"
            return 1
        fi

        local fname
        fname=$(basename "$jar_url" | cut -d'?' -f1)
        curl -sfL "$jar_url" -o "mods-download/${fname}" \
            && log_success "  Downloaded: $fname" \
            || log_warning "  Failed to download $label"
    }

    if [ "$SUPPORTS_FABRIC_MODS" = true ] && [ ${#SELECTED_MODS[@]} -gt 0 ]; then
        log_info "Downloading mods from Modrinth..."
        local ALWAYS_DEPS=("fabric-api" "fabric-language-kotlin")
        local dep mid
        for dep in "${ALWAYS_DEPS[@]}"; do
            download_modrinth_mod "$dep" "$dep" "$MC_VERSION" || true
        done
        for mod in "${SELECTED_MODS[@]}"; do
            mid="${MOD_MODRINTH_ID[$mod]}"
            download_modrinth_mod "$mid" "${MODS[$mod]}" "$MC_VERSION" || true
        done
    fi

    # ── Vanilla Tweaks datapacks — auto-install or manual instructions ───────────
    if [ "$SUPPORTS_FABRIC_MODS" = true ] && { [ "$USE_VT_ZIPS" = true ] || [ ${#SELECTED_DATAPACKS[@]} -gt 0 ]; }; then
        if [ "$USE_VT_ZIPS" = true ]; then
            echo ""
            log_info "Installing pre-packaged Vanilla Tweaks files..."
            if [ -n "$VT_DATAPACK_ZIP" ]; then
                log_info "  Unzipping datapacks: $(basename "$VT_DATAPACK_ZIP")"
                if unzip -o "$VT_DATAPACK_ZIP" -d "$MC_DIR/datapacks-download/"; then
                    log_success "  Datapacks extracted to datapacks-download/"
                else
                    log_warning "  unzip failed — copy files manually to $MC_DIR/datapacks-download/"
                fi
            fi
            if [ -n "$VT_CRAFTING_ZIP" ]; then
                log_info "  Copying crafting tweaks: $(basename "$VT_CRAFTING_ZIP")"
                if cp "$VT_CRAFTING_ZIP" "$MC_DIR/datapacks-download/"; then
                    log_success "  Crafting tweaks ZIP copied to datapacks-download/"
                else
                    log_warning "  Copy failed — copy $(basename "$VT_CRAFTING_ZIP") manually to $MC_DIR/datapacks-download/"
                fi
            fi
            ensure_docker_dir_ownership "$MC_DIR/datapacks-download"
        else
            local VT_VERSION
            VT_VERSION=$(echo "$MC_VERSION" | awk -F. '{print $1"."$2}')

            echo ""
            log_info "Vanilla Tweaks — download your selected packs manually:"
            echo ""
            echo "  ┌─ Pre-configured selections (open in a browser on this machine) ──────┐"
            echo "  │  Datapacks (22):       https://vanillatweaks.net/share#B3QqSd        │"
            echo "  │  Crafting tweaks (10): https://vanillatweaks.net/share#SqzGkO        │"
            echo "  └────────────────────────────────────────────────────────────────────────┘"
            echo ""
            echo "  Or pick manually — go to https://vanillatweaks.net/picker/datapacks/"
            echo "  Select version ${VT_VERSION}, then enable your chosen packs:"
            echo ""
            local _LAST_CAT="" _cat dp
            for dp in "${DPACK_ORDER[@]}"; do
                [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] || continue
                _cat="${DPACK_CAT[$dp]}"
                if [ "$_cat" != "$_LAST_CAT" ]; then
                    echo "       ── ${_cat}"
                    _LAST_CAT="$_cat"
                fi
                echo "         • ${DPACKS[$dp]}"
            done
            echo ""
            echo "  ── How to install ────────────────────────────────────────────────────"
            echo "  1. Download the ZIP from vanillatweaks.net"
            echo "  2. For next time: rename to include 'datapack' + version"
            echo "     (e.g. datapacks_${VT_VERSION}.zip) and place in extras/datapacks/"
            echo "  3. SCP directly to this server (run on your local machine):"
            echo "       scp ~/Downloads/VanillaTweaks*.zip ${ACTUAL_USER}@$(hostname -I | awk '{print $1}'):${MC_DIR}/datapacks-download/"
            echo "  4. On this server:"
            echo "       cd ${MC_DIR}/datapacks-download"
            echo "       unzip 'VanillaTweaks*.zip' && rm VanillaTweaks*.zip"
            echo "  5. Restart:  cd ${MC_DIR} && docker compose up -d"
            echo "  ──────────────────────────────────────────────────────────────────────"
            echo "  The itzg image auto-installs .zip files from /datapacks/ on startup."
            echo "  Datapacks land in ${MC_NAME}/data/datapacks/ and persist across restarts."
            echo ""
            read -p "  Press Enter when datapacks are in datapacks-download/ (or Enter to skip): "
        fi
    fi

    # ── LuckPerms bootstrap script ──────────────────────────────────────────────
    if [[ " ${SELECTED_MODS[*]} " =~ " luckperms " ]]; then
        log_info "Generating LuckPerms bootstrap commands..."
        mkdir -p "$MC_DIR/luckperms-bootstrap"
        cat > "$MC_DIR/luckperms-bootstrap/bootstrap.txt" << 'LPEOF'
# LuckPerms bootstrap — runs once on first server start via startup script
# Groups: default (all players) → mod → admin

lp creategroup mod
lp creategroup admin

# Default player permissions
lp group default permission set essentials.home true
lp group default permission set essentials.sethome true
lp group default permission set essentials.delhome true
lp group default permission set essentials.back true
lp group default permission set essentials.spawn true
lp group default permission set essentials.tpa true
lp group default permission set essentials.tpaccept true
lp group default permission set essentials.tpdeny true
lp group default permission set essentials.warp true

# Mod inherits default
lp group mod parent set default
lp group mod permission set essentials.kick true
lp group mod permission set essentials.mute true
lp group mod permission set essentials.tp true
lp group mod permission set essentials.tphere true
lp group mod permission set ledger.query true

# Admin inherits mod
lp group admin parent set mod
lp group admin permission set luckperms.* true
lp group admin permission set essentials.* true
lp group admin permission set "*" true
LPEOF
        log_success "LuckPerms bootstrap written to ${MC_NAME}/luckperms-bootstrap/bootstrap.txt"
    fi

    # ── Dockerfile ────────────────────────────────────────────────────────────
    log_info "Generating Dockerfile..."
    cat > "$MC_DIR/Dockerfile" << 'MCEOF'
FROM itzg/minecraft-server:latest

# Mods and datapacks are copied in at build time
COPY mods-download/ /mods/
COPY datapacks-download/ /datapacks/
MCEOF

    if [[ " ${SELECTED_MODS[*]} " =~ " luckperms " ]]; then
        echo "COPY luckperms-bootstrap/bootstrap.txt /luckperms-bootstrap.txt" >> "$MC_DIR/Dockerfile"
    fi
    log_success "Dockerfile created"

    # ── pregen-startup.sh ───────────────────────────────────────────────────────
    # Lives inside data/ (bind-mounted to /data). itzg executes /data/*.sh on
    # startup; our guard exits 0 unless PREGEN=1 is set, so normal startup is
    # unaffected. Do NOT COPY into /data (it's a bind-mount) or add extra mounts.
    if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
        [ -e "$MC_DIR/pregen-startup.sh" ] && rm -rf "$MC_DIR/pregen-startup.sh"
        mkdir -p "$MC_DIR/data"
        [ -e "$MC_DIR/data/pregen-startup.sh" ] && [ ! -f "$MC_DIR/data/pregen-startup.sh" ] \
            && rm -rf "$MC_DIR/data/pregen-startup.sh"
        local BORDER_LINE=""
        [ "$USE_BORDER" = true ] && BORDER_LINE="mc-send-to-console \"worldborder set ${BORDER_SIZE}\""
        cat > "$MC_DIR/data/pregen-startup.sh" << PREGENEOF
#!/bin/bash
[ "\${PREGEN:-0}" = "1" ] || exit 0
_PIPE=/tmp/minecraft-console-in
echo "Waiting for server to be ready (may take 1-2 minutes)..."
_WAIT=0
until [ -p "\$_PIPE" ]; do
    sleep 3
    _WAIT=\$((\$_WAIT + 3))
    if [ \$_WAIT -ge 180 ]; then
        echo "ERROR: Timed out waiting for server (3 min). Is CREATE_CONSOLE_IN_PIPE=true set?"
        exit 1
    fi
done
echo "Server ready. Sending pre-gen commands..."
${BORDER_LINE}
mc-send-to-console "chunky center 0 0"
mc-send-to-console "chunky radius ${PREGEN_RADIUS}"
mc-send-to-console "chunky start"
echo "Pre-gen started. Monitor progress:"
echo "  docker exec ${MC_NAME} mc-send-to-console 'chunky progress'"
PREGENEOF
        chmod +x "$MC_DIR/data/pregen-startup.sh"
        chown 1000:1000 "$MC_DIR/data/pregen-startup.sh" 2>/dev/null || true
        log_success "pregen-startup.sh written to ${MC_NAME}/data/"

        local _BORDER_STATUS
        [ "$USE_BORDER" = true ] && _BORDER_STATUS="enabled" || _BORDER_STATUS="disabled"
        cat > "$MC_DIR/PREGEN_INSTRUCTIONS.md" << PREGENINFOEOF
# Chunk Pre-Generation

Pre-gen radius: ${PREGEN_RADIUS} blocks
World border:   ${BORDER_SIZE} blocks (${_BORDER_STATUS})

Pre-generation ran automatically when the server started. It uses Chunky
to generate all chunks within the radius before players explore.

## Monitor progress

\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console 'chunky progress'
\`\`\`

## Re-run manually (if server was started without pre-gen)

\`\`\`bash
docker exec -e PREGEN=1 -u 1000 ${MC_NAME} bash /data/pregen-startup.sh
\`\`\`

## Cancel pre-gen

\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console 'chunky cancel'
\`\`\`
PREGENINFOEOF
        chown "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR/PREGEN_INSTRUCTIONS.md" 2>/dev/null || true
        log_success "PREGEN_INSTRUCTIONS.md written"
    fi

    # ── Standalone docker-compose.yml (per-service folder) ──────────────────────
    log_info "Writing ${MC_NAME}/docker-compose.yml..."

    local MC_ENV=""
    MC_ENV+="      - TYPE=${FLAVOUR}"$'\n'
    MC_ENV+="      - VERSION=${MC_VERSION}"$'\n'
    MC_ENV+="      - EULA=TRUE"$'\n'
    MC_ENV+="      - SERVER_NAME=${SERVER_NAME}"$'\n'
    MC_ENV+="      - MAX_PLAYERS=${MAX_PLAYERS}"$'\n'
    MC_ENV+="      - DIFFICULTY=${DIFFICULTY}"$'\n'
    MC_ENV+="      - MODE=${GAMEMODE}"$'\n'
    MC_ENV+="      - WHITELIST=${WHITELIST_ENABLED}"$'\n'
    MC_ENV+="      - MEMORY=${MC_RAM}G"$'\n'
    MC_ENV+="      - ENABLE_RCON=false"$'\n'
    MC_ENV+="      - MOTD=${SERVER_NAME}"$'\n'
    MC_ENV+="      - CREATE_CONSOLE_IN_PIPE=true"$'\n'
    [ -n "$MC_SEED" ] && MC_ENV+="      - SEED=${MC_SEED}"$'\n'
    if [ "$SUPPORTS_FABRIC_MODS" = true ]; then
        MC_ENV+="      - MODS_DIR=/mods"$'\n'
    fi

    # Volumes: data always; config too when chunky is selected.
    local MC_VOLUMES="      - ./data:/data"
    if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
        MC_VOLUMES+="
      - ./config:/data/config"
    fi

    cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF
name: ${MC_NAME}

services:
  ${MC_NAME}:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: ${MC_NAME}
    environment:
${MC_ENV}    ports:
      - "${MC_PORT}:25565"
    volumes:
${MC_VOLUMES}
    restart: unless-stopped
COMPOSEEOF

    # Optional playit.gg service appended as a SECOND service into THIS
    # instance's compose (shares the minecraft container's network namespace).
    if [ "$USE_PLAYIT" = true ]; then
        cat >> "$MC_DIR/docker-compose.yml" << PLAYITEOF

  playit-${MC_NAME}:
    image: ghcr.io/playit-cloud/playit-agent:latest
    container_name: playit-${MC_NAME}
    network_mode: "service:${MC_NAME}"
    env_file:
      - ./.env.playit
    restart: unless-stopped
    depends_on:
      - ${MC_NAME}
PLAYITEOF
    fi
    log_success "Created ${MC_NAME}/docker-compose.yml"

    if [ "$USE_PLAYIT" = true ]; then
        cat > "$MC_DIR/.env.playit" << 'ENVEOF'
# Get your secret from https://playit.gg after creating a tunnel
# Then add it here:
PLAYIT_SECRET=your_secret_key_here
ENVEOF
        log_success "Created ${MC_NAME}/.env.playit — add your playit.gg secret after signing up"
    fi

    # ── Client mod download page (own folder + standalone compose) ──────────────
    echo ""
    log_info "Client Mod Download Page"
    echo ""
    echo "A local webpage where your players can download all client mods at once."
    echo ""
    local CM_ENABLE=""
    prompt_yn "Enable client mod download page? (y/n) [y]:" "y" CM_ENABLE
    if [[ ${CM_ENABLE:-y} =~ ^[Yy]$ ]]; then

        local MODS_PORT=""
        prompt_text "Port for download page [8091]:" "8091" MODS_PORT
        local MODS_DOMAIN=""
        if [ -n "$BASE_DOMAIN" ]; then
            local _PREFIX=""
            prompt_text "Subdomain prefix for mod download page [mods].${BASE_DOMAIN}:" "mods" _PREFIX
            MODS_DOMAIN="${_PREFIX}.${BASE_DOMAIN}"
            echo "  → ${MODS_DOMAIN}"
        else
            prompt_text "Subdomain for mod download page (e.g. mods.yourdomain.com) [leave blank to skip]:" "" MODS_DOMAIN
        fi

        # Per-instance folder so multiple servers don't share one mods page.
        local CM_NAME="client-mods"; [ "$MC_NAME" != "minecraft" ] && CM_NAME="client-mods-${MC_NAME}"
        local CLIENT_MODS_DIR="$DOCKER_DIR/$CM_NAME"
        mkdir -p "$CLIENT_MODS_DIR/files"
        ensure_docker_dir_ownership "$CLIENT_MODS_DIR"

        log_info "Downloading client mods from Modrinth..."

        declare -A CMODS
        declare -A CMOD_DESC
        declare -A CMOD_SLUG
        declare -A CMOD_URL

        CMODS["xaeros_minimap"]="Xaero's Minimap"
        CMOD_DESC["xaeros_minimap"]="Corner minimap with waypoints, entity radar, and cave mode."
        CMOD_SLUG["xaeros_minimap"]="xaeros-minimap"
        CMOD_URL["xaeros_minimap"]="https://modrinth.com/mod/xaeros-minimap"

        CMODS["xaeros_worldmap"]="Xaero's World Map"
        CMOD_DESC["xaeros_worldmap"]="Fullscreen map of everywhere you've explored."
        CMOD_SLUG["xaeros_worldmap"]="xaeros-world-map"
        CMOD_URL["xaeros_worldmap"]="https://modrinth.com/mod/xaeros-world-map"

        CMODS["replaymod"]="ReplayMod"
        CMOD_DESC["replaymod"]="Record and replay your game sessions. View replays in cinematic mode."
        CMOD_SLUG["replaymod"]="replaymod"
        CMOD_URL["replaymod"]="https://modrinth.com/mod/replaymod"

        CMODS["nochatreports"]="NoChatReports"
        CMOD_DESC["nochatreports"]="Disables Mojang's chat reporting on the client side."
        CMOD_SLUG["nochatreports"]="no-chat-reports"
        CMOD_URL["nochatreports"]="https://modrinth.com/mod/no-chat-reports"

        CMODS["sodium"]="Sodium"
        CMOD_DESC["sodium"]="Major FPS improvement — the most impactful performance mod available."
        CMOD_SLUG["sodium"]="sodium"
        CMOD_URL["sodium"]="https://modrinth.com/mod/sodium"

        CMODS["iris"]="Iris Shaders"
        CMOD_DESC["iris"]="Shader support that works alongside Sodium."
        CMOD_SLUG["iris"]="iris"
        CMOD_URL["iris"]="https://modrinth.com/mod/iris"

        CMODS["indium"]="Indium"
        CMOD_DESC["indium"]="Sodium compatibility layer — needed by some other mods."
        CMOD_SLUG["indium"]="indium"
        CMOD_URL["indium"]="https://modrinth.com/mod/indium"

        CMODS["fabric_api"]="Fabric API"
        CMOD_DESC["fabric_api"]="Required by almost all Fabric mods. Install this first."
        CMOD_SLUG["fabric_api"]="fabric-api"
        CMOD_URL["fabric_api"]="https://modrinth.com/mod/fabric-api"

        local CMOD_ORDER=("fabric_api" "nochatreports" "sodium" "iris" "indium"
                    "xaeros_minimap" "xaeros_worldmap" "replaymod")

        declare -A CMOD_FILENAME
        declare -A CMOD_FILESIZE

        local key slug name jar_url fname fpath
        for key in "${CMOD_ORDER[@]}"; do
            slug="${CMOD_SLUG[$key]}"
            name="${CMODS[$key]}"

            jar_url=$(curl -sf \
                "https://api.modrinth.com/v2/project/${slug}/version?game_versions=%5B%22${MC_VERSION}%22%5D&loaders=%5B%22fabric%22%5D" \
                | python3 -c "
import sys, json
versions = json.load(sys.stdin)
for v in versions:
    for f in v.get('files', []):
        if f.get('primary'):
            print(f['url'])
            sys.exit(0)
" 2>/dev/null || echo "")

            if [ -z "$jar_url" ]; then
                log_warning "  Could not find $name for MC $MC_VERSION — will link to Modrinth instead"
                CMOD_FILENAME[$key]=""
                CMOD_FILESIZE[$key]=""
                continue
            fi

            fname=$(basename "$jar_url" | cut -d'?' -f1)
            fpath="$CLIENT_MODS_DIR/files/$fname"

            if curl -sfL "$jar_url" -o "$fpath"; then
                log_success "  Downloaded: $fname"
                CMOD_FILENAME[$key]="$fname"
                CMOD_FILESIZE[$key]=$(du -h "$fpath" | cut -f1)
            else
                log_warning "  Failed: $name"
                CMOD_FILENAME[$key]=""
                CMOD_FILESIZE[$key]=""
            fi
        done

        # Essential Mod — not on Modrinth, link to official site
        CMODS["essential"]="Essential Mod"
        CMOD_DESC["essential"]="Invite friends to worlds, cosmetics, social features. Not on Modrinth."
        CMOD_SLUG["essential"]=""
        CMOD_URL["essential"]="https://essential.gg/download"
        CMOD_FILENAME["essential"]=""
        CMOD_FILESIZE["essential"]=""
        CMOD_ORDER+=("essential")

        log_info "Generating client mod download page..."

        local MOD_CARDS="" desc fsize modrinth_url btn
        for key in "${CMOD_ORDER[@]}"; do
            name="${CMODS[$key]}"
            desc="${CMOD_DESC[$key]}"
            fname="${CMOD_FILENAME[$key]}"
            fsize="${CMOD_FILESIZE[$key]}"
            modrinth_url="${CMOD_URL[$key]}"

            if [ -n "$fname" ]; then
                btn="<a class=\"btn\" href=\"/files/${fname}\" download>⬇ Download ${fsize}</a>"
            else
                btn="<a class=\"btn btn-ext\" href=\"${modrinth_url}\" target=\"_blank\">↗ Get from ${modrinth_url##*/}</a>"
            fi

            MOD_CARDS="${MOD_CARDS}
        <div class=\"card\">
            <div class=\"card-name\">${name}</div>
            <div class=\"card-desc\">${desc}</div>
            ${btn}
        </div>"
        done

        log_info "Creating all-mods ZIP..."
        ( cd "$CLIENT_MODS_DIR/files" && \
          zip -q "../all-client-mods-mc${MC_VERSION}.zip" *.jar 2>/dev/null ) \
            && log_success "Created all-client-mods-mc${MC_VERSION}.zip" \
            || log_warning "zip not found or no jars — skipping bundle (install zip: sudo apt install zip)"

        local ZIP_SIZE ZIP_BTN=""
        ZIP_SIZE=$(du -h "$CLIENT_MODS_DIR/all-client-mods-mc${MC_VERSION}.zip" 2>/dev/null | cut -f1 || echo "")
        if [ -n "$ZIP_SIZE" ]; then
            ZIP_BTN="<a class=\"btn btn-all\" href=\"/all-client-mods-mc${MC_VERSION}.zip\" download>⬇ Download All Mods (${ZIP_SIZE} ZIP)</a>"
        fi

        cat > "$CLIENT_MODS_DIR/index.html" << HTMLEOF
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${SERVER_NAME} — Client Mods</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: 'Segoe UI', sans-serif;
            background: #1a1a2e;
            color: #e0e0e0;
            min-height: 100vh;
        }
        header {
            background: #16213e;
            padding: 2rem;
            text-align: center;
            border-bottom: 3px solid #4ecca3;
        }
        header h1 { font-size: 2rem; color: #4ecca3; margin-bottom: 0.5rem; }
        header p  { opacity: 0.8; }
        .hero {
            text-align: center;
            padding: 2rem;
            background: #0f3460;
        }
        .hero p { margin-bottom: 1rem; opacity: 0.9; }
        .btn-all {
            display: inline-block;
            background: #4ecca3;
            color: #1a1a2e;
            font-weight: bold;
            padding: 0.9rem 2rem;
            border-radius: 8px;
            text-decoration: none;
            font-size: 1.1rem;
            transition: opacity 0.2s;
        }
        .btn-all:hover { opacity: 0.85; }
        .instructions {
            max-width: 800px;
            margin: 2rem auto;
            padding: 1.5rem 2rem;
            background: #16213e;
            border-radius: 12px;
            border-left: 4px solid #4ecca3;
        }
        .instructions h2 { color: #4ecca3; margin-bottom: 1rem; }
        .instructions ol  { padding-left: 1.5rem; line-height: 2; }
        .instructions code {
            background: #0f3460;
            padding: 2px 6px;
            border-radius: 4px;
            font-size: 0.9em;
        }
        .grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
            gap: 1.5rem;
            max-width: 1200px;
            margin: 2rem auto;
            padding: 0 1.5rem 3rem;
        }
        .card {
            background: #16213e;
            border-radius: 12px;
            padding: 1.5rem;
            display: flex;
            flex-direction: column;
            gap: 0.75rem;
            border: 1px solid #0f3460;
            transition: transform 0.2s, border-color 0.2s;
        }
        .card:hover { transform: translateY(-4px); border-color: #4ecca3; }
        .card-name { font-size: 1.1rem; font-weight: bold; color: #4ecca3; }
        .card-desc { font-size: 0.9rem; opacity: 0.8; flex: 1; }
        .btn {
            display: inline-block;
            background: #4ecca3;
            color: #1a1a2e;
            font-weight: bold;
            padding: 0.5rem 1rem;
            border-radius: 6px;
            text-decoration: none;
            text-align: center;
            font-size: 0.9rem;
            transition: opacity 0.2s;
        }
        .btn:hover { opacity: 0.85; }
        .btn-ext {
            background: #0f3460;
            color: #4ecca3;
            border: 1px solid #4ecca3;
        }
        footer {
            text-align: center;
            padding: 1.5rem;
            opacity: 0.5;
            font-size: 0.85rem;
            border-top: 1px solid #16213e;
        }
    </style>
</head>
<body>
    <header>
        <h1>⛏ ${SERVER_NAME}</h1>
        <p>Client Mods — Minecraft ${MC_VERSION} · Fabric</p>
    </header>

    <div class="hero">
        <p>Download all recommended mods in one click, or pick individually below.</p>
        ${ZIP_BTN}
    </div>

    <div class="instructions">
        <h2>📋 Install Instructions</h2>
        <ol>
            <li>Install <a href="https://fabricmc.net/use/" target="_blank" style="color:#4ecca3">Fabric Loader</a> for Minecraft ${MC_VERSION}</li>
            <li>Download <strong>Fabric API</strong> below (required by all mods)</li>
            <li>Download whichever other mods you want</li>
            <li>Place all <code>.jar</code> files into your <code>.minecraft/mods/</code> folder</li>
            <li>Launch Minecraft with the Fabric profile</li>
        </ol>
    </div>

    <div class="grid">
        ${MOD_CARDS}
    </div>

    <footer>Generated by ubuntu-post-install setup · ${SERVER_NAME}</footer>
</body>
</html>
HTMLEOF
        log_success "Download page created at ${CM_NAME}/index.html"

        mkdir -p "$CLIENT_MODS_DIR/nginx"
        cat > "$CLIENT_MODS_DIR/nginx/nginx.conf" << 'NGINXEOF'
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Mod JARs and ZIPs — long cache, force download
    location /files/ {
        add_header Content-Disposition "attachment";
        add_header Cache-Control "public, max-age=86400";
    }
    location ~* \.zip$ {
        add_header Content-Disposition "attachment";
        add_header Cache-Control "public, max-age=86400";
    }

    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache";
    }

    gzip on;
    gzip_types text/html text/css application/javascript;
}
NGINXEOF

        cat > "$CLIENT_MODS_DIR/Dockerfile" << 'CLIENTDOCKEREOF'
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
COPY files/ /usr/share/nginx/html/files/
COPY all-client-mods-*.zip /usr/share/nginx/html/
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CLIENTDOCKEREOF

        # Standalone compose for the client-mods page (its own folder).
        cat > "$CLIENT_MODS_DIR/docker-compose.yml" << CMCOMPOSEEOF
name: ${CM_NAME}

services:
  ${CM_NAME}:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: ${CM_NAME}
    ports:
      - "${MODS_PORT}:80"
    restart: unless-stopped
CMCOMPOSEEOF
        log_success "Created ${CM_NAME}/docker-compose.yml"

        chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CLIENT_MODS_DIR" 2>/dev/null || true

        # Caddy snippet (the page is a normal HTTP service — Caddy can proxy it)
        if [ -n "$MODS_DOMAIN" ]; then
            local GAMING_SERVER_IP CADDY_IP=""
            GAMING_SERVER_IP=$(hostname -I | awk '{print $1}')
            prompt_text "Gaming server IP as seen from Caddy machine [$GAMING_SERVER_IP]:" "$GAMING_SERVER_IP" CADDY_IP
            echo ""
            log_info "Add to your Caddyfile on the Caddy machine:"
            echo ""
            echo "──────────────────────────────────────────────────"
            cat << CADDYEOF
${MODS_DOMAIN} {
    reverse_proxy ${CADDY_IP}:${MODS_PORT}
}
CADDYEOF
            echo "──────────────────────────────────────────────────"
            echo ""
            echo "Reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
            echo ""
        fi
    fi  # end client mod download page

    # ── Generate CLIENT_MODS.md (into the instance folder) ──────────────────────
    log_info "Generating CLIENT_MODS.md..."
    cat > "$MC_DIR/CLIENT_MODS.md" << 'CLIENTEOF'
# Client Mods for Players

These mods are installed on **your Minecraft client**, not the server.
All are optional but highly recommended for the best experience.

## Required loader
Install [Fabric Loader](https://fabricmc.net/use/) for your Minecraft version,
then install [Fabric API](https://modrinth.com/mod/fabric-api).

## Recommended client mods

| Mod | Download | Notes |
|-----|----------|-------|
| **Xaero's Minimap** | [Modrinth](https://modrinth.com/mod/xaeros-minimap) | Corner minimap with waypoints, entity radar |
| **Xaero's World Map** | [Modrinth](https://modrinth.com/mod/xaeros-world-map) | Fullscreen explored world map |
| **ReplayMod** | [Modrinth](https://modrinth.com/mod/replaymod) | Record and replay game sessions |
| **NoChatReports** | [Modrinth](https://modrinth.com/mod/no-chat-reports) | Disable chat reporting (also on server) |
| **Essential Mod** | [Modrinth](https://modrinth.com/mod/essential) | Invite friends, cosmetics, social features |
| **Sodium** | [Modrinth](https://modrinth.com/mod/sodium) | Major FPS improvement |
| **Iris Shaders** | [Modrinth](https://modrinth.com/mod/iris) | Shader support (works with Sodium) |
| **Indium** | [Modrinth](https://modrinth.com/mod/indium) | Sodium compatibility layer for other mods |

## Notes
- Xaero's Minimap and World Map are **client-only** — they work on any server
  automatically, nothing needed server-side.
- ReplayMod is **client-only** — the server runs ServerReplay to record
  server-side replays, but you view them with ReplayMod on your client.
- Essential Mod's world invite feature works peer-to-peer — both players need it.
- NoChatReports is optional on client when the server already has it, but
  installing it on both gives the strongest protection.
CLIENTEOF
    chown "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR/CLIENT_MODS.md" 2>/dev/null || true
    log_success "CLIENT_MODS.md written"

    # ── Generate MINECRAFT_NETWORKING.md (into the instance folder) ─────────────
    log_info "Generating MINECRAFT_NETWORKING.md..."

    local PORT_NOTE
    if [ "$MC_PORT" = "25565" ]; then
        PORT_NOTE="This server uses the **standard Minecraft port (25565)**."
    else
        PORT_NOTE="This server uses **port ${MC_PORT}** (non-standard)."
    fi

    cat > "$MC_DIR/MINECRAFT_NETWORKING.md" << NETEOF
# Minecraft Server Networking Guide

${PORT_NOTE}
$([ -n "$MC_DOMAIN" ] && echo "Intended domain: **${MC_DOMAIN}**")

---

## How Minecraft server addressing works

Minecraft Java Edition connects to servers using a **host:port** pair.
The default port is **25565**. If you use the default, players just type
your domain and the game connects automatically. If you use any other port,
players would normally have to type \`domain.com:PORT\` — unless you use a
**DNS SRV record**, which hides the port completely. Players always just type
your domain regardless of which port is actually in use.

This means:
- **One server, default port 25565** → simple A record, no SRV needed
- **One server, non-default port** → A record + SRV record
- **Multiple servers, same IP** → each gets its own port and its own SRV record
  Players type different subdomains, never see port numbers

---

## Step 1 — Choose your ports

Each Minecraft server needs its own unique port. Plan these before touching DNS.

| Server | Subdomain players type | Port to use | Notes |
|--------|----------------------|-------------|-------|
| First server | \`mc.yourdomain.com\` | 25565 | Standard port, simplest |
| Second server | \`survival.yourdomain.com\` | 25566 | Non-standard, needs SRV |
| Third server | \`creative.yourdomain.com\` | 25567 | Non-standard, needs SRV |

Your server is configured on port **${MC_PORT}**.

Ports 25565–25570 are the conventional range for multiple Minecraft servers.
Any port from 1024–65535 works as long as nothing else on your server uses it.

Check what's already in use on your server:
\`\`\`bash
sudo ss -tlnp | grep 255
\`\`\`

---

## Step 2 — Port forward on your router

You need one port forward rule per Minecraft server.

1. Find your server's **local IP**:
   \`\`\`bash
   hostname -I | awk '{print \$1}'
   \`\`\`
2. Log into your router — usually **http://192.168.1.1** or **http://192.168.0.1**
   (check the label on your router if unsure)
3. Find **Port Forwarding** — sometimes listed under NAT, Firewall, Virtual Servers,
   or Advanced depending on your router brand
4. Add a rule for each Minecraft server:

   | Field | Value |
   |-------|-------|
   | External port | ${MC_PORT} |
   | Internal IP | your server's local IP |
   | Internal port | ${MC_PORT} |
   | Protocol | TCP |

   For a second server on port 25566, add another rule with 25566 in both port fields.

5. Save and apply. No reboot needed on most routers.

### Common router brands — where to find port forwarding

| Router brand | Path |
|-------------|------|
| Netgear | Advanced → Advanced Setup → Port Forwarding |
| ASUS | WAN → Virtual Server / Port Forwarding |
| TP-Link | Advanced → NAT Forwarding → Port Forwarding |
| Linksys | Security → Apps and Gaming → Port Range Forwarding |
| Eero | (app only) Settings → Network Settings → Reservations & Port Forwarding |
| Google/Nest Wifi | (app only) Settings → Network & General → Advanced Networking → Port Management |
| ISP-provided router | Usually under Firewall or Advanced — check your ISP's support pages |

---

## Step 3 — Point your domain at your server

First find your **public IP**:
\`\`\`bash
curl -s https://api.ipify.org
\`\`\`

⚠️ **Dynamic IP warning:** Most home internet connections change IP occasionally.
If yours does, set up free DDNS (DuckDNS at duckdns.org or No-IP at noip.com)
and use their subdomain as your A record target instead of a raw IP.

### DNS records to add

#### Scenario A — One server on the standard port (25565)

Just an A record. No SRV needed. Players type \`mc.yourdomain.com\` and it works.

\`\`\`
Type:  A
Name:  mc
Value: your.public.ip
TTL:   1 hour (3600)
\`\`\`

Players connect to: \`mc.yourdomain.com\`

---

#### Scenario B — One server on a non-standard port (e.g. ${MC_PORT})

You need both an A record and a SRV record.
Without the SRV record, players would have to type \`mc.yourdomain.com:${MC_PORT}\`.
With the SRV record, they just type \`mc.yourdomain.com\` — the game resolves the port.

\`\`\`
# A record — points the hostname at your IP
Type:  A
Name:  mc
Value: your.public.ip
TTL:   1 hour

# SRV record — tells Minecraft clients which port to use
Type:     SRV
Name:     _minecraft._tcp.mc        (some providers want the full name:
                                     _minecraft._tcp.mc.yourdomain.com)
Priority: 0
Weight:   5
Port:     ${MC_PORT}
Target:   mc.yourdomain.com
TTL:      1 hour
\`\`\`

Players connect to: \`mc.yourdomain.com\`

---

#### Scenario C — Multiple servers on the same IP (most common setup)

One A record pointing to your server, then one SRV record per server.
Each SRV record maps a player-friendly subdomain to a specific port.

\`\`\`
# One A record for the host — all SRV records point here
Type:  A
Name:  mc
Value: your.public.ip
TTL:   1 hour

# Server 1 — survival on port 25565
Type:     SRV
Name:     _minecraft._tcp.survival
Priority: 0
Weight:   5
Port:     25565
Target:   mc.yourdomain.com
TTL:      1 hour

# Server 2 — creative on port 25566
Type:     SRV
Name:     _minecraft._tcp.creative
Priority: 0
Weight:   5
Port:     25566
Target:   mc.yourdomain.com
TTL:      1 hour

# Server 3 — minigames on port 25567
Type:     SRV
Name:     _minecraft._tcp.minigames
Priority: 0
Weight:   5
Port:     25567
Target:   mc.yourdomain.com
TTL:      1 hour
\`\`\`

Players connect to:
- \`survival.yourdomain.com\` → hits port 25565
- \`creative.yourdomain.com\` → hits port 25566
- \`minigames.yourdomain.com\` → hits port 25567

Nobody types a port number. Ever.

---

## Step 4 — Add records at your DNS provider

### GoDaddy

1. Log in → **My Products** → find your domain → click **DNS**
2. Click **Add New Record**

**A record:**
- Type: A
- Name: mc
- Value: your.public.ip
- TTL: 1 hour
- Click Save

**SRV record** (if needed):
- Type: SRV
- Name: \`_minecraft._tcp.mc\` (or whichever subdomain)
- Priority: 0
- Weight: 5
- Port: ${MC_PORT}
- Target: \`mc.yourdomain.com\`
- TTL: 1 hour
- Click Save

Changes propagate in minutes to a few hours.
Official SRV docs: https://uk.godaddy.com/help/add-an-srv-record-19234

---

### Namecheap

Namecheap splits the SRV record across fields in a non-obvious way.
For a subdomain like \`mc11111.yourdomain.com\`, the fields must be:

| Field | Value | |
|-------|-------|-|
| Service | \`_minecraft\` | Always this exact value |
| Protocol | \`_tcp.mc11111\` | ← **the subdomain goes here** |
| Priority | \`0\` | |
| Weight | \`5\` | |
| Port | \`${MC_PORT}\` | |
| Target | \`mc11111.yourdomain.com.\` | Trailing dot optional |
| TTL | Automatic | |

> ⚠️ **This is Namecheap-specific.** Every other registrar puts the subdomain in the
> Name/Host field. Namecheap is the exception — append the subdomain to the **Protocol**
> field instead. Using any other field makes the record look valid but return NXDOMAIN.

**Steps:**

1. Log in → **Domain List** → **Manage** → **Advanced DNS** → **Add New Record**

2. **A record** (always needed):

   | Field | Value |
   |-------|-------|
   | Type | A Record |
   | Host | \`mc11111\` (your chosen subdomain) |
   | Value | your.public.ip |
   | TTL | Automatic |

   Click the ✓ checkmark to save.

3. **SRV record** (needed when port is not 25565):

   Fill in the fields from the main table above, substituting your actual subdomain
   for \`mc11111\` and your port number for \`${MC_PORT}\`.

   Click **Save All Changes**, then wait ~30 minutes for propagation.

**Verify:**
\`\`\`bash
nslookup -type=SRV _minecraft._tcp.mc11111.yourdomain.com
\`\`\`

If it returns NXDOMAIN, recheck the Protocol field — it must read \`_tcp.mc11111\`, not just \`_tcp\`.

**For additional servers** (e.g. \`mc22222\` on a different port):
- A record Host: \`mc22222\`
- SRV Protocol: \`_tcp.mc22222\`
- Each server gets its own unique subdomain and port

Official docs: https://www.namecheap.com/support/knowledgebase/article.aspx/9776/2237/how-to-create-a-srv-record/

---

### Cloudflare

1. Log in → **dash.cloudflare.com** → select your domain → **DNS** → **Records**
2. Click **Add record**

**A record:**
- Type: A
- Name: mc
- IPv4 address: your.public.ip
- Proxy status: **DNS only (grey cloud)** ← critical
- TTL: Auto
- Click Save

⚠️ **Cloudflare proxy (orange cloud) does NOT work for Minecraft.**
Minecraft uses raw TCP on port 25565, not HTTP. The orange cloud only proxies
HTTP/HTTPS traffic. Always use the grey cloud (DNS only) for Minecraft records.

**SRV record** (if needed):
- Type: SRV
- Name: \`_minecraft._tcp.mc\`
- Priority: 0
- Weight: 5
- Port: ${MC_PORT}
- Target: \`mc.yourdomain.com\`
- TTL: Auto
- Click Save

Official docs: https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/

---

## Step 5 — Open the port on your server firewall

The router forwards the traffic, but your server's own firewall also needs to allow it.

\`\`\`bash
# Allow your Minecraft port
sudo ufw allow ${MC_PORT}/tcp comment "Minecraft"

# If running multiple servers, add each port
sudo ufw allow 25566/tcp comment "Minecraft server 2"
sudo ufw allow 25567/tcp comment "Minecraft server 3"

sudo ufw reload
sudo ufw status
\`\`\`

---

## Step 6 — Verify everything is working

\`\`\`bash
# Check your A record resolved
dig mc.yourdomain.com A +short

# Check your SRV record (if you added one)
dig _minecraft._tcp.mc.yourdomain.com SRV

# Test raw TCP connectivity to your port
nc -zv mc.yourdomain.com ${MC_PORT}

# Expected output from nc:
# Connection to mc.yourdomain.com ${MC_PORT} port [tcp/*] succeeded!
\`\`\`

If \`dig\` returns your IP but \`nc\` fails, the problem is port forwarding or firewall.
If \`dig\` returns nothing, the problem is the DNS record.
If both work but Minecraft can't connect, check the server is actually running:
\`\`\`bash
cd ${MC_DIR} && docker compose ps
docker logs ${MC_NAME}
\`\`\`

---

## Fallback — playit.gg (if you cannot port forward)

Some ISPs use CGNAT (you share a public IP with many customers) which makes
port forwarding impossible. If \`curl -s https://api.ipify.org\` returns a
different IP than your router's WAN IP, you are behind CGNAT.

In that case, use playit.gg as a free tunnel:

1. Sign up at **https://playit.gg**
2. Create a tunnel → Minecraft Java → set local port to ${MC_PORT}
3. Copy the secret key from your dashboard
4. Add to \`${MC_NAME}/.env.playit\`:
   \`\`\`
   PLAYIT_SECRET=your_secret_key_here
   \`\`\`
5. Restart: \`cd ${MC_DIR} && docker compose up -d\`
6. Players connect to the address shown in your playit.gg dashboard

To use your own domain with playit.gg, add a CNAME record pointing
\`mc.yourdomain.com\` to your playit.gg tunnel address.

Note: playit.gg routes all player traffic through their servers.
See their privacy policy at https://playit.gg/privacy-policy/ before using.

---

## Player Management

### Whitelist

If you enabled the whitelist during setup, only players you explicitly add can join.

**Add a player** (server must be running):
\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console "whitelist add PlayerName"
\`\`\`

**View the whitelist:**
\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console "whitelist list"
\`\`\`

**Remove a player:**
\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console "whitelist remove PlayerName"
\`\`\`

Minecraft resolves the UUID from the username automatically. The whitelist is saved
to \`${MC_NAME}/data/whitelist.json\` and persists across container restarts.

### Get a player's UUID from their username

Only needed if you want to pre-populate \`whitelist.json\` before the server first starts:

\`\`\`bash
curl -s "https://api.mojang.com/users/profiles/minecraft/PLAYERNAME" | \\
  python3 -c "
import sys, json
d = json.load(sys.stdin)
uid = d['id']
print(f'{uid[:8]}-{uid[8:12]}-{uid[12:16]}-{uid[16:20]}-{uid[20:]}')
"
\`\`\`

Example output: \`69a79aa5-a4ef-4a5c-a61f-7ab0e52cdb6a\`

Then edit \`${MC_NAME}/data/whitelist.json\` (create it if missing):
\`\`\`json
[
  {
    "uuid": "69a79aa5-a4ef-4a5c-a61f-7ab0e52cdb6a",
    "name": "PlayerName"
  }
]
\`\`\`

Add one object per player. Start the server after saving — it reads the file on startup.

### Make a player an operator (admin)

\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console "op PlayerName"
\`\`\`

Operators can use all game commands, kick/ban players, and edit server settings in-game.
Remove operator status with \`/deop PlayerName\` in-game or:
\`\`\`bash
docker exec ${MC_NAME} mc-send-to-console "deop PlayerName"
\`\`\`

NETEOF
    chown "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR/MINECRAFT_NETWORKING.md" 2>/dev/null || true
    log_success "MINECRAFT_NETWORKING.md written"

    # Make sure everything under the instance folder is owned correctly, but
    # keep data/config owned by uid 1000 (itzg runs as that user).
    chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true
    chown -R 1000:1000 "$MC_DIR/data" "$MC_DIR/config" 2>/dev/null || true

    # ── Summary ─────────────────────────────────────────────────────────────────
    echo ""
    echo "═══════════════════════════════════════════════════════"
    echo "          Minecraft Setup Complete"
    echo "═══════════════════════════════════════════════════════"
    echo ""
    echo "  Instance:    $MC_NAME ($MC_DIR)"
    echo "  Flavour:     $FLAVOUR_NAME $MC_VERSION"
    echo "  Port:        $MC_PORT"
    echo "  Memory:      ${MC_RAM}GB"
    [ -n "$MC_DOMAIN" ] && echo "  Domain:      $MC_DOMAIN"
    if [ "$USE_PLAYIT" = true ]; then
        echo "  Networking:  playit.gg tunnel"
        echo "               → Add your secret to ${MC_NAME}/.env.playit"
    elif [ "$USE_PORTFORWARD" = true ]; then
        echo "  Networking:  Direct port forward"
        echo "               → See ${MC_NAME}/MINECRAFT_NETWORKING.md"
    else
        echo "  Networking:  Local only"
    fi
    echo ""
    echo "  Mods:        ${#SELECTED_MODS[@]} selected"
    if [ "$USE_VT_ZIPS" = true ]; then
        echo "  Datapacks:   Auto-installed from pre-packaged ZIPs in extras/datapacks/"
        [ -n "$VT_DP_VER" ] && echo "               Datapacks v${VT_DP_VER}  ($(basename "$VT_DATAPACK_ZIP"))"
        [ -n "$VT_CT_VER" ] && echo "               Crafting tweaks v${VT_CT_VER}  ($(basename "$VT_CRAFTING_ZIP"))"
    elif [ ${#SELECTED_DATAPACKS[@]} -gt 0 ]; then
        echo "  Datapacks:   Download from vanillatweaks.net/picker/datapacks/ → MC $MC_VERSION"
        echo "               Place .zip in ${MC_NAME}/datapacks-download/ then rebuild"
    else
        echo "  Datapacks:   none"
    fi
    if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
        echo "  Pre-gen:     ${PREGEN_RADIUS} block radius (~$(( PREGEN_RADIUS / 1000 * 8 ))GB per server)"
        echo "  World border: ${BORDER_SIZE} blocks $([ "$USE_BORDER" = true ] && echo "(enabled)" || echo "(disabled)")"
    fi
    echo ""
    echo "Files written under ${MC_DIR}:"
    echo "  data/ mods-download/ datapacks-download/   Server files and downloaded mods"
    echo "  docker-compose.yml                         Standalone compose (build itzg image)"
    echo "  CLIENT_MODS.md                             What players install on their client"
    echo "  MINECRAFT_NETWORKING.md                    DNS and port forward instructions"
    [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]] && \
        echo "  PREGEN_INSTRUCTIONS.md                     How to monitor and re-run pre-gen"
    [ "$USE_PLAYIT" = true ] && echo "  .env.playit                                Add playit.gg secret here"
    echo ""
    echo "── BACKUPS ───────────────────────────────────────────"
    echo ""
    echo "  Protect your world: set up automatic backups of ${MC_NAME}/data with Kopia."
    echo "  Run:  sudo ./setup.sh backup"
    echo ""

    write_readme "$MC_DIR" << MD
# Minecraft — ${MC_NAME}

## Manage
\`\`\`bash
cd $MC_DIR
docker compose up -d --build   # start (builds image on first run)
docker compose down            # stop
docker compose logs -f         # logs
docker compose pull && docker compose up -d --build  # update
\`\`\`

## Backups
World data lives in \`./data\` — covered by Kopia/Borg if installed.
\`\`\`bash
sudo ./setup.sh backup
\`\`\`
MD

    # ── Optional: start server and run pre-gen now ──────────────────────────────
    local START_MC=""
    prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC
    if [[ ${START_MC:-y} =~ ^[Yy]$ ]]; then
        log_info "Building and starting ${MC_NAME}..."
        if ( cd "$MC_DIR" && docker compose up -d --build ); then
            log_success "${MC_NAME} started"
        else
            log_warning "Failed to build/start ${MC_NAME} — check: cd ${MC_DIR} && docker compose logs"
        fi

        if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
            log_info "Waiting for ${MC_NAME} container to be running..."
            local _WAIT=0
            until docker ps --filter "name=^${MC_NAME}$" --filter "status=running" \
                  --format "{{.Names}}" 2>/dev/null | grep -q "^${MC_NAME}$"; do
                sleep 3
                _WAIT=$((_WAIT + 3))
                if [ $_WAIT -ge 60 ]; then
                    log_warning "Container not yet showing as running — attempting pregen anyway"
                    break
                fi
            done

            echo ""
            log_info "Running chunk pre-generation..."
            log_info "(Waiting for server to finish loading — usually 1-2 minutes...)"
            docker exec -e PREGEN=1 -u 1000 "${MC_NAME}" bash /data/pregen-startup.sh
            echo ""
            log_success "Pre-generation started!"
            echo "  Monitor: docker exec ${MC_NAME} mc-send-to-console 'chunky progress'"
            echo "  Logs:    docker logs -f ${MC_NAME}"
        fi
    else
        echo ""
        log_info "When ready:"
        echo "  cd ${MC_DIR} && docker compose up -d --build"
        if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then
            echo "  docker exec -e PREGEN=1 -u 1000 ${MC_NAME} bash /data/pregen-startup.sh"
        fi
    fi
    echo ""
}

# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_minecraft
