From bdb3c8f4a78c8a216c562ca381e011d8b053dbba Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:03:43 +0100 Subject: [PATCH] fix(multiplayer): gate allocated matches on full roster --- Game/scripts/server_boot.gd | 10 ++++++++++ Game/tests/cases/test_server_config.gd | 7 +++++++ multiplayer-next.md | 2 ++ 3 files changed, 19 insertions(+) diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index 473ba0fe..cc034b9d 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -83,6 +83,10 @@ func _ready() -> void: get_tree().quit(1) return _control = ServerControlScript.new() + # An allocated process owns exactly the roster issued for this match. + # Never let the general-purpose direct-server default (one player) start + # an allocated match with only a partial assignment admitted. + config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players"))) _control.name = "ServerControl" _control.drain_requested.connect(_on_drain_requested) get_tree().root.add_child.call_deferred(_control) @@ -180,3 +184,9 @@ func _on_drain_requested() -> void: _drain_requested = true MatchNet.admissions_open = false ServerLog.info("server_draining", {"reason": "control_request"}) + + +static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int: + if allocated and roster_size > 0: + return roster_size + return configured diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index 5847816d..bc2862c9 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -157,3 +157,10 @@ func test_allocated_mode_rejects_missing_or_expired_assignment_manifest_fields() assert_true(not missing.is_valid(), "client build and expiry are required") var expired = _parse(["--allocated-mode", "--match-id=m", "--server-id=s", "--playlist-version=v", "--client-build=client", "--assignment-expiry-unix=1", "--server-image-digest=sha256:" + "a".repeat(64), "--transport=enet", "--region=EU"]) assert_true(not expired.is_valid(), "expired assignment is rejected") + + +func test_allocated_start_floor_is_the_verified_roster_size() -> void: + var boot = preload("res://scripts/server_boot.gd") + assert_eq(boot.required_min_players(true, 6, 1), 6, "allocated six-player roster cannot start with one player") + assert_eq(boot.required_min_players(true, 2, 6), 2, "allocated casual roster uses its complete size") + assert_eq(boot.required_min_players(false, 1, 1), 1, "direct server keeps its configured floor") diff --git a/multiplayer-next.md b/multiplayer-next.md index c31a30da..f954c953 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1396,3 +1396,5 @@ Bounded, but not by much: the attacker must race a genuine disconnect, and they The current working implementation now wires `deploy/k8s/base/fleet.yaml` to the digest-pinned `game-server` supervisor target, the in-cluster control-plane Service, workload roster materialization, signing/drain secret references, downward-API server/image identity, and the required game-server egress policy. `kubectl kustomize deploy/k8s/base` and `server/security/test_fleet_manifests.py` pass. The older 8.28 narrative above still records the pre-wiring state; live Agones, operator secret/image replacement, and real cluster readiness remain explicit gates. The NA overlay now also patches the allocated child’s `--region=NA` argument, keeping it aligned with the NA Fleet label; rendered EU and NA overlays and the adversarial manifest test verify that regional assignment validation cannot silently remain EU in the NA deployment. + +Allocated Godot startup now derives its `min-players` floor from the verified signed roster size, preventing the direct-server default of one player from starting a partially admitted allocated match. A focused regression test covers six-player, casual two-player, and direct-server behavior; the full Godot harness is currently unavailable because Godot cannot open its shared `user://` log and crashes in the macOS renderer before test execution.