#!/bin/bash
# =============================================================================
#  m2-render-config -- materialise the core tree on the state volume and write
#                      every CONFIG / conf.txt from the environment.
#
#  The mt2009 (martysama0134 r41023) edition of linux-port's script of the same
#  name. Same contract, same boot plan, same ports, same core names, so the
#  panels, the launcher and the healthcheck see the world they always saw.
#  What differs is the engine underneath:
#
#    * the protos live in MariaDB (`world' schema, PROTO_FROM_DB = 1), so there
#      is no share/conf and nothing to link into the db core's directory but
#      data/ and locale/, exactly as the package's own tree has it;
#    * the db core needs a sixth SQL handle, SQL_WORLD;
#    * the package starts player ids at 100 and item ids at two billion.
#
#  Runs on every container start. The core directories live on the state
#  volume; everything they need from the image is reached through an ABSOLUTE
#  symlink into /opt/metin2/share. Configuration files are rewritten every
#  boot: they are derived state, not operator state.
# =============================================================================
set -euo pipefail

log() { printf '%s [config] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%SZ')" "$*"; }

SHARE_DIR="${M2_SHARE_DIR:-/opt/metin2/share}"
VAR_DIR="${M2_VAR_DIR:-/opt/metin2/var}"

# -----------------------------------------------------------------------------
# Addressing -- see linux-port/docker/game/bin/m2-render-config for the three
# addresses a core has (listen / public / proxy) and why they are kept apart.
# -----------------------------------------------------------------------------
CONTAINER_IP="$(getent hosts "$(hostname)" 2>/dev/null | awk '{print $1; exit}')"
[ -n "$CONTAINER_IP" ] || CONTAINER_IP="127.0.0.1"

is_ipv4() {
  local o IFS=.
  # shellcheck disable=SC2206
  local parts=($1)
  [ "${#parts[@]}" = 4 ] || return 1
  for o in "${parts[@]}"; do
    [[ "$o" =~ ^[0-9]+$ ]] || return 1
    [ "$o" -le 255 ] || return 1
  done
  return 0
}

# The ItemShop address is host:port and nothing else (cmd_general.cpp writes
# "mall http://%s/ishop?..." around it).
if [ -n "${M2_MALL_URL:-}" ]; then
  M2_MALL_URL="${M2_MALL_URL#http://}"
  M2_MALL_URL="${M2_MALL_URL#https://}"
  M2_MALL_URL="${M2_MALL_URL%/}"
  export M2_MALL_URL
fi

PROXY_LINE=""
if [ -n "${M2_PUBLIC_ADDRESS:-}" ]; then
  PROXY_LINE="PROXY_IP: ${M2_PUBLIC_ADDRESS}"
  if is_ipv4 "$M2_PUBLIC_ADDRESS"; then
    log "advertising ${M2_PUBLIC_ADDRESS} to clients (literal address)"
  else
    log "advertising ${M2_PUBLIC_ADDRESS} to clients (resolved at core startup)"
  fi
fi

BIND_LINE="BIND_IP: ${M2_BIND_IP:-0.0.0.0}"
# This core's own address, stated rather than sniffed: GetIPInfo() files a
# 192.168.x address under INTERNAL_IP, and a Compose network can be one.
PUBLIC_LINE="PUBLIC_IP: ${CONTAINER_IP}"

# -----------------------------------------------------------------------------
# Map distribution.
#
# One map is hosted by exactly one core and a bot cannot cross between cores
# (it has no client to answer a HEADER_GC_WARP), so a kingdom's whole life --
# village, second village, guild map, Monkey Dungeon -- sits on one core, and
# the shared world the bots hunt on sits with Chunjo on game1. The package's
# own layout (ch11: the three first villages, ch12/ch13: everything else) would
# strand every bot at the first gate.
#
# Everything the package hosts is hosted here, plus the three guild villages,
# the original Monkey Dungeon and the high maps the r40250 layout carried
# (72 73 208 216 217 301-304 351). The sungzi set (114, 118-128), the test maps
# 103/105 and 100/101 are left out exactly as the package leaves them out.
# The CONFIG parser reads a line with fgets(buf, 256, fp); the longest line
# below is well inside that.
# -----------------------------------------------------------------------------
MAPS_first="1 3 4 5 6 107 81 110 111 112 113 181 182 183 200 250 302 304"
# 66, 67 and 68 - the Demon Tower, the Forest and the Red Forest - moved off
# game2 the way 104 and 109 were moved before them: a bot cannot walk onto a map
# its own core does not host, and every bot in this world lives on game1. The
# Demon Tower is what the Biologist's level-50 specimen and the military horse
# are behind; the two forests are the 65-71 and 74-82 ground this world had
# nowhere else. 62, Doyyumhwaji, followed them: its 69-72 fill the band between
# the two forests, and each kingdom enters it through a gate of its own. 72,
# the first Grotto of Exile, joined its second (73) here on 26 September: the
# two are the 81-97 ground past the Red Forest, entered from Orc Valley, and a
# grotto on game2 was a grotto no bot could stand in.
MAPS_game1="21 23 24 25 26 61 62 63 64 65 66 67 68 69 70 71 104 108 109 79 216 217 72 73"
MAPS_game2="41 43 44 45 46 90 208 301 303 351"

