mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 22:42:01 +00:00
feat(multiplayer): enforce allocated initial connect policy
This commit is contained in:
@@ -311,6 +311,10 @@ var _late_joiners: Array[Dictionary] = []
|
|||||||
# to this scene; static because the loop cannot hold a reference to a node that
|
# to this scene; static because the loop cannot hold a reference to a node that
|
||||||
# does not exist yet, and consumed on read so it cannot leak into a later match.
|
# does not exist yet, and consumed on read so it cannot leak into a later match.
|
||||||
static var server_arena_override := ""
|
static var server_arena_override := ""
|
||||||
|
# Set only by ServerMatchLoop after an allocated casual match passes the
|
||||||
|
# initial-connect policy. It is consumed once while building the authoritative
|
||||||
|
# six-slot lineup, so direct servers and ranked allocations cannot add bots.
|
||||||
|
static var server_bot_fill_override := false
|
||||||
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
|
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
|
||||||
# entirely, negative means unlimited.
|
# entirely, negative means unlimited.
|
||||||
var _max_spectators := -1
|
var _max_spectators := -1
|
||||||
@@ -451,6 +455,23 @@ func _start_server() -> void:
|
|||||||
var teams := PackedInt32Array()
|
var teams := PackedInt32Array()
|
||||||
var spawn_indices := PackedInt32Array()
|
var spawn_indices := PackedInt32Array()
|
||||||
var team_counts := {0: 0, 1: 0}
|
var team_counts := {0: 0, 1: 0}
|
||||||
|
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
|
||||||
|
var use_assigned_bot_fill := server_bot_fill_override and bool(config.get_value("allocated-mode"))
|
||||||
|
server_bot_fill_override = false
|
||||||
|
var spawn_entries: Array[Dictionary] = []
|
||||||
|
if use_assigned_bot_fill:
|
||||||
|
var by_identity := {}
|
||||||
|
for peer_id in MatchNet.roster.keys():
|
||||||
|
var roster_info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
||||||
|
by_identity[roster_info.player_identity] = {"peer_id": int(peer_id), "info": roster_info}
|
||||||
|
for assigned: Dictionary in MatchNet.assigned_player_slots():
|
||||||
|
var identity := String(assigned["player_identity"])
|
||||||
|
if by_identity.has(identity):
|
||||||
|
var human: Dictionary = by_identity[identity]
|
||||||
|
spawn_entries.append({"peer_id": human["peer_id"], "info": human["info"], "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": false})
|
||||||
|
else:
|
||||||
|
spawn_entries.append({"peer_id": -1, "info": null, "team": int(assigned["team"]), "spawn_index": int(assigned["slot"]) % 3, "bot": true})
|
||||||
|
else:
|
||||||
var sorted_peer_ids: Array = MatchNet.roster.keys()
|
var sorted_peer_ids: Array = MatchNet.roster.keys()
|
||||||
sorted_peer_ids.sort()
|
sorted_peer_ids.sort()
|
||||||
for peer_id in sorted_peer_ids:
|
for peer_id in sorted_peer_ids:
|
||||||
@@ -458,17 +479,23 @@ func _start_server() -> void:
|
|||||||
var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0)
|
var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0)
|
||||||
if info.spawn_index < 0:
|
if info.spawn_index < 0:
|
||||||
team_counts[info.team] = spawn_index + 1
|
team_counts[info.team] = spawn_index + 1
|
||||||
|
spawn_entries.append({"peer_id": peer_id, "info": info, "team": info.team, "spawn_index": spawn_index, "bot": false})
|
||||||
|
for entry: Dictionary in spawn_entries:
|
||||||
|
var peer_id: int = int(entry["peer_id"])
|
||||||
|
var info: MatchNet.PlayerInfo = entry["info"]
|
||||||
|
var team: int = int(entry["team"])
|
||||||
|
var spawn_index: int = int(entry["spawn_index"])
|
||||||
var slot := SlotInfo.new()
|
var slot := SlotInfo.new()
|
||||||
slot.peer_id = peer_id
|
slot.peer_id = peer_id
|
||||||
slot.team = info.team
|
slot.team = team
|
||||||
slot.spawn_index = spawn_index
|
slot.spawn_index = spawn_index
|
||||||
slot.player_name = info.player_name
|
slot.player_name = "Bot %d" % spawn_index if bool(entry["bot"]) else info.player_name
|
||||||
slot.player_identity = MatchNet.player_identity(peer_id)
|
slot.player_identity = "" if bool(entry["bot"]) else MatchNet.player_identity(peer_id)
|
||||||
slot.controller = RLShipController.new()
|
slot.controller = _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch") if bool(entry["bot"]) else RLShipController.new()
|
||||||
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
|
slot.ship = spawn_ship(team, spawn_index, slot.controller)
|
||||||
_slots.append(slot)
|
_slots.append(slot)
|
||||||
peer_ids.append(peer_id)
|
peer_ids.append(peer_id)
|
||||||
teams.append(info.team)
|
teams.append(team)
|
||||||
spawn_indices.append(spawn_index)
|
spawn_indices.append(spawn_index)
|
||||||
|
|
||||||
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
|
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ func _install_match_loop() -> void:
|
|||||||
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
||||||
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
|
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
|
||||||
loop.rotation_mode = String(config.get_value("arena-rotation"))
|
loop.rotation_mode = String(config.get_value("arena-rotation"))
|
||||||
|
loop.allocated_mode = bool(config.get_value("allocated-mode"))
|
||||||
|
loop.allocated_playlist = String(config.get_value("playlist"))
|
||||||
|
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
|
||||||
get_tree().root.add_child.call_deferred(loop)
|
get_tree().root.add_child.call_deferred(loop)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ static func specs() -> Array[Spec]:
|
|||||||
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
|
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
|
||||||
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
|
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
|
||||||
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
|
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
|
||||||
|
out.append(Spec.new("playlist", Kind.STRING, "", "allocation", "Allocated playlist: casual or ranked"))
|
||||||
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
|
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
|
||||||
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
|
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
|
||||||
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
|
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
|
||||||
@@ -268,7 +269,7 @@ func _validate() -> void:
|
|||||||
if not rotation in ["sequential", "random"]:
|
if not rotation in ["sequential", "random"]:
|
||||||
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
|
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
|
||||||
if bool(values["allocated-mode"]):
|
if bool(values["allocated-mode"]):
|
||||||
for key in ["match-id", "server-id", "playlist-version", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
|
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
|
||||||
if str(values[key]).is_empty():
|
if str(values[key]).is_empty():
|
||||||
errors.append("--allocated-mode requires --%s" % key)
|
errors.append("--allocated-mode requires --%s" % key)
|
||||||
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
|
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
|
||||||
@@ -286,6 +287,9 @@ func _validate() -> void:
|
|||||||
var region := String(values["region"])
|
var region := String(values["region"])
|
||||||
if not region in ["EU", "NA"]:
|
if not region in ["EU", "NA"]:
|
||||||
errors.append("--region must be EU or NA, got '%s'" % region)
|
errors.append("--region must be EU or NA, got '%s'" % region)
|
||||||
|
var playlist := String(values["playlist"])
|
||||||
|
if not playlist in ["casual", "ranked"]:
|
||||||
|
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
|
||||||
|
|
||||||
|
|
||||||
static func _is_sha256_digest(value: String) -> bool:
|
static func _is_sha256_digest(value: String) -> bool:
|
||||||
|
|||||||
@@ -35,17 +35,25 @@ extends Node
|
|||||||
signal match_starting(arena_path: String, match_index: int)
|
signal match_starting(arena_path: String, match_index: int)
|
||||||
|
|
||||||
const POLL_INTERVAL_MS := 250
|
const POLL_INTERVAL_MS := 250
|
||||||
|
const ALLOCATED_WAIT := "WAIT"
|
||||||
|
const ALLOCATED_READY := "READY"
|
||||||
|
const ALLOCATED_CANCEL := "CANCEL"
|
||||||
|
const ALLOCATED_START_WITH_BOTS := "START_WITH_BOTS"
|
||||||
|
|
||||||
var min_players := 1
|
var min_players := 1
|
||||||
var start_countdown_seconds := 5.0
|
var start_countdown_seconds := 5.0
|
||||||
var max_matches := 0 # 0 = run forever
|
var max_matches := 0 # 0 = run forever
|
||||||
var rotation_mode := "sequential"
|
var rotation_mode := "sequential"
|
||||||
|
var allocated_mode := false
|
||||||
|
var allocated_playlist := ""
|
||||||
|
var allocated_roster_size := 0
|
||||||
|
|
||||||
var matches_completed := 0
|
var matches_completed := 0
|
||||||
var _countdown_started_ms := -1
|
var _countdown_started_ms := -1
|
||||||
var _match_active := false
|
var _match_active := false
|
||||||
var _next_poll_ms := 0
|
var _next_poll_ms := 0
|
||||||
var _shutting_down := false
|
var _shutting_down := false
|
||||||
|
var _allocated_connect_started_ms := -1
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
@@ -57,10 +65,57 @@ func _process(_delta: float) -> void:
|
|||||||
_next_poll_ms = now + POLL_INTERVAL_MS
|
_next_poll_ms = now + POLL_INTERVAL_MS
|
||||||
if _match_active:
|
if _match_active:
|
||||||
_poll_match_end()
|
_poll_match_end()
|
||||||
|
else:
|
||||||
|
if allocated_mode:
|
||||||
|
_poll_allocated_match_start(now)
|
||||||
else:
|
else:
|
||||||
_poll_match_start(now)
|
_poll_match_start(now)
|
||||||
|
|
||||||
|
|
||||||
|
func _poll_allocated_match_start(now: int) -> void:
|
||||||
|
if _allocated_connect_started_ms < 0:
|
||||||
|
_allocated_connect_started_ms = now
|
||||||
|
var connected := MatchNet.roster.size()
|
||||||
|
var has_team_zero := false
|
||||||
|
var has_team_one := false
|
||||||
|
for info: MatchNet.PlayerInfo in MatchNet.roster.values():
|
||||||
|
has_team_zero = has_team_zero or info.team == 0
|
||||||
|
has_team_one = has_team_one or info.team == 1
|
||||||
|
var action := allocated_initial_connect_action(allocated_playlist, now - _allocated_connect_started_ms, connected, allocated_roster_size, has_team_zero, has_team_one)
|
||||||
|
if action == ALLOCATED_READY:
|
||||||
|
_poll_match_start(now)
|
||||||
|
return
|
||||||
|
if action == ALLOCATED_CANCEL:
|
||||||
|
var reason := "ranked_initial_connect_timeout" if allocated_playlist == "ranked" else "casual_initial_connect_ineligible"
|
||||||
|
_cancel_allocated_no_show(reason, connected)
|
||||||
|
return
|
||||||
|
if action == ALLOCATED_START_WITH_BOTS:
|
||||||
|
NetworkedMatch.server_bot_fill_override = true
|
||||||
|
_poll_match_start(now)
|
||||||
|
|
||||||
|
|
||||||
|
static func allocated_initial_connect_action(playlist: String, elapsed_ms: int, connected: int, expected: int, has_team_zero: bool, has_team_one: bool) -> String:
|
||||||
|
if elapsed_ms < 0 or connected < 0 or expected < 1:
|
||||||
|
return ALLOCATED_CANCEL
|
||||||
|
if connected >= expected:
|
||||||
|
return ALLOCATED_READY
|
||||||
|
if playlist == "ranked":
|
||||||
|
return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT
|
||||||
|
if playlist == "casual":
|
||||||
|
if elapsed_ms < 60000:
|
||||||
|
return ALLOCATED_WAIT
|
||||||
|
return ALLOCATED_START_WITH_BOTS if connected >= 2 and has_team_zero and has_team_one else ALLOCATED_CANCEL
|
||||||
|
return ALLOCATED_CANCEL
|
||||||
|
|
||||||
|
func _cancel_allocated_no_show(reason: String, connected: int) -> void:
|
||||||
|
if _shutting_down:
|
||||||
|
return
|
||||||
|
_shutting_down = true
|
||||||
|
ServerLog.info("initial_connect_cancelled", {"reason": reason, "connected": connected, "expected": allocated_roster_size})
|
||||||
|
NetworkManager.shutdown()
|
||||||
|
get_tree().quit(0)
|
||||||
|
|
||||||
|
|
||||||
# A match is over when the match scene is gone. NetworkedMatch returns both
|
# A match is over when the match scene is gone. NetworkedMatch returns both
|
||||||
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
|
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
|
||||||
# when everyone has left (§6.4), so "the scene we started is no longer the
|
# when everyone has left (§6.4), so "the scene we started is no longer the
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void
|
|||||||
var valid = _parse([
|
var valid = _parse([
|
||||||
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
|
"--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456",
|
||||||
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
|
"--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64),
|
||||||
"--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
|
"--playlist=casual", "--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json", "--join-authorisations-key-file=/run/secrets/join-authorisations.key"
|
||||||
])
|
])
|
||||||
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))
|
assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
extends "res://tests/test_case.gd"
|
||||||
|
|
||||||
|
func test_allocated_initial_connect_policy_has_explicit_boundaries() -> void:
|
||||||
|
var loop = preload("res://scripts/server_match_loop.gd")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("ranked", 29999, 5, 6, true, true), loop.ALLOCATED_WAIT, "ranked waits before 30 seconds")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("ranked", 30000, 5, 6, true, true), loop.ALLOCATED_CANCEL, "ranked cancels at 30 seconds")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("casual", 59999, 2, 6, true, true), loop.ALLOCATED_WAIT, "casual waits before 60 seconds")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, true), loop.ALLOCATED_START_WITH_BOTS, "casual starts with bots when both teams are represented")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("casual", 60000, 2, 6, true, false), loop.ALLOCATED_CANCEL, "casual cancels when one team is empty")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("casual", 1000, 6, 6, true, true), loop.ALLOCATED_READY, "complete roster is ready immediately")
|
||||||
|
assert_eq(loop.allocated_initial_connect_action("other", 0, 1, 6, true, true), loop.ALLOCATED_CANCEL, "unknown allocated playlist fails closed")
|
||||||
@@ -69,6 +69,7 @@ spec:
|
|||||||
- --match-id=allocation-placeholder
|
- --match-id=allocation-placeholder
|
||||||
- --server-id=allocation-placeholder
|
- --server-id=allocation-placeholder
|
||||||
- --playlist-version=casual
|
- --playlist-version=casual
|
||||||
|
- --playlist=casual
|
||||||
- --client-build=build-1
|
- --client-build=build-1
|
||||||
- --assignment-expiry-unix=1
|
- --assignment-expiry-unix=1
|
||||||
- --server-image-digest=sha256:0000000000000000000000000000000000000000000000000000000000000000
|
- --server-image-digest=sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
|||||||
@@ -1420,3 +1420,5 @@ The maintenance command now invokes a bounded `ReconcileInitialConnect` sweep fo
|
|||||||
Allocation registration now writes a participant-targeted, revisioned `state_changed` outbox event for both `PROCESS_READY` and `ASSIGNMENT_READY` transitions. The production and test API binaries run a type-scoped dispatcher with delivery-before-ack semantics, so allocation lifecycle events survive WebSocket outages without competing with proposal or result consumers. Store/API adversarial tests cover event-type isolation, target validation, and revision mismatches; live allocator/Agones delivery remains an integration gate.
|
Allocation registration now writes a participant-targeted, revisioned `state_changed` outbox event for both `PROCESS_READY` and `ASSIGNMENT_READY` transitions. The production and test API binaries run a type-scoped dispatcher with delivery-before-ack semantics, so allocation lifecycle events survive WebSocket outages without competing with proposal or result consumers. Store/API adversarial tests cover event-type isolation, target validation, and revision mismatches; live allocator/Agones delivery remains an integration gate.
|
||||||
|
|
||||||
The state-event implementation is now complete through the registration boundary: the durable registration SQL returns the authoritative match revision, includes every participant target in the payload, and the dispatcher validates aggregate/revision/state consistency before fan-out. Full Go tests, race checks, and vet pass after an adversarial database-cursor review.
|
The state-event implementation is now complete through the registration boundary: the durable registration SQL returns the authoritative match revision, includes every participant target in the payload, and the dispatcher validates aggregate/revision/state consistency before fan-out. Full Go tests, race checks, and vet pass after an adversarial database-cursor review.
|
||||||
|
|
||||||
|
Allocated Godot runtime now applies the same initial-connect policy: ranked allocations cancel and exit after 30 seconds if the signed roster is incomplete; casual allocations wait 60 seconds, cancel when fewer than two humans or one team is absent, and otherwise start with a deterministic six-slot assignment-derived lineup containing explicit bots. The bot branch is opt-in and consumed once, so direct servers and ranked matches cannot inherit it. Godot parse plus the 155-test harness and manifest checks pass; durable no-show penalties/state reconciliation remain owned by the control-plane sweep.
|
||||||
|
|||||||
Reference in New Issue
Block a user