Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions nuclear-option/README.md
Original file line number Diff line number Diff line change
@@ -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 <message>` — send broadcast message to all players
- `kick-player <steam-id>`
- `banlist-add <steam-id>` — [optionally `<reason>`]
- `banlist-remove <steam-id>`
- `set-next-mission <Group> <Name> <MaxTime>` — **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
11 changes: 11 additions & 0 deletions nuclear-option/module.yaml
Original file line number Diff line number Diff line change
@@ -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
222 changes: 222 additions & 0 deletions nuclear-option/template.yaml
Original file line number Diff line number Diff line change
@@ -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<name>[^"]*)"'
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
2 changes: 1 addition & 1 deletion validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")



Expand Down
Loading