fix(multiplayer): lock signed team assignments

This commit is contained in:
Josh Creek
2026-09-02 19:05:45 +01:00
parent 0bca441ca1
commit d59e0017f7
3 changed files with 36 additions and 5 deletions
+17 -5
View File
@@ -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():
+17
View File
@@ -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")
+2
View File
@@ -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.