mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 22:52:03 +00:00
feat(multiplayer): preserve authoritative queue wait
This commit is contained in:
@@ -275,6 +275,8 @@ static func is_retryable_mutation_response(response_code: int) -> bool:
|
|||||||
|
|
||||||
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
||||||
var result := payload.duplicate(true)
|
var result := payload.duplicate(true)
|
||||||
|
if result.has("enqueued_at") and result["enqueued_at"] is String:
|
||||||
|
result["enqueued_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["enqueued_at"]))
|
||||||
if result.has("expires_at") and result["expires_at"] is String:
|
if result.has("expires_at") and result["expires_at"] is String:
|
||||||
result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"]))
|
result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"]))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ func _render(snapshot: Dictionary) -> void:
|
|||||||
if String(snapshot.get("message", "")) != "":
|
if String(snapshot.get("message", "")) != "":
|
||||||
detail_label.text = String(snapshot["message"])
|
detail_label.text = String(snapshot["message"])
|
||||||
elif phase == MatchmakingState.QUEUED:
|
elif phase == MatchmakingState.QUEUED:
|
||||||
detail_label.text = "Elapsed %.0fs · revision %d" % [_elapsed_seconds, int(snapshot.get("revision", 0))]
|
var waited := _elapsed_seconds
|
||||||
|
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
|
||||||
|
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
|
||||||
|
detail_label.text = "Waiting %.0fs · revision %d" % [waited, int(snapshot.get("revision", 0))]
|
||||||
elif phase == MatchmakingState.PROPOSED:
|
elif phase == MatchmakingState.PROPOSED:
|
||||||
detail_label.text = "Review the proposal before the countdown expires"
|
detail_label.text = "Review the proposal before the countdown expires"
|
||||||
elif phase == MatchmakingState.IDLE:
|
elif phase == MatchmakingState.IDLE:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ var phase := IDLE
|
|||||||
var ticket_id := ""
|
var ticket_id := ""
|
||||||
var playlist := ""
|
var playlist := ""
|
||||||
var revision := 0
|
var revision := 0
|
||||||
|
var enqueued_at_unix := 0
|
||||||
var expires_at_unix := 0
|
var expires_at_unix := 0
|
||||||
var proposal_id := ""
|
var proposal_id := ""
|
||||||
var proposal_revision := 0
|
var proposal_revision := 0
|
||||||
@@ -61,6 +62,8 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
|||||||
# queued ticket's expiry is ever set at all.
|
# queued ticket's expiry is ever set at all.
|
||||||
if update.has("expires_at_unix"):
|
if update.has("expires_at_unix"):
|
||||||
expires_at_unix = int(update["expires_at_unix"])
|
expires_at_unix = int(update["expires_at_unix"])
|
||||||
|
if update.has("enqueued_at_unix"):
|
||||||
|
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||||
return true
|
return true
|
||||||
if incoming_revision > revision + 1:
|
if incoming_revision > revision + 1:
|
||||||
return _request_resync(self.ticket_id)
|
return _request_resync(self.ticket_id)
|
||||||
@@ -73,6 +76,8 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
|||||||
playlist = String(update["playlist"])
|
playlist = String(update["playlist"])
|
||||||
if update.has("expires_at_unix"):
|
if update.has("expires_at_unix"):
|
||||||
expires_at_unix = int(update["expires_at_unix"])
|
expires_at_unix = int(update["expires_at_unix"])
|
||||||
|
if update.has("enqueued_at_unix"):
|
||||||
|
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||||
if update.has("message"):
|
if update.has("message"):
|
||||||
message = String(update["message"])
|
message = String(update["message"])
|
||||||
else:
|
else:
|
||||||
@@ -173,6 +178,7 @@ func restore_snapshot(saved: Dictionary) -> bool:
|
|||||||
playlist = saved_playlist
|
playlist = saved_playlist
|
||||||
phase = saved_phase
|
phase = saved_phase
|
||||||
revision = maxi(0, int(saved.get("revision", 0)))
|
revision = maxi(0, int(saved.get("revision", 0)))
|
||||||
|
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
|
||||||
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
|
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
|
||||||
proposal_id = String(saved.get("proposal_id", ""))
|
proposal_id = String(saved.get("proposal_id", ""))
|
||||||
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
||||||
@@ -187,8 +193,14 @@ func can_cancel() -> bool:
|
|||||||
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
|
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
|
||||||
|
|
||||||
|
|
||||||
|
func waited_seconds(now_unix: int) -> int:
|
||||||
|
if enqueued_at_unix <= 0:
|
||||||
|
return 0
|
||||||
|
return maxi(0, now_unix - enqueued_at_unix)
|
||||||
|
|
||||||
|
|
||||||
func snapshot() -> Dictionary:
|
func snapshot() -> Dictionary:
|
||||||
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
|
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
|
||||||
|
|
||||||
|
|
||||||
func _ticket_differs(update: Dictionary) -> bool:
|
func _ticket_differs(update: Dictionary) -> bool:
|
||||||
@@ -219,6 +231,7 @@ func _reset() -> void:
|
|||||||
phase = IDLE
|
phase = IDLE
|
||||||
playlist = ""
|
playlist = ""
|
||||||
revision = 0
|
revision = 0
|
||||||
|
enqueued_at_unix = 0
|
||||||
expires_at_unix = 0
|
expires_at_unix = 0
|
||||||
proposal_id = ""
|
proposal_id = ""
|
||||||
proposal_revision = 0
|
proposal_revision = 0
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
|
|||||||
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
|
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
|
||||||
|
|
||||||
|
|
||||||
|
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
|
||||||
|
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
|
||||||
|
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
|
||||||
|
|
||||||
|
|
||||||
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
|
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
|
||||||
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
|
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
|
||||||
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
|
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
|
||||||
|
|||||||
@@ -84,3 +84,13 @@ func test_restart_restore_requires_valid_identity_and_requests_authoritative_rec
|
|||||||
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
|
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
|
||||||
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
|
assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state")
|
||||||
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
|
assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected")
|
||||||
|
|
||||||
|
|
||||||
|
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
|
||||||
|
var state := MatchmakingState.new()
|
||||||
|
assert_true(state.begin_queue("ticket-wait", "casual"), "queue setup succeeds")
|
||||||
|
assert_true(state.apply_ticket_update({"ticket_id": "ticket-wait", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
|
||||||
|
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
|
||||||
|
var restored := MatchmakingState.new()
|
||||||
|
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
|
||||||
|
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
|
||||||
|
|||||||
@@ -1526,3 +1526,5 @@ silent client-side stale state. Normal, race, vet, and SQL-shape checks pass;
|
|||||||
the live PostgreSQL chaos/restart gate remains part of 8.50.
|
the live PostgreSQL chaos/restart gate remains part of 8.50.
|
||||||
|
|
||||||
The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` when no PATH executable or `GODOT_BIN` override exists, while retaining explicit override precedence. The same gate passes without an environment override on this macOS host.
|
The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` when no PATH executable or `GODOT_BIN` override exists, while retaining explicit override precedence. The same gate passes without an environment override on this macOS host.
|
||||||
|
|
||||||
|
The client matchmaking projection now preserves the server's `enqueued_at` timestamp through normalization, snapshots, and recovery, and uses it for the displayed queue wait when available. This prevents a client restart or delayed response from resetting the user's perceived wait to local process uptime; a local timer remains the fallback when older responses omit the timestamp. Godot state and normalization tests cover the projection and restore path.
|
||||||
|
|||||||
Reference in New Issue
Block a user