Confirmed live: install_frigate()'s fresh-install path overwrote a working, hand-crafted docker-compose.yml (Frigate + mosquitto + frigate-notify) with zero backup, because that file's shape didn't match what frigate.sh's own "existing install" detection knew how to recognize. Every service's own detection is a judgment call about what counts as "already installed" and can miss a real setup built outside this repo's conventions. lib/common.sh gains backup_if_exists(FILE) — copies FILE to FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN). Applied before every service's own `cat > docker-compose.yml`/`cat > .env` write across all 60 services that do one (115 call sites), plus a matching standalone-mode stub added to every service's own bootstrap block, same convention already used for port_in_use/find_free_port. This doesn't replace a service's own update/fresh-reinstall detection — it's the safety net underneath it, so a wrong detection costs a .bak file to restore from instead of the original silently disappearing. Also fixes the actual gap that surfaced this: services/frigate.sh's Authelia offer only checked for Authelia installed locally on Frigate's own box, which is never true for a dedicated NVR box with no local Caddy either (the common shape — Caddy lives elsewhere, snippet-generation mode already handles that). Now offers Authelia protection unconditionally and, when Authelia isn't local, asks whether it lives on the same machine as Caddy (still "import authelia", since that's local to wherever Caddy ends up) or on a genuinely separate third machine (the explicit header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote Authelia" note, needed because a bare authelia:9091 shortcut only works one hop).
2560 lines
110 KiB
Bash
2560 lines
110 KiB
Bash
#!/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
|
||
|
||
backup_if_exists "$MC_DIR/docker-compose.yml"
|
||
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).
|
||
backup_if_exists "$CLIENT_MODS_DIR/docker-compose.yml"
|
||
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
|