mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): add Steam transport foundation
This commit is contained in:
@@ -22,5 +22,9 @@ training/build/
|
||||
# --export-release "Linux Dedicated Server"`.
|
||||
server/build/
|
||||
|
||||
# Steam exports and local App ID configuration are developer-machine inputs.
|
||||
steam/build/
|
||||
steam_appid.txt
|
||||
|
||||
# Texture generator scripts: throwaway env, not the scripts themselves.
|
||||
tools/textures/.venv/
|
||||
|
||||
@@ -55,3 +55,61 @@ texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.2]
|
||||
|
||||
name="Linux Steam Client"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features="steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteam.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.2.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.3]
|
||||
|
||||
name="Linux Steam Dedicated Server"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=true
|
||||
custom_features="dedicated_server,steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteamServer.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.3.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=false
|
||||
texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name EnetTransport
|
||||
extends NetTransport
|
||||
|
||||
func transport_id() -> String:
|
||||
return "enet"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func create_server(port: int, max_clients: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, port: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
@@ -0,0 +1,26 @@
|
||||
class_name NetTransport
|
||||
extends RefCounted
|
||||
|
||||
# Narrow construction boundary for Godot's MultiplayerPeer implementations.
|
||||
# NetworkManager owns polling, RPC policy, and lifecycle; a transport only
|
||||
# creates a peer. Keeping that split means ENet remains a first-class path
|
||||
# while Steam can use SDR without duplicating the rest of the networking code.
|
||||
|
||||
func transport_id() -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return "transport is unavailable"
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
|
||||
|
||||
func create_client(_address: String, _port: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
|
||||
# transport: hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
|
||||
# hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
||||
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
||||
#
|
||||
@@ -52,6 +52,11 @@ signal shutting_down()
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_CLIENTS := 32
|
||||
const TRANSPORT_ENET := "enet"
|
||||
const TRANSPORT_STEAM := "steam"
|
||||
|
||||
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
|
||||
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
|
||||
|
||||
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
||||
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
||||
@@ -64,7 +69,8 @@ const CLOCK_WINDOW_SEC := 5.0
|
||||
|
||||
var is_server := false
|
||||
var is_client := false
|
||||
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var active_transport := ""
|
||||
|
||||
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
||||
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
||||
@@ -121,31 +127,46 @@ func poll() -> void:
|
||||
multiplayer.poll()
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
|
||||
func available_transports() -> PackedStringArray:
|
||||
var transports := PackedStringArray([TRANSPORT_ENET])
|
||||
if SteamTransportScript.new().is_available():
|
||||
transports.append(TRANSPORT_STEAM)
|
||||
return transports
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_server(port, max_clients)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
|
||||
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = true
|
||||
is_client = false
|
||||
return OK
|
||||
|
||||
|
||||
func join(address: String, port: int = DEFAULT_PORT) -> Error:
|
||||
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_client(address, port)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
|
||||
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = false
|
||||
is_client = true
|
||||
return OK
|
||||
@@ -164,6 +185,7 @@ func shutdown() -> void:
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
_peer = null
|
||||
active_transport = ""
|
||||
is_server = false
|
||||
is_client = false
|
||||
rtt_ms = -1.0
|
||||
@@ -174,6 +196,17 @@ func shutdown() -> void:
|
||||
_last_raw_rtt_ms = -1.0
|
||||
|
||||
|
||||
func _make_transport(transport: String) -> NetTransport:
|
||||
match transport:
|
||||
TRANSPORT_ENET:
|
||||
return EnetTransportScript.new()
|
||||
TRANSPORT_STEAM:
|
||||
return SteamTransportScript.new()
|
||||
_:
|
||||
push_error("NetworkManager: unknown transport '%s'" % transport)
|
||||
return null
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _ping(client_send_ms: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class_name SteamBootstrap
|
||||
extends RefCounted
|
||||
|
||||
# Spacewar is Valve's development App ID. It is intentionally a development
|
||||
# default, never a public-server identity or discovery configuration.
|
||||
const SPACEWAR_APP_ID := 480
|
||||
const APP_ID_ENV := "COSMIC_CLASH_STEAM_APP_ID"
|
||||
|
||||
|
||||
static func app_id() -> int:
|
||||
var configured := OS.get_environment(APP_ID_ENV).strip_edges()
|
||||
if configured.is_valid_int() and int(configured) > 0:
|
||||
return int(configured)
|
||||
return SPACEWAR_APP_ID
|
||||
|
||||
|
||||
static func is_runtime_available() -> bool:
|
||||
return OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer") and Engine.has_singleton("Steam")
|
||||
|
||||
|
||||
static func unavailable_reason() -> String:
|
||||
if not OS.has_feature("steam"):
|
||||
return "this export was not built with the steam feature"
|
||||
if not ClassDB.class_exists("SteamMultiplayerPeer"):
|
||||
return "SteamMultiplayerPeer is missing from this custom Godot build"
|
||||
if not Engine.has_singleton("Steam"):
|
||||
return "the GodotSteam Steam singleton is missing from this custom Godot build"
|
||||
return "Steam is unavailable"
|
||||
|
||||
|
||||
static func initialize() -> Dictionary:
|
||||
if not is_runtime_available():
|
||||
return {"error": ERR_UNAVAILABLE, "reason": unavailable_reason()}
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
# `steamInit` is deliberately called dynamically: stock Godot must be able
|
||||
# to parse and run this project without GodotSteam symbols installed.
|
||||
var result = steam.call("steamInit")
|
||||
if result is bool and result:
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
if result is Dictionary and bool(result.get("status", false)):
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name SteamTransport
|
||||
extends NetTransport
|
||||
|
||||
const SteamBootstrapScript = preload("res://scripts/steam_bootstrap.gd")
|
||||
|
||||
# The SteamMultiplayerPeer extension is looked up dynamically so a stock ENet
|
||||
# build never references an unavailable native class while parsing scripts.
|
||||
const VIRTUAL_PORT := 0
|
||||
|
||||
func transport_id() -> String:
|
||||
return "steam"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return SteamBootstrapScript.is_runtime_available()
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return SteamBootstrapScript.unavailable_reason()
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_host", VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, _port: int) -> Dictionary:
|
||||
var steam_id_text := address.strip_edges()
|
||||
if not steam_id_text.is_valid_int() or int(steam_id_text) <= 0:
|
||||
return {"error": ERR_INVALID_PARAMETER, "reason": "Steam transport requires the server's numeric Steam ID"}
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_client", int(steam_id_text), VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
@@ -0,0 +1,26 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const EnetTransport = preload("res://scripts/enet_transport.gd")
|
||||
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
|
||||
const SteamTransport = preload("res://scripts/steam_transport.gd")
|
||||
|
||||
func test_enet_is_available_without_steam() -> void:
|
||||
var transport = EnetTransport.new()
|
||||
assert_true(transport.is_available(), "ENet remains available in a stock Godot build")
|
||||
assert_eq(transport.transport_id(), "enet", "stable selection key")
|
||||
|
||||
|
||||
func test_steam_development_app_id_has_a_safe_default() -> void:
|
||||
assert_eq(SteamBootstrap.app_id(), SteamBootstrap.SPACEWAR_APP_ID, "Spacewar is the local-development default")
|
||||
assert_true(SteamBootstrap.app_id() > 0, "Steam bootstrap never uses an invalid app ID")
|
||||
|
||||
|
||||
func test_stock_build_refuses_steam_without_falling_back_to_enet() -> void:
|
||||
var transport = SteamTransport.new()
|
||||
if transport.is_available():
|
||||
assert_true(true, "a custom Steam build is validated by the separate Steam export check")
|
||||
return
|
||||
var result: Dictionary = transport.create_server(7777, 2)
|
||||
assert_eq(int(result.error), ERR_UNAVAILABLE, "Steam request fails explicitly when its custom build is absent")
|
||||
assert_true(not result.has("peer") or result.peer == null, "an unavailable Steam request never returns an ENet peer")
|
||||
assert_true(not transport.unavailable_reason().is_empty(), "failure tells an operator what is missing")
|
||||
@@ -0,0 +1,20 @@
|
||||
extends Node
|
||||
|
||||
# This is intentionally separate from the stock test suite: it is run only
|
||||
# by scripts/verify_steam_templates.sh, where failing to supply a custom Steam
|
||||
# executable is a setup failure rather than a regression in the ENet build.
|
||||
func _ready() -> void:
|
||||
var failures: Array[String] = []
|
||||
if not OS.has_feature("steam"):
|
||||
failures.append("custom export is missing the steam feature")
|
||||
if not ClassDB.class_exists("SteamMultiplayerPeer"):
|
||||
failures.append("SteamMultiplayerPeer is missing")
|
||||
if not Engine.has_singleton("Steam") and not Engine.has_singleton("SteamServer"):
|
||||
failures.append("neither Steam nor SteamServer singleton is available")
|
||||
if failures.is_empty():
|
||||
print("STEAM TEMPLATE SMOKE PASS")
|
||||
get_tree().quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
printerr("STEAM TEMPLATE SMOKE FAIL: %s" % failure)
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/steam_template_smoke.gd" id="1"]
|
||||
|
||||
[node name="SteamTemplateSmoke" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -1,4 +1,7 @@
|
||||
.PHONY: verify-phase6
|
||||
.PHONY: verify-phase6 verify-steam-templates
|
||||
|
||||
verify-phase6:
|
||||
bash scripts/verify_phase6.sh
|
||||
|
||||
verify-steam-templates:
|
||||
bash scripts/verify_steam_templates.sh
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Steam development setup
|
||||
|
||||
Steam support is optional. The default Godot build and every Phase 6 Docker
|
||||
check use ENet; they do not need a Steam client, SDK, or App ID. Selecting
|
||||
`transport="steam"` never falls back to ENet: a missing custom build or failed
|
||||
Steam initialization returns an error with the missing prerequisite.
|
||||
|
||||
## Pinned inputs
|
||||
|
||||
The exact expected build inputs live in
|
||||
[`steam-dependencies.lock.json`](steam-dependencies.lock.json). They are not
|
||||
committed to this repository because the Steamworks SDK is governed by Valve's
|
||||
partner access and the engine binaries are platform-specific. Use the matching
|
||||
GodotSteam client build, server build, and `SteamMultiplayerPeer` extension
|
||||
from that lock file; do not mix release families.
|
||||
|
||||
Place the resulting custom executables/templates outside this checkout and set:
|
||||
|
||||
```bash
|
||||
export COSMIC_CLASH_STEAM_CLIENT_GODOT=/absolute/path/to/godotsteam
|
||||
export COSMIC_CLASH_STEAM_SERVER_GODOT=/absolute/path/to/godotsteam-server
|
||||
make verify-steam-templates
|
||||
```
|
||||
|
||||
The command imports the project with each custom build, checks the `steam`
|
||||
feature plus the `Steam`/`SteamServer` singleton and `SteamMultiplayerPeer`,
|
||||
then exports `Linux Steam Client` and `Linux Steam Dedicated Server`. It
|
||||
refuses to use a normal Godot binary, so a green result proves the expected
|
||||
native pieces are in the supplied builds. It writes only ignored `steam/build/`
|
||||
artifacts.
|
||||
|
||||
## App IDs and scope
|
||||
|
||||
The local-development default is Valve's Spacewar App ID **480**. To use a
|
||||
different development App ID, set `COSMIC_CLASH_STEAM_APP_ID` to a positive
|
||||
integer and place `steam_appid.txt` beside the executable (never commit that
|
||||
file). Spacewar is only for local bootstrap/transport tests: it must not be
|
||||
used to advertise servers, validate ownership/VAC, or ship.
|
||||
|
||||
A project-owned Steamworks App ID and its server credentials are required
|
||||
before Phase 7 server browser, `BeginAuthSession`, identity-backed slot
|
||||
reclaim, bans, or public hosting. Until then, Phase 6's external test is
|
||||
controlled-only because display-name slot reclaim is insecure.
|
||||
|
||||
## Transport contract
|
||||
|
||||
`NetworkManager.host()` and `NetworkManager.join()` default to `"enet"`.
|
||||
Passing `"steam"` explicitly creates a `SteamMultiplayerPeer` over SDR; for
|
||||
this foundation the join address is the server's numeric Steam ID and the
|
||||
virtual port is zero. Discovery and server advertisement intentionally remain
|
||||
unimplemented until the project-owned App ID exists.
|
||||
@@ -19,9 +19,9 @@ The largest gap between this and a AAA-feeling product is presentation, not code
|
||||
|
||||
## Multiplayer (long term)
|
||||
|
||||
Planned in **[`multiplayer-todo.md`](multiplayer-todo.md)** — architecture decisions (server-authoritative dedicated servers, client-side prediction, ENet then Steam), wire format, latency budget, and an eight-phase task breakdown. Nothing implemented yet.
|
||||
Tracked in **[`multiplayer-todo.md`](multiplayer-todo.md)** — server-authoritative multiplayer, prediction, ENet dedicated hosting, and the Phase 6 exported-server Docker/CI verification are implemented. The remaining gates are a human latency playtest, a real 3v3 session, a controlled external-host run, and Phase 7 Steam identity/browser work.
|
||||
|
||||
Phase 0 of that plan is a set of non-networked refactors that land independently and are verifiable in single-player today; start there. It now also carries the **graphics/performance work** — the project has never been profiled, and `video_settings.gd` exposes only AA, glow and brightness while SDFGI, SSIL, SSAO and five shadow-casting lights are on by default and unreachable (see §5.5 there).
|
||||
Phase 7 begins with optional GodotSteam bootstrap and a transport boundary; direct-IP ENet remains fully supported. It also carries the **graphics/performance work** — the project has never been profiled, and `video_settings.gd` exposes only AA, glow and brightness while SDFGI, SSIL, SSAO and five shadow-casting lights are on by default and unreachable (see §5.5 there).
|
||||
|
||||
**Tasks 0.1–0.15, 0.18–0.25, 0.27, 0.29 are done** (see the Phase 0 table in `multiplayer-todo.md` for what each one actually changed — several deviated from the original plan for concrete GDScript/Godot reasons recorded inline). Remaining, all blocked on **0.15b (profile, on reference hardware, in the live editor — not done)**: 0.16 (camera to `_process`), 0.17/0.17b/0.17c/0.17d (graphics presets, vsync, resolution scaling), **0.26 (bake the arena GI to retire SDFGI — the largest frame-time win available, costs no image quality since the arena is fully static)**, and 0.28 (physics separate-thread prototype, flagged as the riskiest task in the phase). These need a human at the editor with real hardware to profile and eyeball, not further code changes.
|
||||
|
||||
|
||||
+13
-13
@@ -4,7 +4,7 @@ Working document for the online multiplayer effort. `TODO.md` points here.
|
||||
|
||||
Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
|
||||
|
||||
**Status: every task in Phases 0–5 is implemented and verified. Both milestones' remaining work is verification a machine cannot do — a human playtest at ~100 ms RTT (Phase 4) and a real 3v3 session (Phase 5). Phases 6 and 7 are unstarted.** The client has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. See the outstanding list immediately below for what is left and why, §7 for the implemented work and its evidence, and §11 for what is deliberately deferred.
|
||||
**Status: every task in Phases 0–6 is implemented and verified locally.** Both multiplayer milestones still need human verification — a playtest at ~100 ms RTT (Phase 4) and a real 3v3 session (Phase 5). Phase 6's public-internet gate is deliberately blocked by the display-name reclaim defect until Phase 7 identity work lands; its export, Docker, rotation/drain, and CI work are complete. Phase 7's Steam foundation is in progress. The client has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. See the outstanding list immediately below for what is left and why, §7 for the implemented work and its evidence, and §11 for what is deliberately deferred.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,10 +39,10 @@ C is the one to plan around: it is fixed for free by task **7.4** (Steam auth ti
|
||||
|
||||
### Unstarted phases
|
||||
|
||||
- **Phase 6 — dedicated server productionisation** (7 tasks): export preset, CLI surface, structured logging, arena rotation, systemd/Docker/`SERVER.md`, CI against the *exported binary*. Gate: `docker run` a server, connect from another machine over the internet, play a full match.
|
||||
- **Phase 7 — Steam transport, browser, identity** (5 tasks): GodotSteam, the `NetTransport` boundary extracted from two working implementations, server browser, auth tickets and ban list, feature-gating so ENet direct-connect never becomes the degraded path. Carries the fix for **C**.
|
||||
- **Phase 6 external gate:** run the exported Docker server and clients from separate machines over the internet, then play a full match. This is a controlled test only until **C** is fixed.
|
||||
- **Phase 7 — Steam transport, browser, identity** (5 tasks): the optional bootstrap and `NetTransport` foundation now exist, but custom Steam client/server templates have not yet been supplied. Browser, auth tickets, and bans await a project-owned Steamworks App ID. Carries the fix for **C**.
|
||||
|
||||
Phase 6 has no dependency on Phase 7 and is the natural next block of work: it is what turns a thing that runs in two terminals into a thing someone else can host.
|
||||
Phase 6 has no dependency on Phase 7 and now turns a two-terminal game into something another person can host. Phase 7 is the next block because Steam identity is required before public exposure.
|
||||
|
||||
### Deferred by choice, not forgotten
|
||||
|
||||
@@ -1098,13 +1098,13 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 6.1 `[P]` | Export preset (`dedicated_server=true`, `custom_features="dedicated_server"`) and `run/main_scene.dedicated_server`, mirroring the existing `run/main_scene.training` mechanism | Preset builds |
|
||||
| 6.2 `[D:6.1]` | **Verify the stripped export boots and scores a goal** | Exported binary runs a full match headless |
|
||||
| 6.3 `[P]` | Full CLI surface plus a config-file fallback | `--help` documents every flag |
|
||||
| 6.4 `[P]` | Structured logging (join, leave, goal, kick, rate-limit, tick overrun) with `--log-level` | Logs are greppable and rotate sanely |
|
||||
| 6.5 `[P]` | Arena rotation between matches; `--max-matches N` drain-and-exit | Server cycles arenas and exits cleanly after N |
|
||||
| 6.6 `[P]` | systemd unit, Dockerfile, `SERVER.md` (ports, firewall, sizing per §1.4, and the SIGTERM caveat) | A third party can host from the docs alone |
|
||||
| 6.7 `[D:3.6]` `[P]` | CI builds the server export and runs the smoke test against the **exported binary**, not source | Green on a clean checkout |
|
||||
| 6.1 `[P]` | **DONE.** Export preset (`dedicated_server=true`, `custom_features="dedicated_server"`) and `run/main_scene.dedicated_server`, mirroring the existing `run/main_scene.training` mechanism | `Linux Dedicated Server` builds |
|
||||
| 6.2 `[D:6.1]` | **DONE.** Verify the stripped export boots and scores a goal | Docker smoke runs two exported-server matches and observes server-owned goals from two headless clients |
|
||||
| 6.3 `[P]` | **DONE.** Full CLI surface plus a config-file fallback | Unit tests cover precedence, validation, and `--help` |
|
||||
| 6.4 `[P]` | **DONE.** Structured logging (join, leave, goal, kick, rate-limit, tick overrun) with `--log-level` | Greppable stdout/stderr events exercised in the smoke |
|
||||
| 6.5 `[P]` | **DONE.** Arena rotation between matches; `--max-matches N` drain-and-exit | Smoke asserts two different arenas and `server_draining` |
|
||||
| 6.6 `[P]` | **DONE.** systemd unit, Dockerfile, `SERVER.md` (ports, firewall, sizing per §1.4, and the SIGTERM caveat) | A third party can host from the docs alone |
|
||||
| 6.7 `[D:3.6]` `[P]` | **DONE.** CI builds the server export and runs the smoke test against the **exported binary**, not source | `.github/workflows/phase6.yml` runs `make verify-phase6` on clean checkout |
|
||||
|
||||
> `dedicated_server=true` enables Godot's strip-visuals export mode, which replaces meshes and textures with placeholders per resource. Every relevant site is already headless-guarded — `ship.gd:167`, `ball.gd:25`, `goal.gd`, `arena_boundary.gd` — so the code should be safe. **Verify it against a real stripped build anyway**; this is the kind of thing that fails silently.
|
||||
|
||||
@@ -1120,8 +1120,8 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 7.1 `[D:1.2]` | GodotSteam integration and custom export templates — **client *and* headless server** | Both templates build and run |
|
||||
| 7.2 `[D:7.1]` | Extract a `NetTransport` boundary **now**, concretely, from two working implementations; add `steam_transport.gd` (`SteamMultiplayerPeer`, SDR, `advertise()` via `ISteamGameServer`) | Transport swap is one line in `NetworkManager` |
|
||||
| 7.1 `[D:1.2]` | **IN PROGRESS.** GodotSteam integration and custom export templates — **client *and* headless server** | Pinned build inputs and the reproducible validation command are documented; awaiting the custom binaries/SDK access |
|
||||
| 7.2 `[D:7.1]` | **IN PROGRESS.** `NetTransport` boundary extracted with ENet and feature-gated `steam_transport.gd` (`SteamMultiplayerPeer`, SDR); advertising waits for `ISteamGameServer` work | `NetworkManager.host/join(..., transport)` selects explicitly; stock builds reject Steam without ENet fallback |
|
||||
| 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate |
|
||||
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side |
|
||||
| 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional |
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
client_godot="${COSMIC_CLASH_STEAM_CLIENT_GODOT:?set COSMIC_CLASH_STEAM_CLIENT_GODOT to the pinned GodotSteam client executable}"
|
||||
server_godot="${COSMIC_CLASH_STEAM_SERVER_GODOT:?set COSMIC_CLASH_STEAM_SERVER_GODOT to the pinned GodotSteam server executable}"
|
||||
output_dir="$root_dir/steam/build"
|
||||
|
||||
for executable in "$client_godot" "$server_godot"; do
|
||||
test -x "$executable"
|
||||
"$executable" --headless --path "$root_dir/Game" --editor --import --quit
|
||||
"$executable" --headless --path "$root_dir/Game" res://tests/steam_template_smoke.tscn
|
||||
done
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
"$client_godot" --headless --path "$root_dir/Game" --export-release "Linux Steam Client" "$output_dir/CosmicClashSteam.x86_64"
|
||||
"$server_godot" --headless --path "$root_dir/Game" --export-release "Linux Steam Dedicated Server" "$output_dir/CosmicClashSteamServer.x86_64"
|
||||
echo "Steam template verification passed: $output_dir"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"godot": "4.7.1-stable",
|
||||
"steamworks_sdk": "1.64",
|
||||
"godotsteam_client": {
|
||||
"version": "4.20.1",
|
||||
"source": "https://github.com/GodotSteam/GodotSteam/releases/tag/v4.20.1"
|
||||
},
|
||||
"godotsteam_server": {
|
||||
"version": "4.9.3",
|
||||
"release_tag": "v4.8.1",
|
||||
"source": "https://github.com/GodotSteam/GodotSteam-Server/releases/tag/v4.8.1"
|
||||
},
|
||||
"steam_multiplayer_peer": {
|
||||
"version": "0.2.5",
|
||||
"source": "https://github.com/expressobits/steam-multiplayer-peer/releases/tag/0.2.5",
|
||||
"required_class": "SteamMultiplayerPeer"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user