# 7 Days to Die — panel-native dedicated server module.
#
# Unlike the earlier vinanrra/LGSM-based revision, this module owns the
# whole stack:
#
#   1. A minimal Debian runtime image (see ./Dockerfile) is built locally
#      as panel-7dtd:latest. It contains ONLY the libraries the game binary
#      needs — no LGSM, no tmux, no GitHub-fetch-on-start, no surprise
#      backups. Just the server process.
#   2. Game files are installed + validated into a Docker volume by the
#      panel's generic SteamCMD updater (`agent/internal/updater/steamcmd.go`).
#      That sidecar uses the official steamcmd/steamcmd:latest image.
#   3. The entrypoint renders our serverconfig.xml template, drops into the
#      `panel` user, and execs startserver.sh under tini with line-buffered
#      stdout so progress streams live to the panel console.
#
# First-run flow for operators:
#   - Create instance in the panel    → container spec created, volumes empty
#   - Click "Update" on the instance  → SteamCMD sidecar downloads ~17 GB
#   - Click "Start"                   → entrypoint launches 7DTD directly
#
# Subsequent starts skip SteamCMD entirely — instant launch.

id: 7dtd
name: "7 Days to Die"
version: 0.4.0
authors:
  - panel contributors

supported_modes:
  - docker

# Create the instance but DON'T auto-start it. The operator finishes setup
# (world/RWG settings, cluster join + mod-seed, RefugeBot) and starts it
# manually. This also removes the warm-seed-then-immediate-start race that
# could OOM the agent host. The first SteamCMD update / warm-seed still runs on
# create (auto_update_on_create stays default-true); only the START is
# suppressed — enforced by a shouldAutoStart() guard in the agent's
# onUpdateComplete (the warm-seed completion hook), since that path isn't
# otherwise gated by this flag.
auto_start_on_create: false

