Files
step3/scripts/autoload/ccs_service.gd
T

289 lines
10 KiB
GDScript

extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/ccs"
signal cases_changed
signal attempt_changed
signal orders_changed
signal history_changed
signal prediction_changed
signal rating_changed
signal attempt_abandoned
signal report_submitted(report_id: String)
signal report_failed(message: String)
signal request_failed(message: String)
signal text_scale_changed
const TEXT_SCALE_MIN := 0.85
const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
var api_url := DEFAULT_API_URL
var request: HTTPRequest
var cases: Array = []
var attempt := {}
var orders: Array = []
var history: Array = []
var history_next_cursor := ""
var prediction := {}
var rating := {"elo": 1000, "ratedCaseCount": 0}
var is_loading := false
var pending_action := ""
var queue: Array = []
var text_scale := 1.0
var highlights := {}
var request_access_token := ""
var launch_origin := "ccs"
func _ready() -> void:
api_url = EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/ccs_api_url", DEFAULT_API_URL)), "/api/ccs")
request = HTTPRequest.new()
request.request_completed.connect(_on_request_completed)
add_child(request)
AuthState.auth_changed.connect(_on_auth_changed)
call_deferred("_connect_account_profile_service")
func _connect_account_profile_service() -> void:
text_scale = _synced_text_scale()
AccountProfileService.profile_changed.connect(_on_account_profile_changed)
func load_cases() -> void:
_send("cases", HTTPClient.METHOD_GET, "/cases")
func create_attempt(case_external_id: String, _mode: String = "timed") -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": "timed"})
func load_active() -> void:
_send("active", HTTPClient.METHOD_GET, "/active")
func load_attempt(attempt_id: String) -> void:
_send("attempt", HTTPClient.METHOD_GET, "/attempts/%s" % attempt_id.uri_encode())
func send_command(type: String, code: String = "", minutes: int = 0) -> void:
if attempt.is_empty():
return
_send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands" % str(attempt.get("id", "")).uri_encode(), {
"commandId": "%d-%d" % [Time.get_ticks_usec(), randi()],
"expectedRevision": int(attempt.get("revision", 0)),
"type": type,
"code": code,
"minutes": minutes
})
func send_dialogue_choice(choice_id: String) -> void:
send_command("dialogue_choice", choice_id)
func send_batch(commands: Array) -> void:
if attempt.is_empty() or commands.is_empty():
return
_send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands/batch" % str(attempt.get("id", "")).uri_encode(), {
"commandId": "%d-%d" % [Time.get_ticks_usec(), randi()],
"expectedRevision": int(attempt.get("revision", 0)),
"commands": commands
})
func search_orders(query: String) -> void:
if query.strip_edges().length() < 2:
orders = []
orders_changed.emit()
return
_send("orders", HTTPClient.METHOD_GET, "/orders/search?q=%s&limit=20" % query.strip_edges().uri_encode())
func load_history(cursor: String = "", status: String = "all", sort_order: String = "newest") -> void:
var suffix := "/history?limit=20&status=%s&sort=%s" % [status.uri_encode(), sort_order.uri_encode()]
if not cursor.is_empty():
suffix += "&cursor=%s" % cursor.uri_encode()
_send("history_append" if not cursor.is_empty() else "history", HTTPClient.METHOD_GET, suffix)
func load_prediction() -> void:
_send("prediction", HTTPClient.METHOD_GET, "/prediction")
func load_rating() -> void:
_send("rating", HTTPClient.METHOD_GET, "/ratings/me")
func submit_report(attempt_id: String, category: String, details: String) -> void:
_send("report", HTTPClient.METHOD_POST, "/reports", {"attemptId": attempt_id, "category": category, "details": details})
func adjust_text_scale(delta: float) -> void:
text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
AccountProfileService.update_text_scale(null, text_scale)
func _synced_text_scale() -> float:
var saved: float = AccountProfileService.preferences.get("ccsTextScale", 1.0)
return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
func _on_account_profile_changed() -> void:
var synced := _synced_text_scale()
if is_equal_approx(synced, text_scale):
return
text_scale = synced
text_scale_changed.emit()
func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void:
var key := "%s:%s" % [attempt_id, region_id]
var ranges: Array = highlights.get(key, [])
var changed := false
for index in range(ranges.size() - 1, -1, -1):
var item: Dictionary = ranges[index]
var highlight_start := int(item.get("start", 0))
var highlight_end := int(item.get("end", 0))
if start < highlight_end and end > highlight_start:
ranges.remove_at(index)
if highlight_start < start:
ranges.insert(index, {"start": highlight_start, "end": start})
index += 1
if end < highlight_end:
ranges.insert(index, {"start": end, "end": highlight_end})
changed = true
if not changed:
ranges.append({"start": start, "end": end})
highlights[key] = ranges
func clear_highlights(attempt_id: String) -> void:
for key in highlights.keys():
if str(key).begins_with("%s:" % attempt_id): highlights.erase(key)
func highlighted_text(attempt_id: String, region_id: String, plain_text: String) -> String:
var ranges: Array = highlights.get("%s:%s" % [attempt_id, region_id], []).duplicate()
ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("start", 0)) < int(b.get("start", 0)))
var result := ""; var cursor := 0
for item in ranges:
var start := clampi(int(item.get("start", 0)), cursor, plain_text.length())
var end := clampi(int(item.get("end", 0)), start, plain_text.length())
result += _escape_bbcode(plain_text.substr(cursor, start - cursor))
result += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(plain_text.substr(start, end - start))
cursor = end
return result + _escape_bbcode(plain_text.substr(cursor))
func abandon() -> void:
if attempt.is_empty():
return
_send("abandon", HTTPClient.METHOD_POST, "/attempts/%s/abandon" % str(attempt.get("id", "")).uri_encode())
func deadline_unix() -> float:
var raw: Variant = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
func hard_deadline_unix() -> float:
var raw: Variant = attempt.get("hardDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void:
var url := "%s%s" % [api_url.rstrip("/"), suffix]
if is_loading:
queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)})
return
if not AuthState.can_send_authenticated_request():
return
is_loading = true
pending_action = action
request_access_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token])
var body := ""
if not payload.is_empty():
headers.append("Content-Type: application/json")
body = JSON.stringify(payload)
var error := request.request(url, headers, method, body)
if error != OK:
is_loading = false
request_failed.emit("The CCS request could not be started.")
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var action := pending_action
pending_action = ""
is_loading = false
call_deferred("_process_queue")
if response_code == 401:
AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var detail := body.get_string_from_utf8().strip_edges().left(240)
var message := "CCS request failed (%d). %s" % [response_code, detail]
if action == "report": report_failed.emit(message)
else: request_failed.emit(message)
return
if response_code == 204:
attempt = {}
if action == "abandon": attempt_abandoned.emit()
else: attempt_changed.emit()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if action in ["cases", "orders"] and typeof(parsed) != TYPE_ARRAY:
request_failed.emit("The CCS API returned an invalid list.")
return
if action not in ["cases", "orders"] and typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The CCS API returned an invalid response.")
return
match action:
"cases": cases = parsed; cases_changed.emit()
"orders": orders = parsed; orders_changed.emit()
"history", "history_append":
var items: Array = parsed.get("items", [])
history = history + items if action == "history_append" else items
var cursor_value: Variant = parsed.get("nextCursor", null)
history_next_cursor = "" if cursor_value == null else str(cursor_value)
history_changed.emit()
"prediction": prediction = parsed; prediction_changed.emit()
"rating": rating = parsed; rating_changed.emit()
"report": report_submitted.emit(str(parsed.get("id", "")))
_:
attempt = parsed
attempt_changed.emit()
func _process_queue() -> void:
if is_loading or queue.is_empty():
return
var queued: Dictionary = queue.pop_front()
var suffix := str(queued.get("url", "")).trim_prefix(api_url.rstrip("/"))
_send(str(queued.get("action", "")), int(queued.get("method", HTTPClient.METHOD_GET)), suffix, queued.get("payload", {}))
func _escape_bbcode(value: String) -> String:
return value.replace("[", "[lb]").replace("]", "[rb]")
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
launch_origin = "ccs"
highlights.clear()
text_scale = 1.0
attempt = {}
orders = []
history = []
history_next_cursor = ""
prediction = {}
rating = {"elo": 1000, "ratedCaseCount": 0}