mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +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:
|
||||
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:
|
||||
result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"]))
|
||||
return result
|
||||
|
||||
@@ -175,7 +175,10 @@ func _render(snapshot: Dictionary) -> void:
|
||||
if String(snapshot.get("message", "")) != "":
|
||||
detail_label.text = String(snapshot["message"])
|
||||
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:
|
||||
detail_label.text = "Review the proposal before the countdown expires"
|
||||
elif phase == MatchmakingState.IDLE:
|
||||
|
||||
@@ -24,6 +24,7 @@ var phase := IDLE
|
||||
var ticket_id := ""
|
||||
var playlist := ""
|
||||
var revision := 0
|
||||
var enqueued_at_unix := 0
|
||||
var expires_at_unix := 0
|
||||
var proposal_id := ""
|
||||
var proposal_revision := 0
|
||||
@@ -61,6 +62,8 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
||||
# queued ticket's expiry is ever set at all.
|
||||
if update.has("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
|
||||
if incoming_revision > revision + 1:
|
||||
return _request_resync(self.ticket_id)
|
||||
@@ -73,6 +76,8 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
||||
playlist = String(update["playlist"])
|
||||
if update.has("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"):
|
||||
message = String(update["message"])
|
||||
else:
|
||||
@@ -173,6 +178,7 @@ func restore_snapshot(saved: Dictionary) -> bool:
|
||||
playlist = saved_playlist
|
||||
phase = saved_phase
|
||||
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)))
|
||||
proposal_id = String(saved.get("proposal_id", ""))
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
@@ -219,6 +231,7 @@ func _reset() -> void:
|
||||
phase = IDLE
|
||||
playlist = ""
|
||||
revision = 0
|
||||
enqueued_at_unix = 0
|
||||
expires_at_unix = 0
|
||||
proposal_id = ""
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
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")
|
||||
|
||||
@@ -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_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")
|
||||
|
||||
|
||||
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 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