Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ __pycache__/
*.sock
docker-compose.override.yml
docker-compose.yml
!examples/**/docker-compose.yml
.env

# ----------------------
Expand Down Expand Up @@ -105,4 +106,11 @@ out/
# Misc
# ----------------------
*.log
*.cache
*.cache

# ----------------------
# Local development
# Used for test mapping and running the server locally
# ----------------------
Makefile
data
38 changes: 38 additions & 0 deletions docs/technical/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,44 @@ The Hytale server container is highly configurable through environment variables

---

## 📥 Hytale Downloader Options

| Variable | Description | Default |
|-------------------------------|---------------------------------------------------------------------------------------------------------|------------|
| `HYTALE_PATCHLINE` | Patchline to download from: `release` or `prerelease` | `release` |
## 📦 CurseForge Mod Downloader

Automatically download and manage mods from CurseForge.

The downloader maintains a manifest to track installed mods and automatically removes mods that are no longer in your list.

| Variable | Description | Default |
|-------------------------------|---------------------------------------------------------------------------------------------------------|------------|
| `CURSEFORGE_MOD_IDS` | Comma-separated list of CurseForge mod project IDs (e.g., `12345,67890`) | `(Empty)` |
| `HYTALE_MOD_DIR` | Directory where mods are downloaded | `./mods` |

### Usage Example

```yaml
environment:
CURSEFORGE_MOD_IDS: "12345,67890,11111"
```

### How It Works

1. On startup, the downloader fetches mod info from cflookup.com
2. Downloads the latest version from forgecdn.net if not already present
3. Removes mods that were previously downloaded but are no longer in `CURSEFORGE_MOD_IDS`
4. Maintains a manifest file (`.curseforge_manifest.json`) in the mods directory

### Finding Mod IDs

The mod ID can be found in the CurseForge URL. For example:
- URL: `https://www.curseforge.com/hytale/mods/example-mod/12345`
- Mod ID: `12345`

---

## 🎮 Hytale Server Options

Options are listed in the same order as they appear in `java -jar HytaleServer.jar --help`.
Expand Down
6 changes: 6 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export GID="${GID:-1000}"
export NO_COLOR="${NO_COLOR:-FALSE}"

# --- Hytale specific environment variables ---
export HYTALE_PATCHLINE="${HYTALE_PATCHLINE:-release}"
export HYTALE_HELP="${HYTALE_HELP:-FA}"
export HYTALE_CACHE="${HYTALE_CACHE:-FALSE}"
export HYTALE_CACHE_DIR="${HYTALE_CACHE_DIR:-$GAME_DIR/Server/HytaleServer.aot}"
Expand Down Expand Up @@ -65,6 +66,10 @@ export HYTALE_VERSION="${HYTALE_VERSION:-FALSE}"
export HYTALE_WORLD_GEN="${HYTALE_WORLD_GEN:-}"
export RUN_AUTO_AUTH="${RUN_AUTO_AUTH:-TRUE}"

# --- CurseForge Mod Downloader (no API key required!) ---
export CURSEFORGE_MOD_IDS="${CURSEFORGE_MOD_IDS:-}"
export HYTALE_MOD_DIR="${HYTALE_MOD_DIR:-$BASE_DIR/mods}"

# Load utilities
. "$SCRIPTS_PATH/utils.sh"

Expand All @@ -81,6 +86,7 @@ fi
# --- 1. Initialization ---
# CRITICAL ORDER: Downloader must run BEFORE config management. The audit suite must run AFTER this step.
sh "$SCRIPTS_PATH/hytale/hytale_downloader.sh"
sh "$SCRIPTS_PATH/hytale/curseforge_mods.sh"
sh "$SCRIPTS_PATH/hytale/hytale_config.sh"
. "$SCRIPTS_PATH/hytale/hytale_options.sh"

Expand Down
13 changes: 13 additions & 0 deletions examples/curseforge-mods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# CurseForge Mods Example

Auto-download mods from CurseForge on server startup.

## Quick Start