# One world on one core, when the operator asks for it.
#
# The shared frontier -- Orc Valley, the desert, Sohan, both Spider Dungeons,
# Hwang -- lives only on game1, and a bot cannot cross cores, so on the default
# per-kingdom split a Shinsoo or Jinno bot has no map past its second village
# and wedges at level thirty-six (reported on the Discord as "czerwoni i
# niebiescy nie moga wyjsc z M2"). M2_PLAYERBOT_WORLD_LAYOUT=unified moves the
# two other kingdoms' four hunting maps (M1/M2/guild/Monkey) onto game1 next to
# Chunjo's and the shared world, so every bot of every kingdom reaches the whole
# 1..104 ladder and can stand on any kingdom's ground. The bootstrap already
# spawns a kingdom wherever its village map is hosted (map_allow_find), so no
# core code changes; first and game2 keep their guild/event/high maps and simply
# host no bots. The cost is load: all bots now tick on one core, so keep the
# population modest (the launcher caps it) -- this is for one machine running one
# world, not for a 2500-bot server that needs the three-way split. Default is
# 'split': the historical layout, unchanged for everyone who does not opt in.
M2_PLAYERBOT_WORLD_LAYOUT="${M2_PLAYERBOT_WORLD_LAYOUT:-split}"
if [ "$M2_PLAYERBOT_WORLD_LAYOUT" = "unified" ]; then
  MAPS_game1="$MAPS_game1 1 3 4 5 41 43 44 45"
  MAPS_first="6 107 81 110 111 112 113 181 182 183 200 250 302 304"
  MAPS_game2="46 90 208 301 303 351"
  log "world layout: unified (all three kingdoms + frontier on game1)"
else
  log "world layout: split (per-kingdom cores; Shinsoo/Jinno cap ~36)"
fi
# The difficulty is acted on by the migrate service (event flags the quests
# read); said here so a support bundle carries it next to the layout.
log "difficulty: ${M2_DIFFICULTY:-easy} (Biologist and stable keeper waits; apply.sh writes the flags)"

# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
link_into() { ln -sfn "$SHARE_DIR/$1" "$2"; }

# core_skeleton <dir> -- what the package's own core directories carry:
# data, locale, package links, a log directory and a place for core dumps.
core_skeleton() {
  local d="$1"
  mkdir -p "$d/log" "$d/cores"
  link_into data    "$d/data"
  link_into locale  "$d/locale"
  link_into package "$d/package"
}

# -----------------------------------------------------------------------------
# db core
# -----------------------------------------------------------------------------
render_db() {
  local d="$VAR_DIR/db"
  mkdir -p "$d/log" "$d/cores"
  ln -sfn "$SHARE_DIR/bin/db" "$d/db"
  link_into data   "$d/data"
  link_into locale "$d/locale"

  # BIND_IP deliberately absent: ClientManager.cpp defaults it to 127.0.0.1 and
  # the db core must never be reachable from outside -- it speaks an
  # unauthenticated protocol.
  # Field order is "host database user password port" -- NOT the game cores'
  # "host user password database". The wrong order reads like a bad password.
  # Config.cpp's GetWord() writes into a 256-byte buffer with no bounds check;
  # M2_DB_PASSWORD is length-checked in the entrypoint for that reason.
  cat > "$d/conf.txt" <<EOF
BIND_PORT = ${M2_DB_CORE_PORT}
SQL_ACCOUNT = "${M2_DB_HOST} account ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
SQL_COMMON = "${M2_DB_HOST} common ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
SQL_HOTBACKUP = "${M2_DB_HOST} hotbackup ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
SQL_PLAYER = "${M2_DB_HOST} player ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
SQL_LOG = "${M2_DB_HOST} log ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
SQL_WORLD = "${M2_DB_HOST} world ${M2_DB_USER} ${M2_DB_PASSWORD} ${M2_DB_PORT}"
TABLE_POSTFIX = "${M2_TABLE_POSTFIX}"
DB_SLEEP_MSEC = 10
CLIENT_HEART_FPS = 10
HASH_PLAYER_LIFE_SEC = 600
BACKUP_LIMIT_SEC = 3600
PLAYER_ID_START = 100
PLAYER_DELETE_LEVEL_LIMIT = ${M2_PLAYER_DELETE_LEVEL_LIMIT}
PLAYER_DELETE_CHECK_SIMPLE = 1
ITEM_ID_RANGE = 2000000000 2100000000
LOCALE = "cp1250"
PROTO_FROM_DB = 1
MIRROR2DB = 1
WELCOME_MSG = "DB Server has been started"
TEST_SERVER = ${M2_TEST_SERVER}
EOF
}

