fix(multiplayer): bound workload credential lifetime

This commit is contained in:
Josh Creek
2026-09-03 21:27:26 +01:00
parent 8507472635
commit ea1c65acfb
12 changed files with 46 additions and 11 deletions
+2 -2
View File
@@ -451,10 +451,10 @@ func configure_result_submission(callback: Callable) -> void:
_result_submit = callback
func submit_authoritative_result(score: Dictionary) -> bool:
func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool:
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
return false
_result_submit.call(int(score[0]), int(score[1]))
_result_submit.call(int(score[0]), int(score[1]), integrity_state)
return true
+13 -2
View File
@@ -320,6 +320,8 @@ static var server_bot_fill_override := false
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _max_overtime_seconds := 900.0
var _overtime_deadline_tick := -1
var _match_over := false
var _planned_server_shutdown := false
var _awaiting_result_submission := false
@@ -349,6 +351,7 @@ func _ready() -> void:
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
# a client cannot shorten anyone's match.
match_length_seconds = maxf(1.0, float(config.get_value("match-length")))
_max_overtime_seconds = maxf(1.0, float(config.get_value("max-overtime-seconds")))
var smoke_after := float(config.get_value("smoke-force-goal-after"))
if smoke_after >= 0.0:
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
@@ -685,6 +688,8 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
# Kickoff is over: bodies move again, and the clock resumes.
_pending_freeze_tick = -1
_set_bodies_frozen(false)
if new_state == MatchState.State.OVERTIME:
_overtime_deadline_tick = at_tick + int(_max_overtime_seconds * SimConstants.TICK_HZ)
# The clock only advances during live play (§6.2 step 9). Derived here
# rather than tracked separately so it cannot disagree with the state.
var was_running := _clock_running
@@ -1055,12 +1060,12 @@ func _update_clock() -> void:
# --- §6.2 step 10: full time, overtime, results (task 5.5) -----------------
func _enter_results(winning_team: int) -> void:
func _enter_results(winning_team: int, integrity_state := "CERTIFIED") -> void:
_match_over = true
_clock_running = false
_set_bodies_frozen(true)
match_ended.emit(winning_team, score.duplicate())
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score):
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score, integrity_state):
_awaiting_result_submission = true
ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime})
_set_match_state(MatchState.State.RESULTS)
@@ -1103,6 +1108,12 @@ func _update_match_state() -> void:
else:
_enter_results(_winning_team())
return
if match_state == MatchState.State.OVERTIME and _overtime_deadline_tick >= 0 and now >= _overtime_deadline_tick:
# Golden goal remains clockless to players, but an operational bound is
# necessary: a stalled draw must finish while its allocated credential is
# valid. REVIEW completes lifecycle delivery without rating either side.
_enter_results(-1, "REVIEW")
return
if _state_deadline_tick < 0 or now < _state_deadline_tick:
return
match match_state:
+3
View File
@@ -56,6 +56,7 @@ static func specs() -> Array[Spec]:
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
out.append(Spec.new("max-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes"))
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
@@ -255,6 +256,8 @@ func _validate() -> void:
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
if float(values["match-length"]) <= 0.0:
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
if float(values["max-overtime-seconds"]) <= 0.0:
errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"]))
if int(values["max-matches"]) < 0:
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
if int(values["min-players"]) < 1:
+1 -1
View File
@@ -30,7 +30,7 @@ func configure(base_url: String, workload_token: String, match_id: String, serve
func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void:
if _submitting or team_0 < 0 or team_1 < 0 or integrity_state != "CERTIFIED":
if _submitting or team_0 < 0 or team_1 < 0 or not integrity_state in ["CERTIFIED", "REVIEW"]:
return
_submitting = true
var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state)
+2
View File
@@ -26,6 +26,7 @@ func test_defaults_apply_when_nothing_is_given() -> void:
assert_true(config.is_valid(), "an empty command line is valid")
assert_eq(config.get_value("port"), 7777, "default port")
assert_eq(config.get_value("max-matches"), 0, "0 means run forever")
assert_eq(config.get_value("max-overtime-seconds"), 900.0, "allocated sudden death has a finite safety cap")
assert_eq(config.get_value("log-level"), "info", "default log level")
@@ -98,6 +99,7 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range")
assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected")
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
assert_true(not _parse(["--max-overtime-seconds=0"]).is_valid(), "an unbounded allocated overtime cap is rejected")
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play")
@@ -21,3 +21,11 @@ func test_only_a_committed_result_acknowledgement_releases_the_match() -> void:
assert_true(not Client.response_is_accepted(200), "an unexpected generic success cannot lose the result")
assert_true(not Client.response_is_accepted(422), "validation failure remains held for operator-visible retry")
assert_true(not Client.response_is_accepted(503), "outage remains held for retry")
func test_review_results_are_permitted_but_forged_states_are_not() -> void:
var client := Client.new()
assert_true(client.configure("https://control.invalid", "token", "match-123456789", "server-123456789"), "test client configures")
# submit itself is asynchronous; the pure configuration boundary proves the
# reporter can carry the REVIEW state selected by bounded overtime.
assert_true(Client.result_nonce("match-123456789", "server-123456789", 1, 1, "REVIEW") != Client.result_nonce("match-123456789", "server-123456789", 1, 1, "CERTIFIED"), "integrity state binds the receipt identity")