1. Find mod IDs from [curseforge.com/hytale](https://www.curseforge.com/hytale)
2. Edit `docker-compose.yml` with your mod IDs
3. Run: `docker-compose up`

## Finding Mod IDs

Mod ID is in the URL: `https://www.curseforge.com/hytale/mods/cool-mod/12345` → ID is `12345`
31 changes: 31 additions & 0 deletions examples/curseforge-mods/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Hytale Server with CurseForge Mod Support
#
# No API key required! Just add mod IDs and go.
#
# Setup:
# 1. Find mod IDs from https://www.curseforge.com/hytale
# 2. Add them to CURSEFORGE_MOD_IDS below
# 3. Run: docker-compose up

services:
hytale:
image: deinfreu/hytale-server:experimental
container_name: hytale-server-mods
environment:
SERVER_IP: "0.0.0.0"
SERVER_PORT: "5520"
DEBUG: "TRUE"
TZ: "UTC"

# CurseForge mod IDs (comma-separated)
# Find IDs in the URL: curseforge.com/hytale/mods/mod-name/12345
CURSEFORGE_MOD_IDS: "12345,67890"

restart: unless-stopped
ports:
- "5520:5520/udp"
volumes:
- ./data:/home/container
- /etc/machine-id:/etc/machine-id:ro
tty: true
stdin_open: true
230 changes: 230 additions & 0 deletions scripts/hytale/curseforge_mods.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/bin/sh
set -eu

# Load dependencies
. "$SCRIPTS_PATH/utils.sh"

# CurseForge Mod Downloader
# Uses cflookup.com to get mod info and forgecdn.net for downloads
# Maintains a manifest to track downloads and clean up removed mods

CFLOOKUP_URL="https://cflookup.com"
FORGECDN_URL="https://mediafilez.forgecdn.net/files"
MOD_DIR="${HYTALE_MOD_DIR:-$GAME_DIR/mods}"
MANIFEST_FILE="$MOD_DIR/.curseforge_manifest.json"

# --- Manifest Functions ---

init_manifest() {
mkdir -p "$MOD_DIR"
[ -f "$MANIFEST_FILE" ] || echo '{"mods":{}}' > "$MANIFEST_FILE"
}

get_from_manifest() {
jq -r ".mods[\"$1\"].$2 // empty" "$MANIFEST_FILE" 2>/dev/null
}

update_manifest() {
local mod_id="$1" file_id="$2" file_name="$3" mod_name="$4"
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

jq --arg mid "$mod_id" \
--arg fid "$file_id" \
--arg fn "$file_name" \
--arg mn "$mod_name" \
--arg ts "$timestamp" \
'.mods[$mid] = {modId: ($mid | tonumber), modName: $mn, fileName: $fn, fileId: $fid, downloadedAt: $ts}' \
"$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" && mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
}

remove_from_manifest() {
jq --arg mid "$1" 'del(.mods[$mid])' "$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" && \
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
}

# --- Utility Functions ---

# Convert file ID to forgecdn path format
# e.g., 7453942 -> 7453/942, 7453042 -> 7453/42
format_file_id() {
local file_id="$1"
local first_part="${file_id%???}"
local second_part="${file_id#"$first_part"}"
# Remove leading zeros from second part
second_part=$(printf '%s' "$second_part" | sed 's/^0*//')
[ -z "$second_part" ] && second_part="0"
printf '%s/%s' "$first_part" "$second_part"
}

# --- HTTP/Parsing Functions ---

fetch_page() {
curl -fsSL --connect-timeout 10 --max-time 30 "$1" 2>/dev/null
}

# Parse mod name from cflookup HTML
parse_mod_name() {
# Look for: <a class="text-white" href="...">ModName</a>
printf '%s' "$1" | sed -n 's/.*class="text-white"[^>]*>[[:space:]]*\([^<]*\)<.*/\1/p' | head -1 | tr -d '\n\r'
}

# Parse latest file info from cflookup HTML
# Returns: filename|fileid
parse_latest_file() {
local html="$1"
local filename fileid

# Extract jar filename
filename=$(printf '%s' "$html" | sed -n 's/.*<td>\([^<]*\.jar\)<\/td>.*/\1/p' | head -1 | tr -d ' \n\r')

# Extract file ID from install button link
fileid=$(printf '%s' "$html" | sed -n 's/.*fileId=\([0-9]*\).*/\1/p' | head -1)

[ -n "$filename" ] && [ -n "$fileid" ] && printf '%s|%s' "$filename" "$fileid"
}

download_file() {
local url="$1" dest="$2"

if curl -fsSL --connect-timeout 10 --max-time 120 -o "$dest" "$url" 2>/dev/null; then
[ -s "$dest" ] && return 0
fi
rm -f "$dest" 2>/dev/null
return 1
}

# --- Main Logic ---

log_section "CurseForge Mod Downloader"

# Check if enabled
if [ -z "${CURSEFORGE_MOD_IDS:-}" ]; then
# If manifest exists, clean up all managed mods
if [ -f "$MANIFEST_FILE" ]; then
log_step "Cleanup"
printf "${YELLOW}CURSEFORGE_MOD_IDS empty, removing managed mods${NC}\n"

manifest_mods=$(jq -r '.mods | keys[]' "$MANIFEST_FILE" 2>/dev/null) || true
for mod_id in $manifest_mods; do
[ -z "$mod_id" ] && continue
filename=$(jq -r ".mods[\"$mod_id\"].fileName // empty" "$MANIFEST_FILE" 2>/dev/null)
if [ -n "$filename" ] && [ -f "$MOD_DIR/$filename" ]; then
rm -f "$MOD_DIR/$filename"
printf " ${DIM}↳ Removed:${NC} ${YELLOW}%s${NC}\n" "$filename"
fi
done
rm -f "$MANIFEST_FILE"
printf " ${DIM}↳ Manifest cleared${NC}\n"
else
log_step "CurseForge Mods"
printf "${DIM}not configured (set CURSEFORGE_MOD_IDS)${NC}\n"
fi
exit 0
fi

init_manifest

# Parse mod IDs (comma or space separated)
MOD_ID_LIST=$(printf '%s' "$CURSEFORGE_MOD_IDS" | tr ',' ' ')

# Counters
CURRENT_MODS=""
DOWNLOAD_COUNT=0
SKIP_COUNT=0
ERROR_COUNT=0

log_step "Processing Mods"
printf "\n"

for mod_id in $MOD_ID_LIST; do
# Trim whitespace
mod_id=$(printf '%s' "$mod_id" | tr -d ' \t\n\r')
[ -z "$mod_id" ] && continue

CURRENT_MODS="$CURRENT_MODS $mod_id"
printf " ${DIM}↳ Mod ID:${NC} %s " "$mod_id"

# Fetch mod page
html=$(fetch_page "$CFLOOKUP_URL/$mod_id") || {
printf "${RED}lookup failed${NC}\n"
ERROR_COUNT=$((ERROR_COUNT + 1))
continue
}

# Parse mod info
mod_name=$(parse_mod_name "$html")
[ -z "$mod_name" ] && mod_name="Mod-$mod_id"

file_info=$(parse_latest_file "$html")
if [ -z "$file_info" ]; then
printf "${YELLOW}no files found${NC}\n"
ERROR_COUNT=$((ERROR_COUNT + 1))
continue
fi

file_name="${file_info%%|*}"
file_id="${file_info##*|}"

# Check if already up-to-date
manifest_file_id=$(get_from_manifest "$mod_id" "fileId")
manifest_filename=$(get_from_manifest "$mod_id" "fileName")

if [ "$manifest_file_id" = "$file_id" ] && [ -f "$MOD_DIR/$file_name" ]; then
printf "${DIM}%s${NC} ${GREEN}(up-to-date)${NC}\n" "$mod_name"
SKIP_COUNT=$((SKIP_COUNT + 1))
continue
fi

# Remove old version if updating
[ -n "$manifest_filename" ] && [ -f "$MOD_DIR/$manifest_filename" ] && rm -f "$MOD_DIR/$manifest_filename"

# Build download URL and fetch
formatted_id=$(format_file_id "$file_id")
download_url="${FORGECDN_URL}/${formatted_id}/${file_name}"

printf "${CYAN}%s${NC} " "$mod_name"
if download_file "$download_url" "$MOD_DIR/$file_name"; then
update_manifest "$mod_id" "$file_id" "$file_name" "$mod_name"
printf "${GREEN}(downloaded)${NC}\n"
DOWNLOAD_COUNT=$((DOWNLOAD_COUNT + 1))
else
printf "${RED}(download failed)${NC}\n"
ERROR_COUNT=$((ERROR_COUNT + 1))
fi
done

# --- Cleanup orphaned mods ---

log_step "Cleanup"
REMOVED_COUNT=0

manifest_mods=$(jq -r '.mods | keys[]' "$MANIFEST_FILE" 2>/dev/null) || true

for manifest_mod_id in $manifest_mods; do
[ -z "$manifest_mod_id" ] && continue

# Check if mod is still in current list
case " $CURRENT_MODS " in
*" $manifest_mod_id "*) continue ;;
esac

