Add freemium access economy and token store
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
extends Node
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/access"
|
||||
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||
const POLL_INTERVAL_SECONDS := 3.0
|
||||
const POLL_LIMIT := 200
|
||||
const CHECKOUT_STORAGE_KEY := "ranaze_access_checkout_url"
|
||||
|
||||
signal status_changed(status: Dictionary)
|
||||
signal operation_succeeded(message: String)
|
||||
signal operation_failed(message: String)
|
||||
signal checkout_started(purchase_id: String, checkout_url: String)
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var status: Dictionary = {}
|
||||
var status_request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var purchase_request: HTTPRequest
|
||||
var poll_timer: Timer
|
||||
var pending_action := ""
|
||||
var request_token := ""
|
||||
var active_purchase_id := ""
|
||||
var poll_count := 0
|
||||
var checkout_popup_opened := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(
|
||||
str(ProjectSettings.get_setting("step3/access_api_url", DEFAULT_URL)),
|
||||
"/api/access")
|
||||
status_request = _request_node(_on_status_completed)
|
||||
action_request = _request_node(_on_action_completed)
|
||||
purchase_request = _request_node(_on_purchase_status_completed)
|
||||
poll_timer = Timer.new()
|
||||
poll_timer.wait_time = POLL_INTERVAL_SECONDS
|
||||
poll_timer.timeout.connect(_poll_purchase)
|
||||
add_child(poll_timer)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not AuthState.can_send_authenticated_request() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
status_request.request(api_url, PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func redeem_credits() -> void:
|
||||
_send_action("redeem", "/redeem-credits", {"operationId": _operation_id("credit-pass")})
|
||||
|
||||
|
||||
func activate_token(token: String) -> void:
|
||||
_send_action("activate", "/activate-token", {
|
||||
"token": token,
|
||||
"operationId": _operation_id("activate-%s" % token)
|
||||
})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
_send_action("reset", "/qbank-reset", {
|
||||
"confirmation": "RESET_QBANK",
|
||||
"operationId": _operation_id("qbank-reset")
|
||||
})
|
||||
|
||||
|
||||
func begin_checkout(sku: String) -> void:
|
||||
if OS.has_feature("steam"):
|
||||
_send_action("steam_checkout", "/steam-checkouts", {"sku": sku})
|
||||
return
|
||||
if OS.has_feature("web") and not _prepare_browser_window():
|
||||
operation_failed.emit("Checkout needs a popup. Allow popups for this site, then try again.")
|
||||
return
|
||||
_send_action("checkout", "/web-checkouts", {"sku": sku})
|
||||
|
||||
|
||||
func apply_embedded_status(value: Variant) -> void:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return
|
||||
status = value.duplicate(true)
|
||||
status_changed.emit(status)
|
||||
|
||||
|
||||
func has_unlimited_access() -> bool:
|
||||
return bool(status.get("unlimited", false))
|
||||
|
||||
|
||||
func questions_remaining() -> int:
|
||||
return int(status.get("questionsRemaining", 20))
|
||||
|
||||
|
||||
func _send_action(action: String, path: String, payload: Dictionary) -> void:
|
||||
if pending_action != "":
|
||||
operation_failed.emit("Another access request is still in progress.")
|
||||
return
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
operation_failed.emit("Log in before managing access.")
|
||||
return
|
||||
pending_action = action
|
||||
request_token = AuthState.access_token
|
||||
var error := action_request.request(
|
||||
"%s%s" % [api_url.rstrip("/"), path],
|
||||
PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % request_token
|
||||
]),
|
||||
HTTPClient.METHOD_POST,
|
||||
JSON.stringify(payload))
|
||||
if error != OK:
|
||||
pending_action = ""
|
||||
operation_failed.emit("The access request could not be started.")
|
||||
|
||||
|
||||
func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
apply_embedded_status(parsed)
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var action := pending_action
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
_close_browser_window()
|
||||
operation_failed.emit(_failure_message("Access request failed.", response_code, body))
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action == "checkout":
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid response.")
|
||||
return
|
||||
active_purchase_id = str(parsed.get("purchaseId", ""))
|
||||
var checkout_url := str(parsed.get("checkoutUrl", ""))
|
||||
if active_purchase_id.is_empty() or not checkout_url.begins_with("https://"):
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid link.")
|
||||
return
|
||||
_open_checkout_url(checkout_url)
|
||||
checkout_started.emit(active_purchase_id, checkout_url)
|
||||
poll_count = 0
|
||||
poll_timer.start()
|
||||
_poll_purchase()
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
apply_embedded_status(parsed)
|
||||
operation_succeeded.emit({
|
||||
"redeem": "Your 24-hour QBank pass is active.",
|
||||
"activate": "Your access token was activated.",
|
||||
"reset": "Your QBank has been reset for a new cycle."
|
||||
}.get(action, "Access updated."))
|
||||
|
||||
|
||||
func _poll_purchase() -> void:
|
||||
if active_purchase_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
if purchase_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
poll_count += 1
|
||||
if poll_count > POLL_LIMIT:
|
||||
poll_timer.stop()
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
purchase_request.request(
|
||||
"%s/purchases/%s" % [api_url.rstrip("/"), active_purchase_id.uri_encode()],
|
||||
PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func _on_purchase_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
match str(parsed.get("status", "")):
|
||||
"paid":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_succeeded.emit("Purchase confirmed. Your token is ready to activate.")
|
||||
refresh()
|
||||
"failed", "expired", "refunded":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_failed.emit("The purchase was not completed.")
|
||||
|
||||
|
||||
func _request_node(callback: Callable) -> HTTPRequest:
|
||||
var node := HTTPRequest.new()
|
||||
node.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
node.request_completed.connect(callback)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s-%d-%d" % [prefix, Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _open_checkout_url(url: String) -> void:
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval(
|
||||
"var ranazeUrl=%s;window.localStorage.setItem('%s',ranazeUrl);if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.location.replace(ranazeUrl);window.ranazeAccessWindow.focus();}else{window.location.assign(ranazeUrl);}" %
|
||||
[JSON.stringify(url), CHECKOUT_STORAGE_KEY], true)
|
||||
else:
|
||||
OS.shell_open(url)
|
||||
|
||||
|
||||
func _prepare_browser_window() -> bool:
|
||||
checkout_popup_opened = bool(JavaScriptBridge.eval(
|
||||
"window.ranazeAccessWindow=window.open('about:blank','ranaze_access','popup=yes,width=620,height=760,resizable=yes,scrollbars=yes');window.ranazeAccessWindow!==null;",
|
||||
true))
|
||||
return checkout_popup_opened
|
||||
|
||||
|
||||
func _close_browser_window() -> void:
|
||||
if OS.has_feature("web") and checkout_popup_opened:
|
||||
JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem('%s');if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.close();}window.ranazeAccessWindow=null;window.focus();" %
|
||||
CHECKOUT_STORAGE_KEY, true)
|
||||
checkout_popup_opened = false
|
||||
|
||||
|
||||
func _failure_message(prefix: String, response_code: int, body: PackedByteArray) -> String:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var message := str(parsed.get("message", parsed.get("detail", ""))).strip_edges()
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "%s Status %d." % [prefix, response_code]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
else:
|
||||
status = {}
|
||||
active_purchase_id = ""
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
status_changed.emit(status)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch2scupdmgjpj
|
||||
@@ -19,6 +19,7 @@ signal question_rating_updated(question_id: String, question_elo: int, rating: D
|
||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||
signal question_report_submitted(question_id: String, report_id: String)
|
||||
signal question_report_failed(question_id: String, message: String)
|
||||
signal access_required(message: String)
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
@@ -157,6 +158,14 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(questions_request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY and str(denied.get("code", "")) == "qbank_limit_reached":
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
var denied_message := str(denied.get("message", "Your free QBank allowance is exhausted."))
|
||||
_set_load_error(denied_message, false)
|
||||
access_required.emit(denied_message)
|
||||
return
|
||||
var message := "The connection to the question service was interrupted."
|
||||
if response_code > 0:
|
||||
message = "Questions API request failed with status %d." % response_code
|
||||
@@ -400,6 +409,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
||||
"choice_id": choice_id,
|
||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||
"duration_seconds": maxi(0, duration_seconds),
|
||||
"completion_id": _new_completion_id(question_id),
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -421,6 +431,10 @@ func _process_attempt_queue() -> void:
|
||||
return
|
||||
|
||||
var queued: Dictionary = attempt_queue[active_attempt_index]
|
||||
if str(queued.get("completion_id", "")).is_empty():
|
||||
queued["completion_id"] = _new_completion_id(str(queued.get("question_id", "")))
|
||||
attempt_queue[active_attempt_index] = queued
|
||||
_save_attempt_queue()
|
||||
attempt_request_token = AuthState.access_token
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -429,13 +443,18 @@ func _process_attempt_queue() -> void:
|
||||
var question_id := str(queued.get("question_id", ""))
|
||||
var url := question_attempt_api_url
|
||||
var body := JSON.stringify({
|
||||
"completionId": str(queued.get("completion_id", "")),
|
||||
"questionExternalId": question_id,
|
||||
"choiceId": str(queued.get("choice_id", "")),
|
||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||
})
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
url = "%s/%s/skip?completionId=%s" % [
|
||||
question_api_base_url.rstrip("/"),
|
||||
question_id.uri_encode(),
|
||||
str(queued.get("completion_id", "")).uri_encode()
|
||||
]
|
||||
body = ""
|
||||
|
||||
attempt_request_active = true
|
||||
@@ -474,7 +493,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(attempt_request_token)
|
||||
active_attempt_index = -1
|
||||
elif response_code in [400, 403, 404]:
|
||||
elif response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY:
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
access_required.emit(str(denied.get("message", "Your free QBank allowance is exhausted.")))
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
elif response_code in [400, 404]:
|
||||
push_warning("Discarding invalid question attempt with status %d." % response_code)
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
@@ -492,9 +518,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
_apply_skip_response(parsed)
|
||||
else:
|
||||
_apply_first_attempt_response(parsed)
|
||||
AccessService.apply_embedded_status(parsed.get("access", {}))
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func _new_completion_id(question_id: String) -> String:
|
||||
return "%s-%d-%d" % [question_id.sha256_text().left(12), Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
|
||||
Reference in New Issue
Block a user