diff --git a/minecraft-java/template.yaml b/minecraft-java/template.yaml index 70866f1..083f722 100644 --- a/minecraft-java/template.yaml +++ b/minecraft-java/template.yaml @@ -23,7 +23,7 @@ spec: displayName: Minecraft (Java Edition) game: minecraft-java # Kept in lockstep with module.yaml's bundle version from 2.5.0 onward. - version: 2.8.1 + version: 2.9.0 categories: [Sandbox, Survival, Building, Modded, Creative] # Brand color the dashboard tints this game's icon/accents with. accentColor: "#5b9a3e" diff --git a/nuclear-option/module.yaml b/nuclear-option/module.yaml index 5daf788..0c83516 100644 --- a/nuclear-option/module.yaml +++ b/nuclear-option/module.yaml @@ -2,7 +2,7 @@ apiVersion: gameplane.local/module/v1 name: nuclear-option displayName: Nuclear Option -version: 1.0.0 +version: 1.0.1 game: nuclear-option categories: [Simulation, PvP] summary: Nuclear Option dedicated server with remote-command console, runtime-downloaded via SteamCMD. diff --git a/nuclear-option/template.yaml b/nuclear-option/template.yaml index 55ebfa6..434a2d6 100644 --- a/nuclear-option/template.yaml +++ b/nuclear-option/template.yaml @@ -28,7 +28,7 @@ spec: displayName: Nuclear Option game: nuclear-option # Kept in lockstep with module.yaml's bundle version. - version: 1.0.0 + version: 1.0.1 categories: [Simulation, PvP] # Brand color for the dashboard. accentColor: "#ff6b6b" @@ -166,12 +166,9 @@ spec: list: command: "get-player-list" entryRegex: '"steamId":\s*"(?P[^"]*)"' - kick: - command: "kick-player {{.Player}}" - ban: - command: "banlist-add {{.Player}}" - unban: - command: "banlist-remove {{.Player}}" + kick: "kick-player {{.Player}}" + ban: "banlist-add {{.Player}}" + unban: "banlist-remove {{.Player}}" # Note: the server returns 2000 (success) for kick/ban/unban even when # the Steam ID is not present, so the agent cannot distinguish a # successful moderation from a silent no-op. diff --git a/terraria/README.md b/terraria/README.md index 02f5fde..325d139 100644 --- a/terraria/README.md +++ b/terraria/README.md @@ -18,7 +18,7 @@ override) or add an entry to `spec.versions`. tModLoader servers get a **Mods** tab that manages modpacks under `/opt/terraria/config/ModPacks` — its own per-(version+loader) volume, so each tModLoader build keeps its own set. Select the active modpack with the -**Active modpack** (`MODPACK`) config field. Vanilla Terraria has no mods, so +**Active modpack** (`MODPACK`) config field; left blank, tModLoader servers start a built-in empty `vanilla` pack. Vanilla Terraria has no mods, so the tab is hidden for vanilla servers. Installs are allowed from GitHub (max 512 MiB), subject to the agent's SSRF guard. @@ -36,7 +36,8 @@ offered. ## Console No RCON. The **Console** tab attaches to the container's stdin/stdout (pty) — -type `help` for the command list. +type `help` for the command list. The image runs the server inside a detached tmux session, so the +template's start wrapper forwards each console line into that session; the Stop button's `exit` saves the world and stops within seconds. ## Ports diff --git a/terraria/module.yaml b/terraria/module.yaml index b3b884a..61f7840 100644 --- a/terraria/module.yaml +++ b/terraria/module.yaml @@ -2,7 +2,7 @@ apiVersion: gameplane.local/module/v1 name: terraria displayName: Terraria -version: 2.2.2 +version: 2.2.3 game: terraria categories: [Sandbox, Survival, Adventure, Modded] summary: Terraria + tModLoader dedicated server (passivelemon/terraria-docker) with a vanilla/tModLoader version picker and a tModLoader ModPacks volume. Console via container stdin. diff --git a/terraria/specs.md b/terraria/specs.md index bf5f964..56b9f79 100644 --- a/terraria/specs.md +++ b/terraria/specs.md @@ -45,6 +45,7 @@ Declared ports under `spec.ports`: - **Protocol**: `none` - **Console Mode**: `pty` +- **Console Bridge**: The start wrapper forwards pty lines to the game's tmux session with `tmux send-keys -l` (as `PUID:PGID` via gosu/su-exec) and forwards SIGTERM to the entrypoint. - **Authentication**: N/A (interactive stdin/stdout console). - **Command Support**: Standard Terraria CLI commands (`say`, `save`, `kick`, `ban`, `settle`, `motd`, `dawn`, `noon`, `dusk`, `midnight`). diff --git a/terraria/template.yaml b/terraria/template.yaml index babdee9..0d897d2 100644 --- a/terraria/template.yaml +++ b/terraria/template.yaml @@ -21,7 +21,7 @@ spec: displayName: Terraria game: terraria # Kept in lockstep with module.yaml's bundle version from 2.1.0 onward. - version: 2.2.0 + version: 2.2.3 categories: [Sandbox, Survival, Adventure, Modded] # Brand color the dashboard tints this game's icon/accents with. accentColor: "#4a90d9" @@ -36,6 +36,58 @@ spec: # authoritative source for the wizard's picker. image: passivelemon/terraria-docker:terraria-latest@sha256:d60f280522d6c71079638b5036bdd6c4ac9f93c9f3250477ddb345fc2c6933f3 + # passivelemon's tModLoader scripts exit when MODPACK is blank or its + # ModPacks//Mods/enabled.json is missing. On tModLoader images + # (GP_LOADER=tmodloader) a blank MODPACK selects a built-in empty "vanilla" + # pack, created on first start and never overwritten. The image runs the + # game in a detached tmux session that never reads container stdin, so the + # wrapper also bridges the console: each line from the pty is sent to the + # game with tmux send-keys (as the game user), and SIGTERM is forwarded to + # the entrypoint so its save-and-exit trap still runs. + command: ["/bin/sh", "-c"] + args: + - | + pid= + trap 'if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null; else exit 143; fi' TERM + if [ "${GP_LOADER:-}" = "tmodloader" ]; then + : "${MODPACK:=vanilla}"; export MODPACK + if [ "$MODPACK" = "vanilla" ]; then + mkdir -p /opt/terraria/config/ModPacks/vanilla/Mods + [ -e /opt/terraria/config/ModPacks/vanilla/Mods/enabled.json ] || printf '[]\n' > /opt/terraria/config/ModPacks/vanilla/Mods/enabled.json + fi + fi + /opt/terraria/entrypoint.sh & + pid=$! + rpid= + if [ -t 0 ]; then + exec 3<&0 + ( + cr=$(printf '\r') + as= + if [ "$(id -u)" = 0 ]; then + if command -v gosu >/dev/null 2>&1; then as="gosu"; else as="su-exec"; fi + as="$as ${PUID:-1000}:${PGID:-1000}" + fi + while :; do + while IFS= read -r line <&3; do + line=${line%"$cr"} + [ -n "$line" ] || continue + if ! { $as /usr/bin/tmux send-keys -l -- "$line" && $as /usr/bin/tmux send-keys Enter; } 2>/dev/null; then + echo "[gameplane] console not ready; input ignored" + fi + done + sleep 1 + done + ) & + rpid=$! + fi + while :; do + wait "$pid"; rc=$? + kill -0 "$pid" 2>/dev/null || break + done + [ -z "$rpid" ] || kill "$rpid" 2>/dev/null + exit "$rc" + ports: - name: game containerPort: 7777 @@ -78,6 +130,9 @@ spec: displayName: "tModLoader · latest" image: passivelemon/terraria-docker:tmodloader-latest@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e loader: tmodloader + env: + - name: GP_LOADER + value: tmodloader # Year channel: follows tModLoader's 2026.x stable line without jumping # to the next yearly major — steadier for servers with version-sensitive # mod sets than the plain latest tag. @@ -85,10 +140,16 @@ spec: displayName: "tModLoader · 2026 stable" image: passivelemon/terraria-docker:tmodloader-2026@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e loader: tmodloader + env: + - name: GP_LOADER + value: tmodloader - id: tmodloader-preview displayName: "tModLoader · latest preview" image: passivelemon/terraria-docker:tmodloader-latest-pre # gameplane:floating loader: tmodloader + env: + - name: GP_LOADER + value: tmodloader probes: readiness: @@ -253,6 +314,6 @@ spec: default: "en-US" - name: MODPACK displayName: Active modpack - description: tModLoader only — a modpack folder name under ModPacks. Leave blank for none. + description: tModLoader only — a modpack folder name under ModPacks. Leave blank for the built-in empty "vanilla" pack. type: string default: "" diff --git a/the-isle/module.yaml b/the-isle/module.yaml index a06d9d6..15541d8 100644 --- a/the-isle/module.yaml +++ b/the-isle/module.yaml @@ -4,7 +4,7 @@ name: the-isle displayName: The Isle version: 1.0.0 game: the-isle -categories: [Survival, Dinosaur, Open World, Multiplayer] +categories: [Survival, Dinosaurs, Open World, Multiplayer] summary: The Isle dedicated server (Unreal Engine 4) with Source RCON remote administration and persistent world save management. homepage: https://findtheisle.com/ license: MIT diff --git a/the-isle/template.yaml b/the-isle/template.yaml index 501b014..4cf6272 100644 --- a/the-isle/template.yaml +++ b/the-isle/template.yaml @@ -17,7 +17,7 @@ spec: displayName: The Isle game: the-isle version: 1.0.0 - categories: [Survival, Dinosaur, Open World, Multiplayer] + categories: [Survival, Dinosaurs, Open World, Multiplayer] accentColor: "#22543d" description: | The Isle dedicated multiplayer server running on Unreal Engine. diff --git a/tmodloader/README.md b/tmodloader/README.md index 083bc94..cdfa2ce 100644 --- a/tmodloader/README.md +++ b/tmodloader/README.md @@ -12,11 +12,11 @@ kubectl apply -f modules/tmodloader/template.yaml ## Mod Management -tModLoader mods (`.tmod`) and modpacks are managed through the **Mods** tab. Mod files are stored under `/root/.local/share/Terraria/tModLoader/Mods`. Installs are allowed from GitHub release archives (max 512 MiB). +A fresh server starts the built-in empty `vanilla` modpack (no mods). The **Mods** tab manages `.tmod` files in that pack, under `/opt/terraria/config/ModPacks/vanilla/Mods`; a mod loads once its name is listed in that folder's `enabled.json`. To run another modpack, put it under `/opt/terraria/config/ModPacks//Mods` (with its `enabled.json`) and set **Active modpack** (`MODPACK`) to ``. Installs are allowed from GitHub release archives (max 512 MiB). ## Console (PTY) -Terraria engines do not provide an RCON TCP port. The **Console** tab attaches directly to the container's stdin/stdout (pty) using the kubelet pod-attach API. Stop sequence issues `exit` to trigger world flushing before shutdown. +Terraria engines do not provide an RCON TCP port. The **Console** tab attaches directly to the container's stdin/stdout (pty) using the kubelet pod-attach API. Stop sequence issues `exit` to trigger world flushing before shutdown. The image runs the server inside a detached tmux session, so the template's start wrapper forwards each console line into that session. ## Ports @@ -26,10 +26,10 @@ Terraria engines do not provide an RCON TCP port. The **Console** tab attaches d ## Storage -Storage is mounted at `/root/.local/share/Terraria/tModLoader` (4 GiB default), holding: +Storage is mounted at `/opt/terraria/config` (4 GiB default), holding: - World files (`Worlds/`) -- Installed mods (`Mods/`) -- Mod configurations and loadouts +- Modpacks (`ModPacks//Mods/`, including the default `vanilla` pack) +- The generated `serverconfig.txt` ## Sample diff --git a/tmodloader/module.yaml b/tmodloader/module.yaml index 4e1b63f..c1fc794 100644 --- a/tmodloader/module.yaml +++ b/tmodloader/module.yaml @@ -2,7 +2,7 @@ apiVersion: gameplane.local/module/v1 name: tmodloader displayName: tModLoader -version: 1.0.1 +version: 1.0.2 game: tmodloader categories: [Sandbox, Survival, Adventure, Modded] summary: tModLoader dedicated server for modded Terraria gameplay with PTY console, .tmod mod manager, and world saves. diff --git a/tmodloader/samples/gameserver.yaml b/tmodloader/samples/gameserver.yaml index 174860e..107c374 100644 --- a/tmodloader/samples/gameserver.yaml +++ b/tmodloader/samples/gameserver.yaml @@ -13,7 +13,6 @@ spec: DIFFICULTY: "1" MAXPLAYERS: "8" MOTD: "Welcome to Gameplane tModLoader Server" - MODPACK: "" networking: expose: NodePort diff --git a/tmodloader/specs.md b/tmodloader/specs.md index 1c9f64d..6db02e8 100644 --- a/tmodloader/specs.md +++ b/tmodloader/specs.md @@ -14,7 +14,7 @@ - **Base Image**: `passivelemon/terraria-docker:tmodloader-latest@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e` - **Architecture**: `linux/amd64` - **Runtime Model**: .NET 6 runtime host running `tModLoader.dll` with integrated mono/native runtime dependencies. -- **User & Execution Context**: Root container execution (`/`). +- **User & Execution Context**: Starts as root; a small wrapper picks the default `vanilla` modpack when `MODPACK` is blank, then the image entrypoint drops to the `terraria` user (uid/gid 1000) via su-exec. --- @@ -30,7 +30,7 @@ Declared ports under `spec.ports`: ## 4. Storage & Persistence Layout -- **Mount Path**: `/root/.local/share/Terraria/tModLoader` +- **Mount Path**: `/opt/terraria/config` - **Default Sizing**: `4Gi` - **Persisted Content**: - Generated modded worlds (`Worlds/*.wld`) @@ -44,6 +44,7 @@ Declared ports under `spec.ports`: - **Protocol**: `none` - **Console Mode**: `pty` +- **Console Bridge**: The start wrapper forwards pty lines to the game's tmux session with `tmux send-keys -l` (as `PUID:PGID` via gosu/su-exec) and forwards SIGTERM to the entrypoint. - **Authentication**: N/A (interactive terminal console). - **Command Support**: Standard Terraria and tModLoader CLI commands (`say`, `save`, `exit`, mod reload commands). diff --git a/tmodloader/template.yaml b/tmodloader/template.yaml index 81da7a6..2cf97a5 100644 --- a/tmodloader/template.yaml +++ b/tmodloader/template.yaml @@ -3,7 +3,8 @@ # # Backed by passivelemon/terraria-docker:tmodloader-latest. Dedicated server # running the tModLoader .NET 6 modding framework for Terraria. Console -# attaches to container stdin (pty); no RCON. +# attaches to container stdin (pty); no RCON. A blank MODPACK starts the +# built-in empty "vanilla" modpack (see the command wrapper below). # # Cluster-scoped; apply once per cluster: # kubectl apply -f modules/tmodloader/template.yaml @@ -17,7 +18,7 @@ metadata: spec: displayName: tModLoader game: tmodloader - version: 1.0.0 + version: 1.0.2 categories: [Sandbox, Survival, Adventure, Modded] accentColor: "#10b981" description: | @@ -26,6 +27,62 @@ spec: image: passivelemon/terraria-docker:tmodloader-latest@sha256:3f2d8703421159f1037084bd2c0901a3a63b85a00801cf36f6f928e8b666b44e + env: + - name: GP_LOADER + value: tmodloader + + # passivelemon's tModLoader scripts exit when MODPACK is blank or its + # ModPacks//Mods/enabled.json is missing. On tModLoader images + # (GP_LOADER=tmodloader) a blank MODPACK selects a built-in empty "vanilla" + # pack, created on first start and never overwritten. The image runs the + # game in a detached tmux session that never reads container stdin, so the + # wrapper also bridges the console: each line from the pty is sent to the + # game with tmux send-keys (as the game user), and SIGTERM is forwarded to + # the entrypoint so its save-and-exit trap still runs. + command: ["/bin/sh", "-c"] + args: + - | + pid= + trap 'if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null; else exit 143; fi' TERM + if [ "${GP_LOADER:-}" = "tmodloader" ]; then + : "${MODPACK:=vanilla}"; export MODPACK + if [ "$MODPACK" = "vanilla" ]; then + mkdir -p /opt/terraria/config/ModPacks/vanilla/Mods + [ -e /opt/terraria/config/ModPacks/vanilla/Mods/enabled.json ] || printf '[]\n' > /opt/terraria/config/ModPacks/vanilla/Mods/enabled.json + fi + fi + /opt/terraria/entrypoint.sh & + pid=$! + rpid= + if [ -t 0 ]; then + exec 3<&0 + ( + cr=$(printf '\r') + as= + if [ "$(id -u)" = 0 ]; then + if command -v gosu >/dev/null 2>&1; then as="gosu"; else as="su-exec"; fi + as="$as ${PUID:-1000}:${PGID:-1000}" + fi + while :; do + while IFS= read -r line <&3; do + line=${line%"$cr"} + [ -n "$line" ] || continue + if ! { $as /usr/bin/tmux send-keys -l -- "$line" && $as /usr/bin/tmux send-keys Enter; } 2>/dev/null; then + echo "[gameplane] console not ready; input ignored" + fi + done + sleep 1 + done + ) & + rpid=$! + fi + while :; do + wait "$pid"; rc=$? + kill -0 "$pid" 2>/dev/null || break + done + [ -z "$rpid" ] || kill "$rpid" 2>/dev/null + exit "$rc" + security: fsGroup: 1000 @@ -38,7 +95,7 @@ spec: storage: size: 4Gi - mountPath: /root/.local/share/Terraria/tModLoader + mountPath: /opt/terraria/config resources: requests: @@ -84,7 +141,7 @@ spec: transport: stdin command: "save" mods: - path: Mods + path: ModPacks/vanilla/Mods extensions: [".tmod", ".zip"] install: allowedHosts: @@ -124,6 +181,6 @@ spec: default: "Welcome to Gameplane tModLoader Server" - name: MODPACK displayName: Active modpack - description: Active modpack name under ModPacks. Leave blank for none. + description: Active modpack folder name under ModPacks. Leave blank for the built-in empty "vanilla" pack. type: string default: "" diff --git a/validate.py b/validate.py index b55d477..91614c1 100644 --- a/validate.py +++ b/validate.py @@ -1026,6 +1026,31 @@ def rule_credential_fields_must_be_password(spec: dict) -> list[Finding]: return findings +def rule_template_version_matches_module(spec: dict, module_version: str | None, template_path: Path) -> list[Finding]: + """Rule 11: GameTemplate spec.version must match module.yaml version. + + Historical bug: minecraft-java template.yaml had spec.version 2.8.1 while + module.yaml was 2.9.0, causing the wizard to display an outdated version. + The template must stay in lockstep with the module's declared version. + """ + if module_version is None: + return [] + + spec_version = spec.get("version", "") + if spec_version == module_version: + return [] + + return [ + Finding( + ERROR, + "template-version-drift", + f"{template_path.name}: spec.version={spec_version!r} but module.yaml version={module_version!r} — " + "these must match exactly (template versions say 'lockstep'). Update template spec.version to " + f"{module_version!r}.", + ) + ] + + LAYOUT_ENFORCED_MODULES = { "cs2", "palworld", @@ -1103,7 +1128,7 @@ def collect_image_refs(spec: dict) -> list[str]: return out -def validate_module(spec: dict, cache: dict[str, dict]) -> list[Finding]: +def validate_module(spec: dict, cache: dict[str, dict], module_dir: Path | None = None) -> list[Finding]: findings: list[Finding] = [] image_refs = collect_image_refs(spec) if not image_refs: @@ -1129,6 +1154,19 @@ def validate_module(spec: dict, cache: dict[str, dict]) -> list[Finding]: findings += rule_mods_loaders_requires_versions(spec) findings += rule_credential_fields_must_be_password(spec) findings += rule_images_pinned(spec) + + # Check template version matches module.yaml if we have the module dir + if module_dir: + module_yaml_path = module_dir / "module.yaml" + if module_yaml_path.exists(): + try: + module_doc = yaml.safe_load(module_yaml_path.read_text()) + module_version = (module_doc or {}).get("version") + template_path = module_dir / "template.yaml" + findings += rule_template_version_matches_module(spec, module_version, template_path) + except yaml.YAMLError: + pass # module.yaml parse error already reported elsewhere if relevant + return findings @@ -1348,7 +1386,7 @@ def main(argv: list[str]) -> int: continue spec = (doc or {}).get("spec") or {} - findings = rule_directory_layout(module_dir) + validate_module(spec, cache) + findings = rule_directory_layout(module_dir) + validate_module(spec, cache, module_dir) if module_dir.name in offline_findings: findings.extend(offline_findings[module_dir.name]) print(f"== {module_dir.name} ==")