Skip to content

Instantly share code, notes, and snippets.

@AfroThundr3007730
Last active December 18, 2024 06:38
Show Gist options
  • Save AfroThundr3007730/a3a01297705d65ed8c327539b5a55c46 to your computer and use it in GitHub Desktop.
Save AfroThundr3007730/a3a01297705d65ed8c327539b5a55c46 to your computer and use it in GitHub Desktop.
Generates a coordinate grid to move a player to generate a Minecraft map
#!/bin/bash
# Generates a coordinate grid to move a player and generate a Minecraft map
# Can run commands live via screen or RCON, or generate a command list
# SPDX-License-Identifier: GPL-3.0-or-later
# For issues or updated versions of this script, browse to the following URL:
# https://gist.github.com/AfroThundr3007730/a3a01297705d65ed8c327539b5a55c46
# Dependencies: screen python3(rcon) journalctl tail pkill
set -euo pipefail
shopt -s extdebug
mc_genmap.set_globals() {
# Identify ourselves
declare -gr \
AUTHOR=AfroThundr \
BASENAME=${0##*/} \
MODIFIED=20241217 \
VERSION=0.7.4
# Overridden via corresponding arguments
declare -g \
DIMENSION=overworld \
HEIGHT=160 \
INTERVAL=16 \
SLEEP=1
# Overridden via environment variables
declare -g \
HOST=${HOST:=localhost} \
PORT=${PORT:=25575} \
SNAME=${SNAME:=mc}
# Prevent string evaluation (SLEEP can be decimal)
declare -gi \
HEIGHT INTERVAL RESUME \
BOX_X1 BOX_X2 BOX_Z1 BOX_Z2 \
EBOX_X1 EBOX_X2 EBOX_Z1 EBOX_Z2 \
ORIGIN_X ORIGIN_Z
# All the rest should be present in this group
declare -g \
QUIET LOGFILE PLAYER \
COMMAND PASS PASSFILE \
OUTPUT MOVE TYPE START STOP \
WD_RUN WD_JOURNAL WD_LOGFILE
# Full program usage
declare -gr _mc_genmap_help_text="
Generates Minecraft map by moving player in a coordinate grid.
Can run the commands via local screen or remote RCON session.
Or alternatively, generates a teleport command list to be run.
Usage: $BASENAME -h | [-v] [-q] [-l <logfile>] -p <player>
-x1 <x_begin> -x2 <x_end> [-z1 <z_begin> -z2 <z_end>]
[-ex1 <ex_begin> -ex2 <ex_end> [-ez1 <ez_begin> -ez2 <ez_end>]]
[-ox <x_origin> -oz <z_origin>] [-d <dimension>]
[-y <height>] [-i <interval>] [-r <step>] [-s <seconds>]
[-o <output_file> | -m -t <type> [-b -c <command>] [-k]]
[-wd -wj <mc_unit> | -wl <mc_logpath>]
Options:
-v Prints version information
-h Display this help text
-q Suppress console output
-l Specify log file path
Core arguments:
-p Name of the player to be moved
-x1 X component of bounding box start coordinate
-x2 X component of bounding box end coordinate
-z1 Z component of bounding box start coordinate
If not specified, reuses -x1 argument
-z2 Z component of bounding box end coordinate
If not specified, reuses -x2 argument
Exclusion box arguments:
-ex1 Behaves like -x1 for exclusion box
-ex2 Behaves like -x2 for exclusion box
-ez1 Behaves like -z1 for exclusion box
If not specified, reuses -ex1 argument
-ez2 Behaves like -z2 for exclusion box
If not specified, reuses -ex2 argument
Origin arguments:
-ox X coordinate on which to center the box
If specified, all coordinates become relative
-oz Z coordinate on which to center the box
If specified, all coordinates become relative
Additional arguments:
-d Dimension ID to generate movement grid on
If not specified, defaults to 'overworld'
-y Y coordinate (altitude) of player
If not specified, defaults to 160
-i Interval in blocks to increment each step
If not specified, defualts to 16
-r Step on which to resume generation
Specify output step of a previous run
-s Seconds to sleep after each move
If not specified, defaults to 1
Command arguments:
-o Output file to write generated command list
If not specified and -m unspecified, uses STDOUT
-m Move player instead of printing coordinates
This requires launching a server and logging in
-t Type of command to execute (screen or rcon)
Must be present if -m is specified
-b Start the Minecraft server before beginning
Requires a command to launch server
-c Mincraft server start command to execute
Must be quoted, or use COMMAND instead
-k Stop Minecraft server after completion
Can be used in either command mode
Watchdog arguments:
-wd Enable watchdog to manage server load
Requires some form of access to server logs
-wj Use journalctl to follow a units logs
The next positional argument is a unit name
-wl Specify a log file to follow directly
The next positional argument is the log path
Config variables:
Used if type is 'rcon':
HOST Set the RCON server hostname
If not specified, defaults to 'localhost'
PORT Set the RCON server port
If not specified, defaults to 25575
PASS Set the RCON server password
Recommended to use PASSFILE instead
Used if type is 'screen':
COMMAND The MC server launch command
Can be used instead of -c
SNAME The screen server session name
If not specified, defaults to 'mc'
"
# Inline RCON client
declare -gr _mc_genmap_rcon='
from asyncio import run
from datetime import datetime, UTC
from os import environ
from signal import signal, SIGINT, SIGTERM
from sys import argv, exit
from rcon.exceptions import EmptyResponse
from rcon.source import rcon
def dprint(str, end=None):
print(datetime.now(UTC).strftime("%FT%TZ") +
": " + str, end=end, flush=True)
def end(*_):
dprint("RCON: Terminating.")
exit(0)
signal(SIGINT, end)
signal(SIGTERM, end)
try:
_ = run(rcon(command=argv[1],
host=environ.get("HOST"),
port=int(environ.get("PORT")),
passwd=environ.get("PASS")))
except EmptyResponse:
dprint("RCON: Command timeout.")
except:
dprint("RCON: Exception occurred.")
exit(1)
'
# Inline watchdog governor
declare -gr _mc_genmap_watchdog='
from datetime import datetime, UTC
from os import kill
from signal import signal, SIGCONT, SIGINT, SIGSTOP, SIGTERM
from sys import argv, exit, stdin
from time import sleep
def dprint(str, end=None):
print(datetime.now(UTC).strftime("%FT%TZ") +
": " + str, end=end, flush=True)
def end(*_):
dprint("WD: Terminating.")
exit(0)
signal(SIGINT, end)
signal(SIGTERM, end)
invalid = False
for ln in stdin:
if len(ln) <= 1:
continue
if "keep up" in ln and not invalid:
dprint("Pausing due to server load.", end="")
kill(int(argv[1]), SIGSTOP)
sleep(10)
print(" Resuming.")
kill(int(argv[1]), SIGCONT)
if argv[2] in ln and "left the game" in ln:
invalid = True
dprint("Pausing due to player left.")
kill(int(argv[1]), SIGSTOP)
sleep(10)
if "Crash Report" in ln or "Stopping server" in ln:
invalid = True
sleep(1)
dprint("Pausing due to server stop.")
kill(int(argv[1]), SIGSTOP)
sleep(60)
if argv[2] in ln and "joined the game" in ln:
invalid = False
dprint("Resuming after player join.")
sleep(10)
kill(int(argv[1]), SIGCONT)
'
}
mc_genmap.parse_args() {
(($# == 0)) && utils.say -h \
'No arguments specified, use -h for help.' && exit 1
while (($# > 0)); do
case "$1" in
-v) utils.say -h \
"$BASENAME: Version $VERSION, updated $MODIFIED by $AUTHOR" &&
shift && (($# > 0)) || exit 0 ;;
-h) utils.say -h "$_mc_genmap_help_text" && exit 0 ;;
-q) QUIET=true && shift ;;
-l) utils.test_arg "$1" "$2" && LOGFILE=$2 && shift 2 ;;
-p) utils.test_arg "$1" "$2" && PLAYER=$2 && shift 2 ;;
-x1) utils.test_arg "$1" "$2" && BOX_X1=$2 && shift 2 ;;
-x2) utils.test_arg "$1" "$2" && BOX_X2=$2 && shift 2 ;;
-z1) utils.test_arg "$1" "$2" && BOX_Z1=$2 && shift 2 ;;
-z2) utils.test_arg "$1" "$2" && BOX_Z2=$2 && shift 2 ;;
-ex1) utils.test_arg "$1" "$2" && EBOX_X1=$2 && shift 2 ;;
-ex2) utils.test_arg "$1" "$2" && EBOX_X2=$2 && shift 2 ;;
-ez1) utils.test_arg "$1" "$2" && EBOX_Z1=$2 && shift 2 ;;
-ez2) utils.test_arg "$1" "$2" && EBOX_Z2=$2 && shift 2 ;;
-x) utils.test_arg "$1" "$2" && ORIGIN_X=$2 && shift 2 ;;
-z) utils.test_arg "$1" "$2" && ORIGIN_Z=$2 && shift 2 ;;
-d) utils.test_arg "$1" "$2" && DIMENSION=$2 && shift 2 ;;
-y) utils.test_arg "$1" "$2" && HEIGHT=$2 && shift 2 ;;
-i) utils.test_arg "$1" "$2" && INTERVAL=$2 && shift 2 ;;
-r) utils.test_arg "$1" "$2" && RESUME=$2 && shift 2 ;;
-s) utils.test_arg "$1" "$2" && SLEEP=$2 && shift 2 ;;
-o) utils.test_arg "$1" "$2" && OUTPUT=$2 && shift 2 ;;
-m) MOVE=true && shift ;;
-t) utils.test_arg "$1" "$2" && TYPE=$2 && shift 2 ;;
-b) START=true && shift ;;
-c) utils.test_arg "$1" "$2" && COMMAND=$2 && shift 2 ;;
-k) STOP=true && shift ;;
-wd) WD_RUN=true && shift ;;
-wj) utils.test_arg "$1" "$2" && WD_JOURNAL=$2 && shift 2 ;;
-wl) utils.test_arg "$1" "$2" && WD_LOGFILE=$2 && shift 2 ;;
*) utils.say -h \
'Invalid argument specified, use -h for help.' && exit 1 ;;
esac
done
}
utils.die() {
utils.say -e "${@:-'Something happened. :P'}"
exit 1
}
utils.say() {
(($# == 1 || $# == 2)) || return
[[ $1 == -h ]] && printf '\e[34m%s\n' "$2" && return
[[ $1 == -e ]] && printf '\e[31m' && local fd=2 && shift
[[ ${TZOFFSET:-} ]] || utils.get_tz_offset_secs -s
[[ ${QUIET:-} ]] ||
printf '%(%FT%TZ)T: %s\n' $((EPOCHSECONDS - TZOFFSET)) "$1" >&"${fd:-1}"
[[ ${LOGFILE:-} ]] &&
printf '%(%FT%TZ)T: %s\n' $((EPOCHSECONDS - TZOFFSET)) "$1" >>"$LOGFILE"
printf '\e[m'
}
utils.get_tz_offset_secs() {
local off && printf -v off '\n%(%z)T' -1
off=${off: -5:1}$((${off: -4:2} * 3600 + ${off: -2:2} * 60))
[[ $1 == -s ]] || printf '%s\n' "$off" && declare -gi TZOFFSET="$off"
}
utils.seconds_to_hms() {
local -i in=${1:-$(</dev/stdin)}
((in / 86400 > 0)) && printf '%d.' $((in / 86400))
printf '%.02d:%.02d:%.02d\n' \
$((in % 86400 / 3600)) $((in % 3600 / 60)) $((in % 60))
}
utils.sleep() {
[[ ${_fd:-} ]] || { exec {_fd}<> <(:) && utils.sleep; }
read -r -t "${1:-0}" -u "$_fd" || :
}
utils.string_xnor() {
(($# == 2)) || return 2
[[ $1 && $2 || ! $1 && ! $2 ]]
}
utils.test_arg() {
(($# == 2)) || return
[[ ${#2} -gt 0 && ($2 == "${2#-}" || $2 =~ ^-?[\.0-9]+) ]] ||
utils.die "Option '$1' expects an argument, but we got: '$2'"
}
mc_genmap.translate_coords() {
: "${BOX_Z1:=BOX_X1}"
: "${BOX_Z2:=BOX_X2}"
: "${EBOX_Z1:=${EBOX_X1:=0}}"
: "${EBOX_Z2:=${EBOX_X2:=0}}"
[[ ! ${ORIGIN_X:-} || ! ${ORIGIN_Z:-} ]] || {
((BOX_X1 += ORIGIN_X, BOX_X2 += ORIGIN_X))
((BOX_Z1 += ORIGIN_Z, BOX_Z2 += ORIGIN_Z))
((EBOX_X1 += ORIGIN_X, EBOX_X2 += ORIGIN_X))
((EBOX_Z1 += ORIGIN_Z, EBOX_Z2 += ORIGIN_Z))
}
}
mc_genmap.validate_args() {
# Check for missing arguments
[[ ${PLAYER:-} && ${#PLAYER} -le 16 ]] ||
utils.die 'Player name invalid. Constraint: 3 <= length(name) <= 16'
utils.string_xnor "${MOVE:-}" "${TYPE:-}" ||
utils.die 'Type and move flags must be specified together.'
[[ ${TYPE:-} == screen ]] &&
! utils.string_xnor "${START:-}" "${COMMAND:-}" &&
utils.die 'COMMAND must be specified if using screen in start mode.'
[[ ${TYPE:-} == rcon && ! ${PASS:-} && ! ${PASSFILE:-} ]] &&
utils.die 'RCON mode chosen but PASS or PASSFILE must be specified.'
[[ ${WD_RUN:-} && ! ${WD_JOURNAL:-} && ! ${WD_LOGFILE:-} ]] &&
utils.die 'Watchdog enabled but neither -wj or -wl were specified.'
{ [[ ${BOX_X1:-} && ${BOX_X2:-} ]] &&
utils.string_xnor "${BOX_Z1:-}" "${BOX_Z2:-}"; } ||
utils.die 'Invalid bounding box. Specify: x1,x2,z1,z2 || x1,x2'
[[ ! ${EBOX_X1:-} ]] || { [[ ${EBOX_X1:-} && ${EBOX_X2:-} ]] &&
utils.string_xnor "${EBOX_Z1:-}" "${EBOX_Z2:-}"; } ||
utils.die 'Invalid exclusion box. Specify: ex1,ex2,ez1,ez2 || ex1,ex2'
utils.string_xnor "${ORIGIN_X:-}" "${ORIGIN_Z:-}" ||
utils.die 'Invalid origin coordinates. Specify: x,z'
# Check for valid arguments
mc_genmap.translate_coords
( ((BOX_X1 < BOX_X2 && BOX_Z1 < BOX_Z2)) &&
( ((EBOX_X1 == EBOX_X2 && EBOX_Z1 == EBOX_Z2)) ||
((EBOX_X1 < EBOX_X2 && EBOX_Z1 < EBOX_Z2)))) ||
utils.die 'Invalid coordinates. Constraint: *x1 < *x2, *z1 < *z2'
( ((BOX_X2 - BOX_X1 >= 256 && BOX_Z2 - BOX_Z1 >= 256)) &&
( ((EBOX_X2 - EBOX_X1 == 0 && EBOX_Z2 - EBOX_Z1 == 0)) ||
((EBOX_X2 - EBOX_X1 >= 64 && EBOX_Z2 - EBOX_Z1 >= 64)))) ||
utils.die 'Invalid box size. Constraint: BBOX > 256, EBOX > 64 || 0'
((64 <= HEIGHT && HEIGHT <= 255)) ||
utils.die 'Invalid height. Constraint: 64 <= height <= 255'
((16 <= INTERVAL && INTERVAL <= (BOX_X2 - BOX_X1) / 2)) ||
utils.die 'Invalid interval. Constraint: 16 <= interval <= (x2-x1)/2'
(((${SLEEP%%.*} > 0 || ${SLEEP##*.} > 0) && ${SLEEP%%.*} <= 10)) ||
utils.die 'Invalid sleep. Constraint: 0 <= sleep <= 10'
# Check for functionality
[[ ${TYPE:-} != rcon ]] || python3 -c 'import rcon' 2>&- ||
utils.die 'The python3 RCON module is needed but missing.'
[[ ${TYPE:-} != rcon ]] || _test=1 mc_genmap.rcon_cmd '/help' ||
utils.die 'Error during RCON test. Check your settings.'
[[ ${TYPE:-} != screen ]] || command -V screen &>/dev/null ||
utils.die 'The screen binary is needed but missing.'
[[ ! ${WD_JOURNAL:-} ]] || command -V journalctl &>/dev/null ||
utils.die 'The journalctl binary is needed but missing.'
}
mc_genmap.exec_die() {
(($? == 130)) && return
[[ ! ${_test:-} ]] || return
utils.die "Error in $TYPE command, aborting."
}
mc_genmap.screen_start() {
[[ ! ${MOVE:-} || $TYPE != screen || ! ${START:-} ]] && return
screen -ls "$SNAME" >&- && return
utils.say "Launching server with command: '$COMMAND'"
screen -dmS "$SNAME" -- sh -c "$COMMAND" || TYPE=launch mc_genmap.exec_die
utils.say 'Server launched.'
[[ ${WD_RUN:-} ]] || {
utils.say 'Press any key when player is logged in.'
read -r -s -n 1 && return
} && {
utils.say 'Waiting for player to login.'
kill -STOP $BASHPID
}
}
mc_genmap.screen_cmd() {
(($# == 1)) || return
screen -S "$SNAME" -X stuff "$1\n" 2>&- || mc_genmap.exec_die
}
mc_genmap.rcon_cmd() {
(($# == 1)) || return
[[ ${PASSFILE:-} ]] && read -r PASS <"$PASSFILE" && export PASS=${PASS##*=}
python3 <(printf '%s' "$_mc_genmap_rcon") "$1" 2>&- || mc_genmap.exec_die
}
mc_genmap.exec_cmd() {
(($# == 1)) || return
[[ ${MOVE:-} ]] && {
[[ $TYPE == screen ]] && mc_genmap.screen_cmd "$1" || :
[[ $TYPE == rcon ]] && mc_genmap.rcon_cmd "$1" || :
} || printf '%s\n' "$1" >>"${OUTPUT:-/dev/stdout}"
}
mc_genmap.move_player() {
(($# == 0 || $# == 3)) || return
[[ ${MOVE:-} && $# -eq 3 ]] && utils.say "Step: $steps, Coords: ($x, $z)"
mc_genmap.exec_cmd "$(
printf '/execute as %s in minecraft:%s run tp %s %s %s %s' \
"$PLAYER" "$DIMENSION" "$PLAYER" "${1:-~}" "${2:-~}" "${3:-~}"
)"
[[ ${MOVE:-} && $# -eq 3 ]] && {
utils.sleep "$SLEEP" && mc_genmap.at_z_edge && utils.sleep "$SLEEP"
((SECONDS % 3600 <= ${SLEEP%%.*})) && utils.say 'Autosaving.' &&
mc_genmap.exec_cmd '/save-all'
} || return 0
}
mc_genmap.outside_ebox() {
((x < EBOX_X1 || x > EBOX_X2 || z < EBOX_Z1 || z > EBOX_Z2))
}
mc_genmap.at_z_edge() {
((z == BOX_Z1 || BOX_Z2 - z < INTERVAL)) ||
( ((EBOX_X1 <= x && x <= EBOX_X2)) &&
( (((EBOX_Z1 > z ? EBOX_Z1 - z : z - EBOX_Z1) <= INTERVAL)) ||
(((EBOX_Z2 > z ? EBOX_Z2 - z : z - EBOX_Z2) <= INTERVAL))))
}
mc_genmap.walk_grid() {
local -i steps=0 x z
utils.say 'Starting movement grid generation.'
mc_genmap.screen_start
mc_genmap.exec_cmd "/gamemode spectator $PLAYER"
utils.say "Moving to correct dimension: $DIMENSION"
mc_genmap.move_player
((${RESUME:=0} > 0)) && utils.say "Resuming generation from step: $RESUME"
for ((x = BOX_X1; x <= BOX_X2; x += INTERVAL)); do
for ((z = BOX_Z1; z <= BOX_Z2; z += INTERVAL)); do
mc_genmap.outside_ebox || continue
((++steps >= RESUME)) || continue
mc_genmap.move_player "$x" "$HEIGHT" "$z"
done
done
utils.say 'Grid walk complete, returning to origin.'
mc_genmap.move_player
utils.say \
"Total steps: $steps, Total time: $(utils.seconds_to_hms $SECONDS)"
[[ ${STOP:-} ]] && utils.say 'Stopping the server.' &&
mc_genmap.exec_cmd '/stop'
utils.say 'Generation complete.'
}
mc_genmap.governor() {
(($# == 1)) || return
utils.say 'Launching watchdog governor.'
[[ ${WD_JOURNAL:-} ]] && local cmd="journalctl -n 1 -o cat -fu $WD_JOURNAL"
[[ ${WD_LOGFILE:-} ]] && local cmd="tail -Fn 1 $WD_LOGFILE"
$cmd 2>&1 | python3 <(printf '%s' "$_mc_genmap_watchdog") "$1" "$PLAYER" ||
TYPE=watchdog mc_genmap.exec_die
}
mc_genmap.generate() {
mc_genmap.set_globals
mc_genmap.parse_args "$@"
mc_genmap.validate_args
[[ ${WD_RUN:-} ]] || {
mc_genmap.walk_grid
return
} && {
trap '2>&- pkill -2 -P $(2>&- jobs -p %2)' EXIT INT TERM
mc_genmap.walk_grid &
mc_genmap.governor "$(jobs -p %1)" &
wait %1
}
}
# Only execute if not being sourced
[[ ${BASH_SOURCE[0]} == "$0" ]] || return 0 && mc_genmap.generate "$@"
@AfroThundr3007730
Copy link
Author

MC Map Generator TODO

  • Refactor generate_map to split command and move modes
  • Check if exclusion box outside of bounding box
  • Alternate mode: don't start/stop server, use existing
  • Allow different x and z intervals, pause on new column
  • Fix steps count when exclusion box specified
  • Alternate resume with coordinates instead of step
  • Add grid_stats to show walk statistics
  • Scrape log or screen stdout to get server status
  • More error handling and dynamic time window for load
  • Auto restart and resume failed walk (client control?)
  • Return player to origin once generation complete
  • Fix other dimensions to use execute for teleport

  • Output mode: Append, don't truncate
  • Fixup help text for variables
  • Refactor sanity checks to exit at end
  • Change errors to use utils.say -e
  • Change help to use utils.say -h
  • Change move_player -> walk_grid
  • Export move syntax to move_player
  • Fix other functions to verb_noun
  • Implement generic check and add rcon_check
  • Merge the two scripts, replay history
  • Set watchdog (and grid_walk) to guard on INTERNAL
  • Enable set -euo pipefail; shopt -s extdebug
  • Test for all external commands (and modules)
  • Adjust watchdog to kill process group (kill -STOP -##)
  • Investigate STOP vs TSTP, and add kill -TERM %2
  • Only fork jobs if watchdog enabled.
  • Add /save-all on completion and every n steps.
  • Properly cleanup jobs on ctrl-c
  • Minimized external dependencies

  • Add the useful functions here to utils.common
  • Test screen mode walk (start and existing)
  • Maybe accept a grid generation spec?
    player[,dim]:[y]:x1,x2[,z1,z2]:[ex1,ex2[,ez1,ez2]]:[ox,oz]:[int]:[sleep]:[resume]
    AfroThundr,overworld::-5000,5000:::100:10:
  • Allow specifying bounding and exclusion radius
    -br <num> [-er <num>]
  • Allow specifying bounding and exclusion corners
    This would mean allowing specifying resume coords.
    -c1 <x1,z1> -c2 <x2,z2> [-ec1 <ex1,ez1> -ec2 <ex2,ez2>]
  • Replay mode: execute a generated command list
  • Add a no return to origin option
  • Consider at_x_edge equivalent check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment