diff --git a/nuclear-option/README.md b/nuclear-option/README.md new file mode 100644 index 0000000..849829a --- /dev/null +++ b/nuclear-option/README.md @@ -0,0 +1,83 @@ +# Nuclear Option Module + +Nuclear Option is a multiplayer tactical team-based game. This module provides a dedicated server that installs and runs via SteamCMD on Kubernetes. + +## Overview + +**Game App ID**: 3930080 +**Server Binary**: NuclearOptionServer.x86_64 (Unity, x86_64 only — no arm64 support) +**Minimum Cluster Resource**: 1 CPU, 2 GiB memory, 2 GiB storage + +The server runs as non-root user `gameserver` (uid/gid 10000) for security. + +## Installation + +On first pod start, SteamCMD downloads the server (~891 MB) into the data volume. This takes a few minutes; the startup probe budgets ~20 minutes to account for network variability. + +## Configuration + +The server is configured via a JSON config file (`DedicatedServerConfig.json`) rendered from the **Settings** tab on server creation: + +| Field | Description | +|-------|-------------| +| **Server name** | Display name in server lists. | +| **Max players** | Concurrent player limit. Default observed: 16. | +| **Server password** | Password to join; leave blank for open server. | +| **Mission rotation type** | Rotation mode: 0 or 1 (exact semantics unverified; 0 appears sequential). | + +## Remote Console + +The server exposes a JSON request/response remote-command protocol on TCP port 7779 (loopback-only, no authentication). The agent connects pod-locally. + +**20 registered commands** (from spec 002-nuclear-option-ip-pool/contracts): + +*Most commonly used*: +- `get-player-list` → `{"Players": [...]}` (player fields: `steamId`, `faction`) +- `send-chat-message ` — send broadcast message to all players +- `kick-player ` +- `banlist-add ` — [optionally ``] +- `banlist-remove ` +- `set-next-mission ` — **THREE args** (e.g., `"BuiltIn"`, `"Escalation"`, `"7200.0"`) +- `get-mission` → current + next mission info +- `get-mission-rotation` → rotation config and sequence + +*Others*: `reload-config`, `get-mission-time`, `set-time-remaining`, `get-server-id`, `unkick-player`, `clear-kicked-player`, `clear-kicked-players`, `clear-next-mission`, `banlist-reload`, `banlist-clear`, `set-mission-rotation`, `update-ready`. + +**Wire Format**: +- **Request**: 4-byte LE length + JSON `{"name": "...", "arguments": [...]}` +- **Response**: 4-byte LE status + 4-byte LE body length + JSON body (if length > 0) +- **Status codes**: 2000 = success, 4003 = JSON error, 4004 = unknown command, 4005 = bad arguments, 5xxx = server errors +- **No authentication**: port is loopback-only; any pod-local client can issue commands + +**Critical caveats**: +- `kick-player`, `banlist-add`, `banlist-remove` return 2000 (success) **even for nonexistent Steam IDs**; the command does not fail or signal that the target was found. Verify outcomes by querying player lists or ban lists. +- Display names: `get-player-list` returns only `steamId` and `faction`; the dashboard hydrates display names via Steam Web API on the API server side. + +## Ports + +| Port | Protocol | Purpose | Advertised | Notes | +|------|----------|---------|-----------|-------| +| 7778 | UDP | Game traffic (inferred) | Yes | Port binding observed during testing; purpose not verified | +| 7779 | TCP | Remote commands | No | Loopback-only (127.0.0.1); pod-local console access only | + +**Game Join Port (UDP 7777)**: The server advertises via Steam (SteamGameServer.LogOnAnonymous + "Set Advertise Server: True" in logs) rather than opening a local UDP 7777 port. No dedicated local listen port for game discovery was observed during testing. Players likely join through Steam's game-server routing rather than a raw local port. + +## Logs and Persistence + +- **Game logs**: stdout (view via `kubectl logs`) +- **Config file**: `/data/DedicatedServerConfig.json` (rendered on every pod start from template) +- **Mission files**: `/data/NuclearOption-Missions/` (persisted across restarts; custom missions placed here) +- **Ban list**: `/data/ban_list.txt` (persisted, managed by the server) + +Backups capture the entire `/data` volume, so game config, world state, ban list, and logs all persist. + +## Known Limitations + +- **No dedicated join port detected**: Live testing on 2026-08-22 found UDP 7778 bound (query port) but **UDP 7777 was NOT observed listening**. The server advertises via Steam (`SteamGameServer.LogOnAnonymous` + `Set Advertise Server: True`), suggesting players join through Steam's game-server routing rather than a raw local UDP port. Join functionality has not been end-to-end verified with an actual client; treat as exploratory. +- **No arm64 support**: The binary is x86_64 only (Linux native, no Proton layer). The module does not run on arm64 clusters. +- **Player list shape partially unverified**: The official protocol spec documents `steamId` and `faction` fields, but this was not confirmed with a live player connected. Display names are not available on the wire; they are hydrated server-side via Steam Web API. + +## Version + +- **Module version**: 1.0.0 +- **Requires Gameplane**: ≥ 0.2.0-beta.7 diff --git a/nuclear-option/module.yaml b/nuclear-option/module.yaml new file mode 100644 index 0000000..5daf788 --- /dev/null +++ b/nuclear-option/module.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../.schema/module.schema.json +apiVersion: gameplane.local/module/v1 +name: nuclear-option +displayName: Nuclear Option +version: 1.0.0 +game: nuclear-option +categories: [Simulation, PvP] +summary: Nuclear Option dedicated server with remote-command console, runtime-downloaded via SteamCMD. +homepage: https://www.nuclearoption.com/ +license: MIT +gameplaneMinVersion: 0.2.0-beta.7 diff --git a/nuclear-option/template.yaml b/nuclear-option/template.yaml new file mode 100644 index 0000000..55ebfa6 --- /dev/null +++ b/nuclear-option/template.yaml @@ -0,0 +1,222 @@ +# yaml-language-server: $schema=../.schema/gametemplate.schema.json +# Gameplane GameTemplate for Nuclear Option (dedicated server). +# +# Backed by a custom image shipping SteamCMD and entrypoint scripts. The game +# server itself is installed/updated by steamcmd on boot (UPDATE_ON_BOOT), so +# the FIRST start downloads ~900 MB — the startup probe budgets for that, and +# the install output streams to the dashboard's Logs tab (source "pod"). +# +# The image runs as non-root user gameserver (uid/gid 10000), so +# spec.security.runAsUser is required and must match numerically. fsGroup +# is also set so the non-root user can write the install into the mounted volume. +# +# Remote console is via the server's JSON-RPC-like remote-command protocol on +# TCP 7779 (loopback-only, not exposed). The protocol supports commands to list +# players, kick, ban, and set the next mission (note: set-next-mission takes +# three arguments: Group, Name, MaxTime). +# +# Cluster-scoped; apply once per cluster: +# kubectl apply -f modules/nuclear-option/template.yaml + +apiVersion: gameplane.local/v1alpha1 +kind: GameTemplate +metadata: + name: nuclear-option + labels: + gameplane.local/module: nuclear-option +spec: + displayName: Nuclear Option + game: nuclear-option + # Kept in lockstep with module.yaml's bundle version. + version: 1.0.0 + categories: [Simulation, PvP] + # Brand color for the dashboard. + accentColor: "#ff6b6b" + description: | + Nuclear Option dedicated server with multiplayer tactical gameplay. + The server installs via steamcmd on first boot (~900 MB — give it a few + minutes). Remote console via the JSON-RPC-like remote-command protocol, + a graceful stop sequence, and live player list. + + # Digest-pinned image; no version catalog needed for this game. + image: ghcr.io/valgulnecron/gameplane/nuclear-option@sha256:1be11d39fa12ba9a90b9a79b6c5c26e5abcaf51cab2453e2029d3ee95262e044 + + # REQUIRED. The image runs as non-root (uid/gid 10000), so spec.security.runAsUser + # must be set to match numerically. fsGroup ensures the non-root user can write + # to the mounted data volume — on first boot, SteamCMD installs the server into + # that volume, and without fsGroup the kubelet would leave it root-owned and + # unwritable to the dropped-privilege user. + security: + runAsUser: 10000 + runAsGroup: 10000 + fsGroup: 10000 + + ports: + - name: game + containerPort: 7778 + protocol: UDP + advertise: true + - name: rcon + containerPort: 7779 + protocol: TCP + # TCP 7779 binds to 127.0.0.1 only (loopback); never advertise it. + # The agent reaches it pod-local via the shared pod network namespace. + advertise: false + + # The whole server install lives on the data volume (steamcmd target dir), + # so game files, config, logs, and mission files all persist together. + # Storage size: 891 MB install + ~100 MB logs + mission files + ban list. + storage: + size: 2Gi + mountPath: /data + + resources: + # CPU and memory are unmeasured under load; these are reasonable game-server + # defaults. CPU mirrors similar-sized game servers (1-4 range); memory is + # conservative until telemetry from live servers informs a better estimate. + requests: + cpu: "1" + memory: 2Gi + limits: + cpu: "4" + memory: 8Gi + + rcon: + protocol: nuclearoption + port: 7779 + # No password authentication on the remote-command port; the server + # binds loopback-only (127.0.0.1:7779) so access is pod-local only. + + # Field names match the DedicatedServerConfig.json structure exactly. + configSchema: + - name: SERVER_NAME + displayName: Server name + type: string + default: "A Gameplane Nuclear Option server" + required: true + - name: SERVER_PASSWORD + displayName: Server password + description: Required to join. Leave blank for an open server. + type: password + default: "" + - name: MAX_PLAYERS + displayName: Max players + description: Maximum concurrent players. Default observed is 16 (server-enforced limit not verified). + type: int + default: "16" + min: 1 + max: 32 + - name: MISSION_ROTATION + displayName: Mission rotation type + description: | + Rotation mode: 0 or 1 (exact semantics not verified in official docs; 0 appears to be sequential). + type: enum + enum: ["0", "1"] + default: "0" + + # Render the config file from the resolved config values. + configFiles: + - path: DedicatedServerConfig.json + template: | + { + "MissionDirectory": "/data/NuclearOption-Missions", + "ModdedServer": false, + "Hidden": false, + "ServerName": {{printf "%q" .Values.SERVER_NAME}}, + "Port": { "IsOverride": false, "Value": 0 }, + "QueryPort": { "IsOverride": false, "Value": 0 }, + "Password": {{printf "%q" .Values.SERVER_PASSWORD}}, + "MaxPlayers": {{ .Values.MAX_PLAYERS }}, + "BanListPaths": ["ban_list.txt"], + "DisableErrorKick": false, + "ErrorKickImmuneListPaths": [], + "NoPlayerStopTime": 30.0, + "PostMissionDelay": 30.0, + "RotationType": {{ .Values.MISSION_ROTATION }}, + "MissionRotation": [ + { "Key": { "Group": "BuiltIn", "Name": "Escalation" }, "MaxTime": 7200.0 }, + { "Key": { "Group": "BuiltIn", "Name": "Terminal Control" }, "MaxTime": 7200.0 } + ], + "VoteKick": { + "Enabled": true, + "PassRatio": 0.6, + "MinVotes": 3, + "AutoBanThreshold": 3, + "VoteDuration": 45.0, + "ResolutionDisplayTime": 20.0, + "NewVoteLockout": 10.0, + "RequesterCooldown": 300.0 + } + } + + # Tell the entrypoint where to find the config file. + env: + - name: UPDATE_ON_BOOT + value: "true" + - name: REMOTE_COMMANDS_ENABLED + value: "true" + - name: DEDICATED_SERVER_CONFIG + value: /data/DedicatedServerConfig.json + + capabilities: + players: + # The remote-command protocol returns {"Players": [...]} on get-player-list, + # with per-entry fields {"steamId": "...", "faction": "..."}. + # Capture Steam IDs; display names are hydrated server-side via Steam Web API. + list: + command: "get-player-list" + entryRegex: '"steamId":\s*"(?P[^"]*)"' + kick: + command: "kick-player {{.Player}}" + ban: + command: "banlist-add {{.Player}}" + unban: + command: "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. + + lifecycle: + # The server's 20 registered commands do not include a shutdown/stop/quit/exit + # command. The image runs the game as PID 1 so SIGTERM reaches it directly; + # rely on the graceful shutdown sequence built into the game itself. + + actions: + - id: broadcast + displayName: Broadcast message + description: Send a message to all players. + icon: megaphone + group: Server + command: "send-chat-message {{.Params.message}}" + params: + - name: message + displayName: Message + type: string + required: true + + probes: + # First boot = ~900 MB steamcmd download before the server reaches readiness, + # so the startup probe budgets ~20 minutes. The remote-command port (TCP 7779) + # is the only reliable liveness signal (game traffic is UDP). + # + # CRITICAL: tcpSocket probes cannot be used because the server binds + # 127.0.0.1:7779 only (loopback). Kubelet runs tcpSocket probes from the + # node against the pod IP, not from inside the pod, so the probe would + # always fail and the container would be killed before reaching Ready. + # Use exec probes from inside the pod instead. + startup: + exec: + command: ["timeout", "2", "nc", "-z", "127.0.0.1", "7779"] + initialDelaySeconds: 30 + periodSeconds: 15 + failureThreshold: 80 + readiness: + exec: + command: ["timeout", "2", "nc", "-z", "127.0.0.1", "7779"] + periodSeconds: 10 + failureThreshold: 6 + liveness: + exec: + command: ["timeout", "2", "nc", "-z", "127.0.0.1", "7779"] + periodSeconds: 30 + failureThreshold: 5 diff --git a/validate.py b/validate.py index 28c2e22..8aebf52 100644 --- a/validate.py +++ b/validate.py @@ -126,7 +126,7 @@ # the GameTemplate CRD's rcon.protocol enum and agent/internal/rcon/. A protocol # listed here but not implemented lets a module ship a console that never # connects, so this list is deliberately conservative. -RCON_PROTOCOLS = ("source", "telnet", "websocket", "battleye", "satisfactory", "palworld", "none") +RCON_PROTOCOLS = ("source", "telnet", "websocket", "battleye", "satisfactory", "palworld", "nuclearoption", "none")