mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 22:52:09 +00:00
fix(multiplayer): honor assigned team and slot
This commit is contained in:
@@ -39,6 +39,7 @@ class PlayerInfo:
|
|||||||
var player_name: String
|
var player_name: String
|
||||||
var player_identity: String
|
var player_identity: String
|
||||||
var team: int = 0
|
var team: int = 0
|
||||||
|
var spawn_index: int = -1
|
||||||
var ready: bool = false
|
var ready: bool = false
|
||||||
|
|
||||||
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
|
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
|
||||||
@@ -119,6 +120,21 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func assigned_player_slots() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for token in _allowed_join_authorisations.keys():
|
||||||
|
var claims := _join_claims(String(token))
|
||||||
|
if claims.is_empty():
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"player_identity": str(claims.get("PlayerID", "")),
|
||||||
|
"team": int(claims.get("Team", -1)),
|
||||||
|
"slot": int(claims.get("Slot", -1)),
|
||||||
|
})
|
||||||
|
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
func player_identity(peer_id: int) -> String:
|
func player_identity(peer_id: int) -> String:
|
||||||
if not roster.has(peer_id):
|
if not roster.has(peer_id):
|
||||||
return ""
|
return ""
|
||||||
@@ -248,7 +264,19 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j
|
|||||||
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
||||||
|
|
||||||
var team := _pick_balanced_team()
|
var team := _pick_balanced_team()
|
||||||
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false, identity)
|
var spawn_index := -1
|
||||||
|
if require_join_authorisation:
|
||||||
|
var claims := _join_claims(supplied_join_authorisation)
|
||||||
|
var assigned_slot := int(claims.get("Slot", -1))
|
||||||
|
var assigned_team := int(claims.get("Team", -1))
|
||||||
|
if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team:
|
||||||
|
await _reject(peer_id, "join authorisation rejected")
|
||||||
|
return
|
||||||
|
team = assigned_team
|
||||||
|
spawn_index = assigned_slot % 3
|
||||||
|
var info := PlayerInfo.new(peer_id, clean_name, team, false, identity)
|
||||||
|
info.spawn_index = spawn_index
|
||||||
|
roster[peer_id] = info
|
||||||
if require_join_authorisation:
|
if require_join_authorisation:
|
||||||
# _reserve_join_authorisation already owns the active peer reservation;
|
# _reserve_join_authorisation already owns the active peer reservation;
|
||||||
# keeping the generation in the history makes fencing auditable without
|
# keeping the generation in the history makes fencing auditable without
|
||||||
@@ -276,6 +304,10 @@ func _valid_join_authorisation(token: String) -> bool:
|
|||||||
return false
|
return false
|
||||||
if str(claims.get("PlayerID", "")).is_empty():
|
if str(claims.get("PlayerID", "")).is_empty():
|
||||||
return false
|
return false
|
||||||
|
var claimed_team := int(claims.get("Team", -1))
|
||||||
|
var claimed_slot := int(claims.get("Slot", -1))
|
||||||
|
if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team:
|
||||||
|
return false
|
||||||
var protocol := str(claims.get("Protocol", ""))
|
var protocol := str(claims.get("Protocol", ""))
|
||||||
var expires_at := str(claims.get("ExpiresAt", ""))
|
var expires_at := str(claims.get("ExpiresAt", ""))
|
||||||
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
|
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
|
||||||
@@ -322,6 +354,21 @@ func _join_identity(token: String) -> String:
|
|||||||
return str(envelope["Authorisation"].get("PlayerID", ""))
|
return str(envelope["Authorisation"].get("PlayerID", ""))
|
||||||
|
|
||||||
|
|
||||||
|
func _join_claims(token: String) -> Dictionary:
|
||||||
|
if token.is_empty():
|
||||||
|
return {}
|
||||||
|
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||||
|
while standard_token.length() % 4 != 0:
|
||||||
|
standard_token += "="
|
||||||
|
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||||
|
if decoded.is_empty():
|
||||||
|
return {}
|
||||||
|
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||||
|
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
|
||||||
|
return {}
|
||||||
|
return envelope["Authorisation"]
|
||||||
|
|
||||||
|
|
||||||
func is_join_authorisation_active(token: String) -> bool:
|
func is_join_authorisation_active(token: String) -> bool:
|
||||||
return not token.is_empty() and _active_join_peers.has(token)
|
return not token.is_empty() and _active_join_peers.has(token)
|
||||||
|
|
||||||
|
|||||||
@@ -455,8 +455,9 @@ func _start_server() -> void:
|
|||||||
sorted_peer_ids.sort()
|
sorted_peer_ids.sort()
|
||||||
for peer_id in sorted_peer_ids:
|
for peer_id in sorted_peer_ids:
|
||||||
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id]
|
||||||
var spawn_index: int = 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)
|
||||||
team_counts[info.team] = spawn_index + 1
|
if info.spawn_index < 0:
|
||||||
|
team_counts[info.team] = spawn_index + 1
|
||||||
var slot := SlotInfo.new()
|
var slot := SlotInfo.new()
|
||||||
slot.peer_id = peer_id
|
slot.peer_id = peer_id
|
||||||
slot.team = info.team
|
slot.team = info.team
|
||||||
|
|||||||
@@ -49,12 +49,17 @@ func test_reservation_reclaim_requires_stable_identity() -> void:
|
|||||||
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
|
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
|
||||||
var claims := {
|
var claims := {
|
||||||
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
||||||
"SteamID": "steam-1", "Slot": 2, "Team": 1, "Protocol": "1",
|
"SteamID": "steam-1", "Slot": 5, "Team": 1, "Protocol": "1",
|
||||||
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
||||||
}
|
}
|
||||||
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||||
var match_net := MatchNet.new()
|
var match_net := MatchNet.new()
|
||||||
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures")
|
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures")
|
||||||
|
var assigned := match_net.assigned_player_slots()
|
||||||
|
assert_eq(assigned.size(), 1, "configured roster exposes one assigned player")
|
||||||
|
assert_eq(assigned[0]["player_identity"], "player-1", "assigned roster preserves player identity")
|
||||||
|
assert_eq(assigned[0]["team"], 1, "assigned roster preserves team")
|
||||||
|
assert_eq(assigned[0]["slot"], 5, "assigned roster preserves slot")
|
||||||
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
|
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
|
||||||
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
|
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
|
||||||
var wrong_claims := claims.duplicate()
|
var wrong_claims := claims.duplicate()
|
||||||
@@ -72,11 +77,23 @@ func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> v
|
|||||||
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
|
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
|
||||||
|
|
||||||
|
|
||||||
|
func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> void:
|
||||||
|
var claims := {
|
||||||
|
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
|
||||||
|
"SteamID": "steam-1", "Slot": 3, "Team": 0, "Protocol": "1",
|
||||||
|
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
|
||||||
|
}
|
||||||
|
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
|
||||||
|
var match_net := MatchNet.new()
|
||||||
|
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "fixture configures")
|
||||||
|
assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0")
|
||||||
|
|
||||||
|
|
||||||
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
|
func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
|
||||||
# This envelope is generated from server/domain.JoinAuthorisationBytes with
|
# This envelope is generated from server/domain.JoinAuthorisationBytes with
|
||||||
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
|
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
|
||||||
# canonical representation rather than merely checking token membership.
|
# canonical representation rather than merely checking token membership.
|
||||||
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjIsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIn0sIlNpZ25hdHVyZSI6IkQ0VmVEejJheVh3Y1J3bFZUc3JkUW1YS3FYYzRmVG05RnByTjRYK3ZzM1k9In0="
|
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIn0sIlNpZ25hdHVyZSI6Ijk0QkFOWjJpMkJUWHNWOVdaSWQ1dnE1Q3FqUXF4eGFXNnB4c2U0SFRXSDg9In0="
|
||||||
var match_net := MatchNet.new()
|
var match_net := MatchNet.new()
|
||||||
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "HMAC roster configures")
|
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "HMAC roster configures")
|
||||||
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
|
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
|
||||||
|
|||||||
@@ -1402,3 +1402,5 @@ The former display-name reclaim weakness (flagged item C) is now closed for allo
|
|||||||
Allocated supervisor launch arguments now have a direct regression guard: authoritative match/server/image/assignment-expiry values replace stale child placeholders without mutating the caller’s command slice or disturbing unrelated arguments; dynamic Agones port propagation remains covered by the existing startup test. This closes the local implementation portion of task 8.29; live Agones passthrough/NAT and multi-match validation remain infrastructure gates.
|
Allocated supervisor launch arguments now have a direct regression guard: authoritative match/server/image/assignment-expiry values replace stale child placeholders without mutating the caller’s command slice or disturbing unrelated arguments; dynamic Agones port propagation remains covered by the existing startup test. This closes the local implementation portion of task 8.29; live Agones passthrough/NAT and multi-match validation remain infrastructure gates.
|
||||||
|
|
||||||
Read-only authenticated queue, proposal, assignment, legacy profile, and ranked-profile routes now emit lifecycle-safe observability events for successful, rejected, and not-found reads. An API regression exercises all five real HTTP routes and verifies the event set; event fields remain free of credentials. This closes the local read-route portion of task 8.44; metrics/traces export, dashboards, and alert routing remain operational work.
|
Read-only authenticated queue, proposal, assignment, legacy profile, and ranked-profile routes now emit lifecycle-safe observability events for successful, rejected, and not-found reads. An API regression exercises all five real HTTP routes and verifies the event set; event fields remain free of credentials. This closes the local read-route portion of task 8.44; metrics/traces export, dashboards, and alert routing remain operational work.
|
||||||
|
|
||||||
|
Allocated join admission now retains and applies the signed assignment’s authoritative team and global slot: peer order can no longer rebalance a valid allocation, and inconsistent team/slot claims are rejected before roster admission. The server exposes the verified assignment list for allocation-aware startup and uses the per-team spawn index derived from the assigned slot. Go verification is clean; Godot execution remains blocked by the documented macOS pre-test crash.
|
||||||
|
|||||||
Reference in New Issue
Block a user