# Remove orphaned mod
old_filename=$(get_from_manifest "$manifest_mod_id" "fileName")
if [ -n "$old_filename" ] && [ -f "$MOD_DIR/$old_filename" ]; then
rm -f "$MOD_DIR/$old_filename"
printf " ${DIM}↳ Removed:${NC} ${YELLOW}%s${NC}\n" "$old_filename"
REMOVED_COUNT=$((REMOVED_COUNT + 1))
fi
remove_from_manifest "$manifest_mod_id"
done

[ "$REMOVED_COUNT" -eq 0 ] && printf "${DIM}no orphaned mods${NC}\n"

# --- Summary ---

log_step "Summary"
printf "${GREEN}%d downloaded${NC}, ${DIM}%d up-to-date${NC}" "$DOWNLOAD_COUNT" "$SKIP_COUNT"
[ "$ERROR_COUNT" -gt 0 ] && printf ", ${RED}%d errors${NC}" "$ERROR_COUNT"
[ "$REMOVED_COUNT" -gt 0 ] && printf ", ${YELLOW}%d removed${NC}" "$REMOVED_COUNT"
printf "\n"
5 changes: 4 additions & 1 deletion scripts/hytale/hytale_downloader.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ elif [ ! -f "$SERVER_JAR_PATH" ]; then
log_warning "HytaleServer.jar not found." "Downloading fresh installation..."

log_step "Download Status"
hytale-downloader
if [ "${DEBUG:-FALSE}" = "TRUE" ]; then
printf " ${DIM}↳ Patchline:${NC} ${GREEN}%s${NC}\n" "$HYTALE_PATCHLINE"
fi
hytale-downloader --patchline "$HYTALE_PATCHLINE"

ZIP_FILE=$(ls "$BASE_DIR"/[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]*.zip 2>/dev/null | head -n 1)

Expand Down