Add freemium access economy and token store

This commit is contained in:
hz
2026-07-27 22:20:21 -05:00
parent 1d5d03a4f6
commit 4bbac27b84
40 changed files with 1807 additions and 37 deletions
+249
View File
@@ -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)