runtime:
  docker:
    # Built locally from ./Dockerfile. Rebuild with:
    #   docker build -t panel-7dtd:latest modules/7dtd
    image: panel-7dtd:latest
    # The Files tab exposes TWO roots — operators can switch between them
    # with a dropdown at the top of the tab:
    #
    #   Saves & Configs (/game-saves) — world data, serverconfig.xml,
    #       admin files. Default view.
    #   Game Files (/game)            — binaries + Mods/ folder + Data/.
    #       Where you drop a mod tarball to install it.
    #
    # `browseable_root` is kept for backward compat (single-root pre-
    # feature clients) but `browseable_roots` wins if both are set.
    # Host networking — the container shares the host's network namespace,
    # so any port the game OR a mod binds is directly on the LAN. No
    # `-p host:container` publishing required; mods like RefugeBot can
    # port-hunt freely (the AMP model — drop the .dll, mod sniffs free
    # port, LAN_IP:<port> just works).
    #
    # Tradeoff: every 7DTD on this agent shares lo. The panel's port
    # allocator + the env-mapped ports below keep distinct instances
    # from clobbering each other on the canonical 7DTD ports.
    network_mode: host
    browseable_root: /game
    browseable_roots:
      - name: "Game Files"
        path: /game
        hint: "AMP-style flat layout: Data/, Mods/, steamapps/, User/, serverconfig.xml"
      - name: "Saves & Configs"
        path: /game-saves
        hint: "Underlying save volume — telnet password, rendered template, raw .local tree"
    # Pre-declare the port env keys so the agent's resolve.go filter lets
    # panel-allocated values flow through to the container env (only keys
    # already present in docker.env get overridden by config_values).
    # Defaults match the manifest port defaults below.
    env:
      GAME_PORT: "26900"
      TELNET_PORT: "8081"
      WEBDASH_PORT: "8080"
      REFUGEBOT_PORT: "8082"
      # Cluster membership marker. Empty for standalone servers. The
      # controller's 7dtd cluster manager sets this (via config_values)
      # when the server joins a cluster; the entrypoint reads it to
      # symlink this world's Player save folder into /cluster/Player.
      # Pre-declared here so resolve.go's filter lets the override flow
      # through (only keys already present in docker.env get overridden).
      CLUSTER_ID: ""
      # Operator-selected world whose Player folder is shared into
      # /cluster/Player ("<World>/<GameName>"). Set via the Cluster tab's
      # "Shared player world" picker (config_values). Pre-declared here so
      # resolve.go's env filter lets it flow through to the container; the
      # entrypoint reads it to override the literal GameWorld/GameName symlink
      # target — required for RWG worlds whose save folder is named after the
      # generated seed (e.g. "North Tuxera Valley"), not the literal "RWG".
      CLUSTER_PLAYER_SAVE: ""
    volumes:
      # Saves, configs, admin files. Never touched by SteamCMD validate.
      - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
      # Game install — populated by SteamCMD sidecar. Differential updates
      # on a named volume avoid Windows NTFS bind-mount misery.
      - { type: volume, name: "panel-$INSTANCE_ID-game",  container: "/game" }
      # Cluster shared dir. Defaults to a private per-instance named volume
      # so a standalone server has a harmless empty /cluster. The controller's
      # 7dtd cluster manager OVERRIDES this (mount_overrides) to a shared host
      # dir when the server joins a cluster, so every member's entrypoint can
      # symlink its Player save folder into the same /cluster/Player.
      - { type: volume, name: "panel-$INSTANCE_ID-cluster", container: "/cluster" }
      # Bind logs to host so operators can tail / rotate.
      - { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
      # Bind the panel-rendered serverconfig.xml into the saves volume so
      # the entrypoint picks it up. Without this, the entrypoint falls back
      # to the stock serverconfig.xml shipped with the game (ServerName=
      # "My Game Host" etc.) and the operator's config_values are ignored.
      - { target: "$DATA_PATH/serverconfig.xml.rendered", container: "/game-saves/serverconfig.xml.rendered", read_only: true }

ports:
  # 7DTD always binds three contiguous UDP ports starting at ServerPort:
  # the game port, then game+1 (LiteNetLib alt) and game+2 (peer-to-peer
  # backup). We only inject `game` into the container env (entrypoint
  # patches ServerPort in serverconfig.xml). gameA/gameB are declared so
  # the allocator records them as taken — preventing a sibling instance
  # from grabbing game+1 or game+2 — but their env field is intentionally
  # blank (the binary computes those from ServerPort, not env).
  - { name: game,    proto: udp, default: 26900, required: true, env: GAME_PORT }
  - { name: gameA,   proto: udp, default: 26901, required: true }
  - { name: gameB,   proto: udp, default: 26902, required: true }
  - { name: telnet,  proto: tcp, default: 8081,  internal: true, env: TELNET_PORT }
  # Vanilla 7DTD's built-in web dashboard. The TFP entrypoint binds
  # this when WebDashboardEnabled=true in serverconfig.xml.
  - { name: webdash, proto: tcp, default: 8080,  required: false, env: WEBDASH_PORT }
  # RefugeBot mod's web UI (Admin Panel + Player Shop). Not required —
  # only listens when RefugeBot is installed via Settings → Bundled
  # mods. Declared here so the panel allocates a unique host port for
  # it (otherwise multiple 7DTDs would all collide on 8082) and Docker
  # publishes the binding so LAN clients can reach LAN_IP:<host_port>.
  # If a 7DTD instance doesn't have RefugeBot installed, the port is
  # still bound but nothing answers — harmless.
  - { name: refugebot, proto: tcp, default: 8082, required: false, env: REFUGEBOT_PORT }

resources:
  min_ram_mb: 4096
  recommended_ram_mb: 8192

# First-start config: the entrypoint copies /game/serverconfig.xml (the one
# shipped with the game install) into /game-saves on first run.

rcon:
  adapter: telnet
  host_port: telnet
  auth: password_prompt
  # Our entrypoint generates a random TelnetPassword on first boot (7DTD
  # only listens on 0.0.0.0 when a password is set — otherwise it's
  # container-loopback only and the agent can't reach it). The plaintext
  # lands here inside the container for the agent to re-read on each RCON
  # dial attempt.
  password_from_file: /game-saves/.panel-telnet-password
  commands:
    list_players: "lp"
    say: "say \"{msg}\""
    kick: "kick {player} \"{reason}\""
    ban: "ban add {player} {duration} \"{reason}\""
    shutdown: "shutdown"
    save: "saveworld"

state_sources:
  # "lp" (list players) returns "Total of N in the game".
  - type: rcon
    command: "lp"
    every: 30s
    parse:
      kind: regex
      pattern: 'Total of (?P<players>\d+) in the game'
      fields:
        players_online: players
  # 7DTD writes its real game log to /game/output_log_*.txt when invoked
  # via startserver.sh. Our entrypoint bypasses that and streams to stdout
  # (so docker logs → agent → panel console is the primary path). We still
  # watch the log files as a belt-and-braces source for event parsing.
  - type: log_tail
    files:
      - "$DATA_PATH/logs/*.log"
      - "/game/output_log_*.txt"

events:
  join:
    pattern: "PlayerSpawnedInWorld \\(by .*?\\): PltfmId='(?P<platform_id>[^']+)', CrossId='(?P<cross_id>[^']*)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
    kind: join
  leave:
    pattern: "Player disconnected: EntityID=\\d+, PltfmId='(?P<platform_id>[^']+)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
    kind: leave
  chat:
    pattern: "Chat \\(from '.*?', entity id '\\d+', to '.*?'\\): (?:\\[.*?\\])?'(?P<name>[^']+)': (?P<msg>.*)"
    kind: chat
  death:
    pattern: "GMSG: Player '(?P<name>[^']+)' died"
    kind: death

# Render serverconfig.xml from templates/serverconfig.xml.tmpl on every
# (re)create, using the merged operator config_values + generated secrets
# as `.Values.*`. The output lands at $DATA_PATH/serverconfig.xml.rendered
# on the agent host and is bind-mounted into /game-saves/ where the
# entrypoint copies it over the live serverconfig.xml.
config_files:
  - path: serverconfig.xml.rendered
    format: xml
    template: templates/serverconfig.xml.tmpl
    # V3.0 "Dead Hot Summer" overhauls serverconfig.xml: ~29 gameplay
    # properties are removed and folded into one encoded SandboxCode property
    # (the 150 in-game Sandbox Options). A 3.0 server therefore needs a
    # different template than ≤2.6. Selection is by the instance's normalized
    # Steam branch (resolveCreateBranch): the 3.0 experimental branch normalizes
    # to "latest_experimental", so it renders serverconfig.v3.xml.tmpl; every
    # other branch (v2.6 default, public, historical) keeps the untouched 2.6
    # template. When TFP cuts a frozen `v3.0`/`v3.x` branch near stable, add the
    # matching normalized key(s) here — no code change needed (RenderForBranch /
    # ConfigFile.TemplateForBranch are data-driven). See
    # panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
    template_by_branch:
      latest_experimental: templates/serverconfig.v3.xml.tmpl

# 7DTD ships per-version LOCKED Steam branches on app 294420 alongside the
# moving `public` branch. Branch names + frozen buildids confirmed live via
# `steamcmd +app_info_print 294420` on 2026-06-14:
#   public  -> buildid 22422094 (MOVING default; becomes 3.0 on 2026-06-29)
#   v2.6    -> buildid 22422094 ("Version 2.6 Stable" — frozen; == public today)
#   v2.5/v2.4/v2.3/v2.0/v1.4/alpha21.2 ... -> older frozen stables
#   (latest_experimental + the 3.0 experimental live under privatebranches)
#
# ORDER MATTERS. UpdateProviders[0] is the default the agent uses for
# warm-seed install-path resolution AND for the create-time auto-update when
# no provider_id is chosen (pickProviderFromConfigValues). We put the FROZEN
# `v2_6` branch FIRST so a server that doesn't explicitly pick a version stays
# locked to 2.6 and can NEVER drift onto the moving public branch when it flips
# to 3.0. Pinning a server to `-beta v2.6` freezes it at buildid 22422094
# permanently — the corruption guard the whole V3.0 readiness work is built on.
#
# Operators pick a version at create via the "Game version" dropdown (which
# writes config_values.provider_id); the chosen id maps to the matching
# provider here. See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md.
update_providers:
  - id: v2_6                       # DEFAULT — Version 2.6 Stable, frozen (buildid 22422094)
    kind: steamcmd
    app_id: "294420"
    beta: "v2.6"
    install_path: /game            # goes directly into our /game volume
  - id: latest_experimental        # rolling experimental — V3.0 "Dead Hot Summer" lands here 2026-06-15
    kind: steamcmd
    app_id: "294420"
    beta: latest_experimental
    install_path: /game
  - id: current                    # public/stable — MOVING. == 2.6 today; becomes 3.0 on 2026-06-29.
    kind: steamcmd
    app_id: "294420"
    install_path: /game
  - id: stable                     # back-compat alias for `current` (pre-2026-06-14 rows persisted provider_id=stable)
    kind: steamcmd
    app_id: "294420"
    install_path: /game
  # Historical version-locked branches — pin here to run an older client.
  - id: v2_5
    kind: steamcmd
    app_id: "294420"
    beta: "v2.5"
    install_path: /game
  - id: v2_4
    kind: steamcmd
    app_id: "294420"
    beta: "v2.4"
    install_path: /game
  - id: v2_3
    kind: steamcmd
    app_id: "294420"
    beta: "v2.3"
    install_path: /game
  - id: v2_0
    kind: steamcmd
    app_id: "294420"
    beta: "v2.0"
    install_path: /game
  - id: v1_4
    kind: steamcmd
    app_id: "294420"
    beta: "v1.4"
    install_path: /game

# Backup scope — capture the whole user folder + server config.
#
# 7DTD writes saves, admin files, and titlestorage into the per-user
# tree at $HOME/.local/share/7DaysToDie/ (our entrypoint pins HOME to
# /game-saves so this becomes /game-saves/.local/share/7DaysToDie/).
# We grab that, the rendered serverconfig.xml, the persisted telnet
# password, and any 7DTD-side config under .config — that's everything
# an operator would lose if the volume vanished.
#
# Excluded paths:
#   - logs/   — large, never useful for restore
#   - Steam/  — Steam runtime cache, regenerated on next start
backup:
  # Saves + admin files live in /game-saves, not /game. The file browser
  # defaults to /game so operators can see the binary tree, but the
  # backup must target /game-saves where the world data actually lives.
  root: /game-saves
  include:
    - .local/share/7DaysToDie
    - .config
    - serverconfig.xml
    - .panel-telnet-password
  exclude:
    - logs
    - Steam

# --- Manifest-driven UI metadata (WI-07) --------------------------
# Consumed by the CONTROLLER/dashboard only (the controller loads its
# own ./modules copy at startup); the agent ignores these at runtime.
# ready_pattern is a JavaScript regex (slash-delimited when it needs
# flags) moved verbatim from the dashboard's READY_PATTERNS map.
ready_pattern: '/StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i'
appearance:
  emoji: "🧟"
  grad: "linear-gradient(135deg,#7b3b12,#d97706)"
  art: "/game-art/7dtd.jpg"
  steam: "251570"
