diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index f3bec78b..74dad6de 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -311,6 +311,10 @@ var _late_joiners: Array[Dictionary] = [] # 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. 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 # entirely, negative means unlimited. var _max_spectators := -1 @@ -451,24 +455,47 @@ func _start_server() -> void: var teams := PackedInt32Array() var spawn_indices := PackedInt32Array() var team_counts := {0: 0, 1: 0} - var sorted_peer_ids: Array = MatchNet.roster.keys() - sorted_peer_ids.sort() - for peer_id in sorted_peer_ids: - var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id] - var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0) - if info.spawn_index < 0: - team_counts[info.team] = spawn_index + 1 + 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() + sorted_peer_ids.sort() + for peer_id in sorted_peer_ids: + var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id] + var spawn_index: int = info.spawn_index if info.spawn_index >= 0 else team_counts.get(info.team, 0) + if info.spawn_index < 0: + 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() slot.peer_id = peer_id - slot.team = info.team + slot.team = team slot.spawn_index = spawn_index - slot.player_name = info.player_name - slot.player_identity = MatchNet.player_identity(peer_id) - slot.controller = RLShipController.new() - slot.ship = spawn_ship(info.team, spawn_index, slot.controller) + slot.player_name = "Bot %d" % spawn_index if bool(entry["bot"]) else info.player_name + slot.player_identity = "" if bool(entry["bot"]) else MatchNet.player_identity(peer_id) + 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(team, spawn_index, slot.controller) _slots.append(slot) peer_ids.append(peer_id) - teams.append(info.team) + teams.append(team) spawn_indices.append(spawn_index) MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices) diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index 27a858e1..acb26c67 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -139,6 +139,9 @@ func _install_match_loop() -> void: 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.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) diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd index 1b0265ce..53a56f08 100644 --- a/Game/scripts/server_config.gd +++ b/Game/scripts/server_config.gd @@ -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("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", 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("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:...)")) @@ -268,7 +269,7 @@ func _validate() -> void: if not rotation in ["sequential", "random"]: errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation) 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(): errors.append("--allocated-mode requires --%s" % key) 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"]) if not region in ["EU", "NA"]: 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: diff --git a/Game/scripts/server_match_loop.gd b/Game/scripts/server_match_loop.gd index f508070d..1586b51b 100644 --- a/Game/scripts/server_match_loop.gd +++ b/Game/scripts/server_match_loop.gd @@ -35,17 +35,25 @@ extends Node signal match_starting(arena_path: String, match_index: int) 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 start_countdown_seconds := 5.0 var max_matches := 0 # 0 = run forever var rotation_mode := "sequential" +var allocated_mode := false +var allocated_playlist := "" +var allocated_roster_size := 0 var matches_completed := 0 var _countdown_started_ms := -1 var _match_active := false var _next_poll_ms := 0 var _shutting_down := false +var _allocated_connect_started_ms := -1 func _process(_delta: float) -> void: @@ -58,7 +66,54 @@ func _process(_delta: float) -> void: if _match_active: _poll_match_end() else: + if allocated_mode: + _poll_allocated_match_start(now) + else: + _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 diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index bc2862c9..2421ed61 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -137,7 +137,7 @@ func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void var valid = _parse([ "--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), - "--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)) diff --git a/Game/tests/cases/test_server_match_loop.gd b/Game/tests/cases/test_server_match_loop.gd new file mode 100644 index 00000000..fcb7ac31 --- /dev/null +++ b/Game/tests/cases/test_server_match_loop.gd @@ -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") diff --git a/deploy/k8s/base/fleet.yaml b/deploy/k8s/base/fleet.yaml index 763647b3..a44c8309 100644 --- a/deploy/k8s/base/fleet.yaml +++ b/deploy/k8s/base/fleet.yaml @@ -69,6 +69,7 @@ spec: - --match-id=allocation-placeholder - --server-id=allocation-placeholder - --playlist-version=casual + - --playlist=casual - --client-build=build-1 - --assignment-expiry-unix=1 - --server-image-digest=sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/multiplayer-next.md b/multiplayer-next.md index c428db2b..86f59348 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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. 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.