Add server-authoritative RZ Coin rewards
This commit is contained in:
@@ -5,6 +5,8 @@ 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
|
||||
signal award_received(amount: int, reason: String)
|
||||
signal ledger_changed
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||
@@ -21,9 +23,13 @@ var loading := false
|
||||
var request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var spin_config_request: HTTPRequest
|
||||
var ledger_request: HTTPRequest
|
||||
var request_token := ""
|
||||
var api_url := DEFAULT_URL
|
||||
var pending_store_action := ""
|
||||
var ledger: Array = []
|
||||
var ledger_next_before := ""
|
||||
var ledger_append := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -42,6 +48,10 @@ func _ready() -> void:
|
||||
spin_config_request.timeout = 20.0
|
||||
spin_config_request.request_completed.connect(_on_spin_config_completed)
|
||||
add_child(spin_config_request)
|
||||
ledger_request = HTTPRequest.new()
|
||||
ledger_request.timeout = 20.0
|
||||
ledger_request.request_completed.connect(_on_ledger_completed)
|
||||
add_child(ledger_request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -67,6 +77,43 @@ func apply_status(parsed: Dictionary) -> void:
|
||||
credits_changed.emit()
|
||||
|
||||
|
||||
func apply_award_response(parsed: Dictionary) -> void:
|
||||
var status: Variant = parsed.get("credits", null)
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
for award_variant in parsed.get("creditAwards", []):
|
||||
if typeof(award_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var award: Dictionary = award_variant
|
||||
var amount := int(award.get("amount", 0))
|
||||
if amount > 0:
|
||||
award_received.emit(amount, str(award.get("description", "Learning reward")))
|
||||
|
||||
|
||||
func load_ledger(before: String = "") -> void:
|
||||
if not AuthState.can_send_authenticated_request() or ledger_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
ledger_append = not before.is_empty()
|
||||
var suffix := "/ledger?limit=20"
|
||||
if not before.is_empty():
|
||||
suffix += "&before=%s" % before.uri_encode()
|
||||
ledger_request.request(api_url + suffix, PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]), HTTPClient.METHOD_GET)
|
||||
|
||||
|
||||
func _on_ledger_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
credits_failed.emit("Coin history could not be loaded.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
var items: Array = parsed.get("items", [])
|
||||
ledger = ledger + items if ledger_append else items
|
||||
var cursor: Variant = parsed.get("nextBefore", null)
|
||||
ledger_next_before = "" if cursor == null else str(cursor)
|
||||
ledger_changed.emit()
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
if response_code == 401:
|
||||
@@ -173,6 +220,8 @@ func _on_auth_changed() -> void:
|
||||
bank_cycle = 0
|
||||
products = []
|
||||
spin_rewards = []
|
||||
ledger = []
|
||||
ledger_next_before = ""
|
||||
loaded = false
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
|
||||
@@ -196,6 +196,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
CreditsService.apply_award_response(parsed)
|
||||
if action == "readiness":
|
||||
readiness = parsed
|
||||
readiness_changed.emit()
|
||||
|
||||
@@ -545,9 +545,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
var rating: Dictionary = parsed.get("rating", {})
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
var credits_status: Variant = parsed.get("credits", null)
|
||||
if typeof(credits_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credits_status)
|
||||
CreditsService.apply_award_response(parsed)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
question_rating_updated.emit(question_id, question_elo, rating)
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
||||
var synced_question_id := ""
|
||||
var synced_is_correct := false
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_award_response(parsed)
|
||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
||||
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
|
||||
|
||||
@@ -38,12 +38,38 @@ func _ready() -> void:
|
||||
theme_service = get_node("/root/UIThemeService")
|
||||
Style.apply_app_theme(self)
|
||||
_build_ui()
|
||||
CreditsService.award_received.connect(_show_coin_award)
|
||||
theme_service.theme_changed.connect(_on_theme_changed)
|
||||
resized.connect(_update_responsive_layout)
|
||||
_update_responsive_layout()
|
||||
show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login")
|
||||
|
||||
|
||||
func _show_coin_award(amount: int, reason: String) -> void:
|
||||
var toast := PanelContainer.new()
|
||||
toast.z_index = 200
|
||||
toast.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
toast.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
toast.position = Vector2(-360, 76)
|
||||
toast.custom_minimum_size = Vector2(340, 52)
|
||||
toast.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.AMBER))
|
||||
var label := Label.new()
|
||||
label.text = "+%d RZ Coins · %s" % [amount, reason]
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
toast.add_child(label)
|
||||
add_child(toast)
|
||||
toast.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(toast, "modulate:a", 1.0, 0.18)
|
||||
tween.tween_interval(2.8)
|
||||
tween.tween_property(toast, "modulate:a", 0.0, 0.25)
|
||||
tween.tween_callback(toast.queue_free)
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
compact_shell = size.x < 900.0
|
||||
if sidebar != null:
|
||||
|
||||
@@ -16,7 +16,6 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
|
||||
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
|
||||
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
|
||||
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
|
||||
const COIN_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||
const FEEDBACK_FADE_SECONDS := 0.45
|
||||
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
||||
const TEXT_SCALE_MIN := 0.85
|
||||
@@ -1650,15 +1649,6 @@ func _submit_or_next() -> void:
|
||||
_update_question_elo_label(true)
|
||||
_show_answer_review()
|
||||
_trigger_correct_feedback()
|
||||
var earned_coin := (
|
||||
BlockSessionService.session_kind == "core"
|
||||
and bool(result.get("mastered", false))
|
||||
and met_time_limit
|
||||
)
|
||||
if earned_coin:
|
||||
var correct_choice_index := _choice_index_for_id(selected_choice)
|
||||
if correct_choice_index >= 0 and correct_choice_index < choice_rows.size():
|
||||
_play_coin_burst(choice_rows[correct_choice_index].get_global_rect())
|
||||
return
|
||||
_lock_wrong_choice(attempted_choice)
|
||||
_show_wrong_answer_followup(attempted_choice)
|
||||
@@ -2263,39 +2253,6 @@ func _trigger_correct_feedback() -> void:
|
||||
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
|
||||
|
||||
|
||||
func _play_coin_burst(origin_rect: Rect2) -> void:
|
||||
if credits_badge == null:
|
||||
return
|
||||
var origin := origin_rect.get_center()
|
||||
var target := credits_badge.get_global_rect().get_center()
|
||||
var coin_count := 6
|
||||
var flight_time := 0.55
|
||||
var stagger := 0.035
|
||||
for index in range(coin_count):
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = COIN_ICON
|
||||
coin.modulate = Brand.AMBER
|
||||
coin.custom_minimum_size = Vector2(16, 16)
|
||||
coin.size = Vector2(16, 16)
|
||||
coin.top_level = true
|
||||
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coin.pivot_offset = Vector2(8, 8)
|
||||
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
|
||||
coin.global_position = origin + jitter - coin.size * 0.5
|
||||
add_child(coin)
|
||||
var tween := create_tween()
|
||||
tween.tween_interval(index * stagger)
|
||||
tween.set_parallel(true)
|
||||
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.set_parallel(false)
|
||||
tween.tween_callback(coin.queue_free)
|
||||
var settle_time := (coin_count - 1) * stagger + flight_time
|
||||
get_tree().create_timer(settle_time).timeout.connect(func():
|
||||
if credits_badge != null:
|
||||
credits_badge.bump())
|
||||
|
||||
|
||||
func _update_answer_feedback(delta: float) -> void:
|
||||
if shake_time > 0.0:
|
||||
shake_time = max(0.0, shake_time - delta)
|
||||
|
||||
@@ -39,6 +39,8 @@ var footer_icon: TextureRect
|
||||
var footer_heading: Label
|
||||
var footer_description: Label
|
||||
var pending_sku := ""
|
||||
var ledger_list: VBoxContainer
|
||||
var ledger_more_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -76,20 +78,70 @@ func _ready() -> void:
|
||||
confirm_dialog.confirmed.connect(_confirm_purchase)
|
||||
add_child(confirm_dialog)
|
||||
|
||||
_build_ledger()
|
||||
_build_footer()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh)
|
||||
CreditsService.credits_failed.connect(_show_error)
|
||||
CreditsService.store_operation_completed.connect(_show_message)
|
||||
CreditsService.ledger_changed.connect(_render_ledger)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
|
||||
_refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CreditsService.refresh()
|
||||
CreditsService.load_ledger()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_ledger() -> void:
|
||||
var heading := Label.new()
|
||||
heading.text = "Coin history"
|
||||
heading.add_theme_font_override("font", Brand.font)
|
||||
heading.add_theme_font_size_override("font_size", 18)
|
||||
add_child(heading)
|
||||
ledger_list = VBoxContainer.new()
|
||||
ledger_list.add_theme_constant_override("separation", 6)
|
||||
add_child(ledger_list)
|
||||
ledger_more_button = Button.new()
|
||||
ledger_more_button.text = "Load more"
|
||||
ledger_more_button.custom_minimum_size = Vector2(130, 40)
|
||||
ledger_more_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
ledger_more_button.pressed.connect(func(): CreditsService.load_ledger(CreditsService.ledger_next_before))
|
||||
add_child(ledger_more_button)
|
||||
|
||||
|
||||
func _render_ledger() -> void:
|
||||
if ledger_list == null:
|
||||
return
|
||||
for child in ledger_list.get_children():
|
||||
child.queue_free()
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
if CreditsService.ledger.is_empty():
|
||||
var empty := Label.new()
|
||||
empty.text = "Learning rewards and purchases will appear here."
|
||||
empty.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
ledger_list.add_child(empty)
|
||||
for item_variant in CreditsService.ledger:
|
||||
if typeof(item_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var item: Dictionary = item_variant
|
||||
var row := HBoxContainer.new()
|
||||
var description := Label.new()
|
||||
description.text = str(item.get("description", "RZ Coin activity"))
|
||||
description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
row.add_child(description)
|
||||
var amount := int(item.get("amount", 0))
|
||||
var amount_label := Label.new()
|
||||
amount_label.text = "%+d" % amount
|
||||
amount_label.add_theme_color_override("font_color", Brand.SUCCESS if amount > 0 else Brand.MAGENTA)
|
||||
row.add_child(amount_label)
|
||||
ledger_list.add_child(row)
|
||||
ledger_more_button.visible = not CreditsService.ledger_next_before.is_empty()
|
||||
|
||||
|
||||
func _build_header() -> void:
|
||||
var title := Label.new()
|
||||
title.text = "RZ Coin Shop"
|
||||
@@ -258,7 +310,7 @@ func _build_footer() -> void:
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(footer_heading)
|
||||
footer_description = Label.new()
|
||||
footer_description.text = "Answer questions correctly on the first try, within the time limit."
|
||||
footer_description.text = "Earn coins for first-correct answers, difficult questions, delayed reviews, mastery milestones, and completed exam simulations."
|
||||
footer_description.add_theme_font_override("font", Brand.font)
|
||||
footer_description.add_theme_font_size_override("font_size", 12)
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
Reference in New Issue
Block a user