# -----------------------------------------------------------------------------
# auth core -- the game binary installed under the name `auth'. PLAYER_SQL
# points at the account database: auth authenticates, it loads no characters.
# -----------------------------------------------------------------------------
#
# (client_version_gate_off.patch, 18 September 2026: server_version is 0 below, so
# NO client is refused for its version. Upstream sets it to 1010100 - the client
# with four inventory pages - and answers "UPDATE" to an older one. The operator
# asked for that requirement to go: an older client now logs in, and draws this
# world's third and fourth bag pages as its horse page and beyond.)
# server_version is the oldest client the auth core lets in. The client sends
# constInfo.GAME_VERSION before it logs in (expansion*10000 + major*100 +
# minor, plus 1000000 for a release build) and CInputAuth::Login answers
# "UPDATE" to anything below this. 1.1.0 is the client with four inventory
# pages (playerbotify.py, apply_four_inventory_pages): a two-page client would
# draw this world's third bag page as its horse page. Only the auth core gets
# the token - in a game core the engine also reads it as "a production server"
# and blocks /reload p and /reload q and the beta_server flag with it.
render_auth() {
  local d="$VAR_DIR/auth"
  core_skeleton "$d"
  ln -sfn "$SHARE_DIR/bin/game" "$d/auth"

  cat > "$d/CONFIG" <<EOF
CHANNEL: 1
HOSTNAME: Auth01
server_version: 0
PORT: ${M2_AUTH_PORT}
P2P_PORT: ${M2_AUTH_P2P_PORT}
DB_ADDR: 127.0.0.1
DB_PORT: ${M2_DB_CORE_PORT}
ACCOUNT_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} account ${M2_DB_PORT}
PLAYER_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} account ${M2_DB_PORT}
COMMON_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} common ${M2_DB_PORT}
LOG_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} log ${M2_DB_PORT}
TABLE_POSTFIX: ${M2_TABLE_POSTFIX}
PASSES_PER_SEC: 25
LOG_KEEP_DAYS: ${M2_LOG_KEEP_DAYS}
PING_EVENT_SECOND_CYCLE: 180
ADMINPAGE_PASSWORD: ${M2_ADMINPAGE_PASSWORD}
ADMINPAGE_IP: ${M2_ADMINPAGE_IP}
AUTH_SERVER: master
TEST_SERVER: ${M2_TEST_SERVER}
${BIND_LINE}
${PUBLIC_LINE}
${PROXY_LINE}
EOF
}

# -----------------------------------------------------------------------------
# a channel game core
#   render_game <channel> <role: first|game1|game2>
# -----------------------------------------------------------------------------
# The channels' first port, 13000 unless M2_GAME_PORT_BASE says otherwise: a
# second stack on the same host (the test server beside a public one) takes
# another range, and a core must listen on the port the players reach, because
# a warp between cores sends the client the target core's own PORT.
M2_GAME_PORT_BASE="${M2_GAME_PORT_BASE:-13000}"
case "$M2_GAME_PORT_BASE" in ''|*[!0-9]*) M2_GAME_PORT_BASE=13000 ;; esac

render_game() {
  local ch="$1" role="$2"
  local d="$VAR_DIR/channel${ch}/${role}"
  local base=$(( M2_GAME_PORT_BASE + (ch - 1) * 10 ))
  local p2pbase=$(( 14000 + (ch - 1) * 10 ))
  local offset hostname maps

  case "$role" in
    first) offset=0; hostname="first"; maps="$MAPS_first" ;;
    game1) offset=1; hostname="Game1"; maps="$MAPS_game1" ;;
    game2) offset=2; hostname="Game2"; maps="$MAPS_game2" ;;
    *) echo "unknown role $role" >&2; return 1 ;;
  esac

  core_skeleton "$d"
  ln -sfn "$SHARE_DIR/bin/game" "$d/game"
  link_into CMD "$d/CMD"

  # Derived state, rewritten by the core every cycle it has bots. A core that
  # ends up hosting none (first/game2 under the unified layout, or after any
  # map move) never rewrites it, so a stale file from a previous layout would
  # leave the panel counting bots that are not there. Clear it on boot; a core
  # with bots recreates it within a second.
  rm -f "$d/playerbot_status.tsv" "$d/playerbot_status.tsv.tmp"

  # Guild marks are player-created content. Seed once, then never touch.
  if [ ! -d "$d/mark" ]; then
    mkdir -p "$d/mark"
    cp -a "$SHARE_DIR/mark-default/." "$d/mark/" 2>/dev/null || true
  fi

  cat > "$d/CONFIG" <<EOF
