Files
CosmicClash/Game/tests/control_plane_proposal_smoke.gd
2026-09-01 15:31:01 +01:00

138 lines
5.6 KiB
GDScript

extends Node
# Two-process real end-to-end proposal smoke test: extends
# control_plane_smoke.gd's single-player login/queue/heartbeat/cancel
# coverage to the matcher path -- two real Godot clients, two real queued
# tickets, a real running server/cmd/matcher pairing them, both clients
# observing the resulting proposal over the real WebSocket event stream and
# accepting it for real. multiplayer-next.md 8.40 names this "a two-player
# proposal round trip" as the next scoped extension to this harness.
#
# ControlPlaneClient is a singleton autoload, so one process can only ever be
# one player -- this mirrors net_smoke.gd's own host/client two-process
# pattern rather than trying to simulate two players in one process:
#
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
# --control-plane-url=http://127.0.0.1:PORT --role=player-a
# godot --headless --path Game res://tests/control_plane_proposal_smoke.tscn -- \
# --control-plane-url=http://127.0.0.1:PORT --role=player-b
#
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
const TIMEOUT_SECONDS := 20.0
var _role := ""
var _finished := false
var _ticket_id := ""
var _accept_sent := false
func _ready() -> void:
var control_plane_url := ""
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--control-plane-url="):
control_plane_url = arg.substr("--control-plane-url=".length())
elif arg.begins_with("--role="):
_role = arg.substr("--role=".length())
if control_plane_url.is_empty() or (_role != "player-a" and _role != "player-b"):
_finish(false, "missing --control-plane-url or --role=player-a|player-b")
return
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
_finish(false, "configure() rejected a valid-looking base URL")
return
_ticket_id = "proposal-smoke-%s-%d" % [_role, Time.get_unix_time_from_system()]
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.request_failed.connect(_on_request_failed)
ControlPlaneClient.state.changed.connect(_on_state_changed)
var web_api_ticket := "proposal-smoke-web-api-ticket-%s-%d" % [_role, Time.get_ticks_usec()]
var err := ControlPlaneClient.login_steam(web_api_ticket)
if err != OK:
_finish(false, "login_steam() failed to start: %s" % error_string(err))
return
print("SMOKE[%s]: logging in against %s..." % [_role, control_plane_url])
var timer := Timer.new()
timer.wait_time = TIMEOUT_SECONDS
timer.one_shot = true
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs waiting for a proposal" % TIMEOUT_SECONDS))
add_child(timer)
timer.start()
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
if _finished:
return
if operation == "steam_session":
print("SMOKE[%s]: logged in as %s, queueing for a 2-player casual match..." % [_role, ControlPlaneClient.player_id])
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
if err != OK:
_finish(false, "queue_create() failed to start: %s" % error_string(err))
return
if operation == "queue_create":
print("SMOKE[%s]: ticket %s QUEUED, waiting for the matcher to propose a match..." % [_role, _ticket_id])
return
if operation.begins_with("proposal_"):
if payload.get("state", "") != "ACCEPTED" and payload.get("state", "") != "OPEN":
_finish(false, "unexpected proposal response after accept: %s" % payload)
return
if payload.get("state", "") == "ACCEPTED":
_finish(true, "both players queued, the real matcher formed a proposal, and this client's accept was recorded")
else:
print("SMOKE[%s]: accepted; waiting for the other player..." % _role)
var recovery_timer := get_tree().create_timer(1.0)
recovery_timer.timeout.connect(_recover_after_accept)
func _recover_after_accept() -> void:
if _finished or ControlPlaneClient._operation != "" or ControlPlaneClient.state.proposal_id.is_empty():
return
var err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id)
if err != OK and err != ERR_BUSY:
_finish(false, "proposal recovery after accept failed to start: %s" % error_string(err))
func _on_state_changed(snapshot: Dictionary) -> void:
if _finished or _accept_sent or String(snapshot.get("proposal_state", "")) != "OPEN":
return
if ControlPlaneClient._operation != "":
return # Already mid-request (e.g. the accept itself); avoid double-sending.
print("SMOKE[%s]: proposal %s is OPEN at revision %d, accepting..." % [_role, ControlPlaneClient.state.proposal_id, ControlPlaneClient.state.proposal_revision])
_accept_sent = true
if _role == "player-b":
var delay := get_tree().create_timer(0.5)
delay.timeout.connect(_send_accept)
return
_send_accept()
func _send_accept() -> void:
if _finished:
return
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
if err != OK and err != ERR_BUSY:
_accept_sent = false
_finish(false, "respond_to_proposal() failed to start: %s" % error_string(err))
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
if _finished:
return
if operation == "proposal_accept" and http_code == HTTPClient.RESPONSE_CONFLICT:
print("SMOKE[%s]: concurrent accept conflicted; ControlPlaneClient is recovering the authoritative proposal..." % _role)
return
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
func _finish(passed: bool, detail: String) -> void:
if _finished:
return
_finished = true
if passed:
print("SMOKE PASS: [%s] %s" % [_role, detail])
get_tree().quit(0)
else:
print("SMOKE FAIL: [%s] %s" % [_role, detail])
get_tree().quit(1)