diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index 3ec8b1e9..b510a84c 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -492,17 +492,29 @@ func _set_team(team: int) -> void: if not multiplayer.is_server(): return var peer_id := multiplayer.get_remote_sender_id() - if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT: + if not _apply_team_change(peer_id, team): return var info: PlayerInfo = roster[peer_id] - if info.team == team: - return - info.team = team - info.ready = false # switching teams un-readies — the roster you were ready against just changed player_state_changed.emit(peer_id, info.team, info.ready) _state_changed.rpc(peer_id, info.team, info.ready) +func _apply_team_change(peer_id: int, team: int) -> bool: + # In allocated matches team and global slot are signed together. Changing + # only team would produce a roster that disagrees with the assignment and + # leave spawn_index anchored to the old team. + if require_join_authorisation: + return false + if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT: + return false + var info: PlayerInfo = roster[peer_id] + if info.team == team: + return false + info.team = team + info.ready = false # switching teams un-readies — the roster you were ready against just changed + return true + + @rpc("any_peer", "call_remote", "reliable") func _set_ready(ready: bool) -> void: if not multiplayer.is_server(): diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index c1b38913..eacfe323 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -70,6 +70,23 @@ func test_draining_disconnect_still_releases_roster_and_join_token() -> void: assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain") +func test_signed_assignment_locks_team_and_spawn_slot_together() -> void: + var match_net := MatchNet.new() + var info := MatchNet.PlayerInfo.new(42, "Alice", 0, true, "player-1") + info.spawn_index = 2 + match_net.roster[42] = info + match_net.require_join_authorisation = true + assert_true(not match_net._apply_team_change(42, 1), "allocated clients cannot override their signed team") + assert_eq(info.team, 0, "signed team is unchanged") + assert_eq(info.spawn_index, 2, "signed spawn index remains paired with its team") + assert_true(info.ready, "rejected mutation does not alter readiness") + + match_net.require_join_authorisation = false + assert_true(match_net._apply_team_change(42, 1), "direct lobbies retain team switching") + assert_eq(info.team, 1, "direct team switch applies") + assert_true(not info.ready, "direct team switch still clears readiness") + + func test_reservation_reclaim_requires_stable_identity() -> void: assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name") assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot") diff --git a/multiplayer-next.md b/multiplayer-next.md index 06a899d9..df8730b0 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1638,3 +1638,5 @@ Replica-independent client convergence now supersedes the earlier "at-least-once The production allocator now uses the API it actually implements: Kubernetes custom-resource paths at `https://kubernetes.default.svc`, rather than sending those paths to the distinct mTLS Agones Allocator Service. Its HTTPS client trusts the mounted cluster CA, rereads the projected service-account token for every request so rotation is honored, applies a ten-second request timeout, and refuses to forward the credential to another origin. The allocator pod explicitly mounts its token; namespaced RBAC permits only GameServer `list` and GameServerAllocation `create`; and its default-deny policy permits portable API-server egress only on TCP 443. Focused Go/auth, static policy, and `kubectl kustomize` checks pass. The real kind/Agones runtime gate remains open because kind and Helm are unavailable here and Docker storage is exhausted; no live-cluster success is claimed. Drain admission now fails at the handshake boundary: a new `_hello` is rejected with the actual RPC peer ID after `admissions_open` closes. Disconnects no longer perform the admission check (or try to reject an already-gone sender); they always invalidate transport state, release the signed join token, record the reconnect boundary, and remove the roster entry. Godot regressions cover the admission decision and cleanup while draining. Task 8.36's live lifecycle/PDB gates remain open. + +Allocated team and slot assignments are now immutable after signed admission. MatchNet rejects client `_set_team` requests whenever join authorisation is required, preserving the signed global-slot/team pairing and its derived spawn index; direct/community lobbies retain team switching and its existing unready behavior. The Godot regression asserts both sides of that compatibility boundary.