Files
step3/scripts/autoload/credits_service.gd
T
hz 1860e6ede6 Add an RZ Coin slot machine
Adds a spend-coins-for-a-random-reward mini-game reachable from the
shop. Backend mirrors the store's existing atomic/idempotent purchase
pattern (SpinAsync in CreditStore.cs) with a server-authoritative,
weighted reward roll and a rolling per-user spin-rate guard; odds are
served from GET /api/credits/spin-config so the client never hardcodes
numbers that could drift from the real payout table. The mockup's
"Streak Protector" reward was swapped for a real Exam Simulation Token
since streaks aren't currently a persisted, protectable concept.

Also fixes two latent bugs surfaced while building this:
- credits_service.gd's operation-id builder referenced a nonexistent
  AuthState.user_id (should be .username), throwing on every credit
  action.
- _post() silently no-op'd with no signal when signed out, which
  combined with a GDScript closure gotcha (lambdas capture outer
  locals by value, so reassigning inside one never reached the
  caller) meant a spin attempted while unauthenticated could hang
  forever instead of surfacing an error.
2026-07-30 00:29:49 -05:00

180 lines
6.2 KiB
GDScript

extends Node
signal credits_changed
signal credits_failed(message: String)
signal store_operation_completed(message: String)
signal spin_resolved(reward_key: String, reward_label: String, coins_won: int)
signal spin_config_loaded
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
var credits: int = 0
var exam_tokens: int = 0
var qbank_reset_tokens: int = 0
var bank_cycle: int = 0
var products: Array = []
var spin_cost := 50
var spin_rewards: Array = []
var loaded := false
var loading := false
var request: HTTPRequest
var action_request: HTTPRequest
var spin_config_request: HTTPRequest
var request_token := ""
var api_url := DEFAULT_URL
var pending_store_action := ""
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)),
"/api/credits")
request = HTTPRequest.new()
request.timeout = 20.0
request.request_completed.connect(_on_completed)
add_child(request)
action_request = HTTPRequest.new()
action_request.timeout = 20.0
action_request.request_completed.connect(_on_action_completed)
add_child(action_request)
spin_config_request = HTTPRequest.new()
spin_config_request.timeout = 20.0
spin_config_request.request_completed.connect(_on_spin_config_completed)
add_child(spin_config_request)
AuthState.auth_changed.connect(_on_auth_changed)
func refresh() -> void:
if loading or not AuthState.can_send_authenticated_request():
return
loading = true
request_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
if error != OK:
loading = false
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
func apply_status(parsed: Dictionary) -> void:
credits = int(parsed.get("credits", credits))
exam_tokens = int(parsed.get("examTokens", exam_tokens))
qbank_reset_tokens = int(parsed.get("qBankResetTokens", qbank_reset_tokens))
bank_cycle = int(parsed.get("bankCycle", bank_cycle))
products = parsed.get("products", products)
loaded = true
credits_changed.emit()
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
credits_failed.emit("The credits service returned an invalid response.")
return
apply_status(parsed)
func purchase(sku: String) -> void:
pending_store_action = "purchase"
_post("/purchases", {"sku": sku, "operationId": _operation_id("purchase")})
func reset_qbank() -> void:
pending_store_action = "reset"
_post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")})
func spin() -> void:
pending_store_action = "spin"
_post("/spin", {"operationId": _operation_id("spin")})
func fetch_spin_config() -> void:
if not AuthState.can_send_authenticated_request():
return
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
spin_config_request.request(api_url + "/spin-config", headers, HTTPClient.METHOD_GET)
func _on_spin_config_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
spin_cost = int(parsed.get("spinCost", spin_cost))
spin_rewards = parsed.get("rewards", spin_rewards)
spin_config_loaded.emit()
func _post(path: String, payload: Dictionary) -> void:
if not AuthState.can_send_authenticated_request():
pending_store_action = ""
credits_failed.emit("You're signed out. Sign back in and try again.")
return
var headers := PackedStringArray([
"Authorization: Bearer %s" % AuthState.access_token,
"Content-Type: application/json"
])
var error := action_request.request(api_url + path, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if error != OK:
credits_failed.emit("The Rz Coin Store could not complete that action.")
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if result != HTTPRequest.RESULT_SUCCESS:
credits_failed.emit("The Rz Coin Store could not complete that action.")
return
if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY:
if pending_store_action == "spin":
var status: Variant = parsed.get("status", {})
if typeof(status) == TYPE_DICTIONARY:
apply_status(status)
pending_store_action = ""
spin_resolved.emit(
str(parsed.get("rewardKey", "")),
str(parsed.get("rewardLabel", "")),
int(parsed.get("coinsWon", 0)))
return
apply_status(parsed)
if pending_store_action == "reset":
ProfileService.reset_qbank_eligibility()
pending_store_action = ""
store_operation_completed.emit("Your Rz Coin Store balance was updated.")
return
if typeof(parsed) == TYPE_DICTIONARY:
var status: Variant = parsed.get("credits", {})
if typeof(status) == TYPE_DICTIONARY:
apply_status(status)
credits_failed.emit(str(parsed.get("message", "The store action could not be completed.")))
else:
credits_failed.emit("The store action could not be completed.")
pending_store_action = ""
func _operation_id(prefix: String) -> String:
return "%s:%s:%d" % [prefix, str(AuthState.username), Time.get_ticks_usec()]
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
credits = 0
exam_tokens = 0
qbank_reset_tokens = 0
bank_cycle = 0
products = []
spin_rewards = []
loaded = false
loading = false
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request.cancel_request()