CHANNEL: ${ch}
HOSTNAME: ${hostname}
PORT: $(( base + offset ))
P2P_PORT: $(( p2pbase + offset ))
DB_ADDR: 127.0.0.1
DB_PORT: ${M2_DB_CORE_PORT}
MAP_ALLOW: ${maps}
ACCOUNT_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} account ${M2_DB_PORT}
PLAYER_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} player ${M2_DB_PORT}
COMMON_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} common ${M2_DB_PORT}
LOG_SQL: ${M2_DB_HOST} ${M2_DB_USER} ${M2_DB_PASSWORD} log ${M2_DB_PORT}
TABLE_POSTFIX: ${M2_TABLE_POSTFIX}
PASSES_PER_SEC: 25
LOG_KEEP_DAYS: ${M2_LOG_KEEP_DAYS}
SAVE_EVENT_SECOND_CYCLE: 180
PING_EVENT_SECOND_CYCLE: 180
VIEW_RANGE: 8000
CHECK_MULTIHACK: 0
ADMINPAGE_PASSWORD: ${M2_ADMINPAGE_PASSWORD}
ADMINPAGE_IP: ${M2_ADMINPAGE_IP}
SPEEDHACK_LIMIT_COUNT: 300
SPEEDHACK_LIMIT_BONUS: 80
PK_PROTECT_LEVEL: 15
MALL_URL: ${M2_MALL_URL}
GLOBAL_SHOUT: 1
SHOP_PRICE_3X_TAX: 0
TEST_SERVER: ${M2_TEST_SERVER}
MOONLIGHT_CHEST_PERMILLE: ${M2_MOONLIGHT_CHEST_PERMILLE:-10}
MOONLIGHT_CHEST_STONE_PERMILLE: ${M2_MOONLIGHT_CHEST_STONE_PERMILLE:-300}
DRAGON_COIN_STONE_PERMILLE: ${M2_DRAGON_COIN_STONE_PERMILLE:-3}
DRAGON_COIN_BOSS_PERMILLE: ${M2_DRAGON_COIN_BOSS_PERMILLE:-50}
BLESSING_SCROLL_STONE_PERMILLE: ${M2_BLESSING_SCROLL_STONE_PERMILLE:-10}
MAX_LEVEL: ${M2_MAX_LEVEL}
${BIND_LINE}
${PUBLIC_LINE}
${PROXY_LINE}
EOF
}

# -----------------------------------------------------------------------------
# Render everything and write the boot plan the supervisor follows:
#     name|dir|argv0|readiness-port|kind
# db first (every core dials it during boot), then auth, then the channels.
# -----------------------------------------------------------------------------
mkdir -p "$VAR_DIR"
PLAN="$VAR_DIR/boot-plan"
: > "$PLAN"

render_db
printf 'db|%s/db|db|%s|db\n' "$VAR_DIR" "$M2_DB_CORE_PORT" >> "$PLAN"

render_auth
printf 'auth|%s/auth|auth|%s|auth\n' "$VAR_DIR" "$M2_AUTH_PORT" >> "$PLAN"

for ch in $(seq 1 "${M2_CHANNELS}"); do
  for role in first game1 game2; do
    render_game "$ch" "$role"
    case "$role" in first) off=0 ;; game1) off=1 ;; game2) off=2 ;; esac
    printf 'ch%s-%s|%s/channel%s/%s|game|%s|game\n' \
      "$ch" "$role" "$VAR_DIR" "$ch" "$role" "$(( M2_GAME_PORT_BASE + (ch - 1) * 10 + off ))" >> "$PLAN"
  done
done

# A channel switched off leaves its cores' reports on the state volume, and the
# panels read every channel's - a stale second channel would be counted as bots
# and guilds that are not there. The live channels cleared their own above.
for chdir in "$VAR_DIR"/channel*; do
  [ -d "$chdir" ] || continue
  n="${chdir##*/channel}"
  case "$n" in ''|*[!0-9]*) continue ;; esac
  if [ "$n" -gt "${M2_CHANNELS}" ]; then
    rm -f "$chdir"/*/playerbot_status.tsv "$chdir"/*/playerbot_events_status.tsv \
      "$chdir"/*/playerbot_guild_status.tsv 2>/dev/null || true
  fi
done

log "rendered $(wc -l < "$PLAN") cores across ${M2_CHANNELS} channel(s)"
log "listen=${M2_BIND_IP:-0.0.0.0} container=${CONTAINER_IP} advertised=${M2_PUBLIC_ADDRESS:-<unset>}"

chmod 0640 "$VAR_DIR"/db/conf.txt "$VAR_DIR"/auth/CONFIG 2>/dev/null || true
find "$VAR_DIR" -name CONFIG -type f -exec chmod 0640 {} + 2>/dev/null || true
