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)
+1
View File
@@ -0,0 +1 @@
uid://ch2scupdmgjpj
+33 -2
View File
@@ -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", "")))
+190
View File
@@ -0,0 +1,190 @@
extends MarginContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main: Control
var summary: Label
var quota: Label
var status_label: Label
var token_labels: Dictionary = {}
var action_buttons: Array[Button] = []
var reset_dialog: ConfirmationDialog
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_ui()
AccessService.status_changed.connect(_render)
AccessService.operation_succeeded.connect(_on_success)
AccessService.operation_failed.connect(_on_failure)
AccessService.checkout_started.connect(_on_checkout_started)
UIThemeService.theme_changed.connect(refresh_theme_controls)
AccessService.refresh()
func on_show(_payload: Variant = null) -> void:
AccessService.refresh()
_render(AccessService.status)
func _build_ui() -> void:
var scroll := ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
var page := VBoxContainer.new()
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 20)
scroll.add_child(page)
var title := Label.new()
title.text = "Credits & Access"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 36)
page.add_child(title)
var intro := Label.new()
intro.text = "Study 20 QBank questions in a rolling 24-hour window. Eligible first attempts earn credits; 100 credits unlocks unlimited QBank access for 24 hours."
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.add_theme_font_override("font", Brand.font)
intro.add_theme_font_size_override("font_size", 17)
page.add_child(intro)
var overview := _panel()
page.add_child(overview)
var overview_content := VBoxContainer.new()
overview_content.add_theme_constant_override("separation", 10)
overview.add_child(overview_content)
summary = _label("", 24)
quota = _label("", 17)
overview_content.add_child(summary)
overview_content.add_child(quota)
var redeem := _button("Redeem 100 credits for 24 hours", AccessService.redeem_credits)
overview_content.add_child(redeem)
var token_grid := GridContainer.new()
token_grid.columns = 3
token_grid.add_theme_constant_override("h_separation", 16)
token_grid.add_theme_constant_override("v_separation", 16)
page.add_child(token_grid)
_add_access_card(token_grid, "day", "1 day", "$1.00", "24 hours")
_add_access_card(token_grid, "week", "1 week", "$5.00", "168 hours")
_add_access_card(token_grid, "month", "1 month", "$20.00", "30 days")
var extras := _panel()
page.add_child(extras)
var extras_content := VBoxContainer.new()
extras_content.add_theme_constant_override("separation", 12)
extras.add_child(extras_content)
extras_content.add_child(_label("Study tools", 24))
token_labels["exam"] = _label("", 16)
extras_content.add_child(token_labels["exam"])
extras_content.add_child(_button("Buy exam simulation token — $1.99",
func(): AccessService.begin_checkout("exam_token_1")))
token_labels["reset"] = _label("", 16)
extras_content.add_child(token_labels["reset"])
extras_content.add_child(_button("Buy QBank reset token — $4.99",
func(): AccessService.begin_checkout("qbank_reset_1")))
extras_content.add_child(_button("Use QBank reset token", _confirm_reset))
status_label = _label("", 15)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
page.add_child(status_label)
reset_dialog = ConfirmationDialog.new()
reset_dialog.title = "Reset QBank progress?"
reset_dialog.dialog_text = "This starts a new QBank cycle. Questions become unused and credit-eligible again. Historical aggregate samples, bookmarks, notes, and review content remain."
reset_dialog.ok_button_text = "Reset QBank"
reset_dialog.confirmed.connect(AccessService.reset_qbank)
add_child(reset_dialog)
refresh_theme_controls()
func _add_access_card(parent: GridContainer, token: String, heading: String, price: String, duration: String) -> void:
var panel := _panel()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
parent.add_child(panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
panel.add_child(content)
content.add_child(_label(heading, 24))
content.add_child(_label("%s of unlimited QBank access" % duration, 15))
token_labels[token] = _label("", 15)
content.add_child(token_labels[token])
content.add_child(_button("Buy token — %s" % price,
func(): AccessService.begin_checkout("access_%s_1" % token)))
content.add_child(_button("Activate token",
func(): AccessService.activate_token(token)))
func _render(value: Dictionary) -> void:
if summary == null:
return
var credits := int(value.get("credits", 0))
var unlimited := bool(value.get("unlimited", false))
var expiry := str(value.get("unlimitedUntilUtc", ""))
summary.text = "%d credits • %s" % [
credits,
("Unlimited QBank active until %s" % expiry) if unlimited else "Free QBank access"
]
quota.text = "%d of %d free questions remaining in the rolling window" % [
int(value.get("questionsRemaining", 20)),
int(value.get("freeQuestionLimit", 20))
]
token_labels.get("day", _label()).text = "%d available" % int(value.get("dayTokens", 0))
token_labels.get("week", _label()).text = "%d available" % int(value.get("weekTokens", 0))
token_labels.get("month", _label()).text = "%d available" % int(value.get("monthTokens", 0))
token_labels.get("exam", _label()).text = "Exam tokens: %d" % int(value.get("examTokens", 0))
token_labels.get("reset", _label()).text = "QBank reset tokens: %d" % int(value.get("resetTokens", 0))
func _confirm_reset() -> void:
reset_dialog.popup_centered(Vector2i(620, 260))
func _on_success(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
func _on_failure(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Color("ef4444"))
func _on_checkout_started(_purchase_id: String, _url: String) -> void:
status_label.text = "Complete checkout in the secure payment window."
func refresh_theme_controls() -> void:
for button in action_buttons:
var dark := UIThemeService.is_dark_mode()
button.add_theme_font_override("font", Brand.font)
button.add_theme_color_override("font_color", Brand.text_color(dark))
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true))
func _panel() -> PanelContainer:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
return panel
func _label(text := "", size := 16) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", size)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
return label
func _button(text: String, callback: Callable) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(0, 48)
button.pressed.connect(callback)
action_buttons.append(button)
return button
+1
View File
@@ -0,0 +1 @@
uid://bgkgwx6ds6cjn
+18 -1
View File
@@ -350,6 +350,11 @@ func _build_ui() -> void:
donations.main = self
screens_container.add_child(donations)
screens["donations"] = donations
var access_store := preload("res://scripts/ui/access_store_screen.gd").new()
access_store.name = "AccessStoreScreen"
access_store.main = self
screens_container.add_child(access_store)
screens["access_store"] = access_store
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog"
@@ -369,10 +374,16 @@ func _build_ui() -> void:
AuthState.auth_changed.connect(_on_auth_changed)
AuthState.session_expired.connect(_on_session_expired)
QuestionBankService.access_required.connect(_on_qbank_access_required)
_refresh_header_controls()
_build_app_menu()
func _on_qbank_access_required(_message: String) -> void:
if AuthState.is_logged_in:
show_screen("access_store")
func _build_sidebar() -> PanelContainer:
var panel := PanelContainer.new()
panel.name = "Sidebar"
@@ -426,6 +437,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
container.add_child(spacer)
container.add_child(HSeparator.new())
_add_nav_button(container, "access_store", "Credits & Access", "clock-3", true)
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "settings", "Settings", "settings", true)
@@ -471,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
"notes": show_screen("notes")
"bookmarks": show_screen("bookmarks")
"donations": show_screen("donations")
"access_store": show_screen("access_store")
"logout": _logout()
@@ -489,7 +502,8 @@ func _update_header_title(screen_name: String) -> void:
"settings": "Settings",
"notes": "Notes",
"bookmarks": "Bookmarks",
"donations": "Support RanaZe"
"donations": "Support RanaZe",
"access_store": "Credits & Access"
}
header_title.text = str(titles.get(screen_name, ""))
@@ -814,6 +828,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
var donations = screens.get("donations")
if donations != null and donations.has_method("refresh_theme_controls"):
donations.refresh_theme_controls()
var access_store = screens.get("access_store")
if access_store != null and access_store.has_method("refresh_theme_controls"):
access_store.refresh_theme_controls()
var player = screens.get("player")
if player != null and player.has_method("refresh_theme_controls"):
player.refresh_theme_controls()
+2
View File
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
key = "bookmarks"
elif screen_name == "donations":
key = "donations"
elif screen_name == "access_store":
key = "access_store"
return key
+16 -2
View File
@@ -137,6 +137,8 @@ func _connect_services() -> void:
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
ExamSimulationService.request_failed.connect(_on_request_failed)
if not AccessService.status_changed.is_connected(_on_access_changed):
AccessService.status_changed.connect(_on_access_changed)
func on_show(_payload: Variant = null) -> void:
@@ -147,6 +149,7 @@ func on_show(_payload: Variant = null) -> void:
load_more_button.disabled = true
ExamSimulationService.load_readiness()
ExamSimulationService.load_active()
AccessService.refresh()
_load_history_page()
@@ -223,7 +226,8 @@ func _render_active_section() -> void:
card_layout.add_child(actions)
var primary := Button.new()
primary.name = "PrimarySimulationAction"
primary.text = "Resume Simulation" if active else "Start Simulation"
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
primary.custom_minimum_size = Vector2(0, 48)
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
_style_gradient_button(primary)
@@ -452,6 +456,7 @@ func _on_simulation_loaded() -> void:
return
if pending_launch:
pending_launch = false
AccessService.refresh()
_route_loaded_simulation()
return
if not pending_simulation_id.is_empty():
@@ -483,6 +488,9 @@ func _route_loaded_simulation() -> void:
func _launch_or_resume() -> void:
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
main.show_screen("access_store")
return
pending_launch = true
pending_simulation_id = ""
_render_active_section()
@@ -538,6 +546,11 @@ func _on_request_failed(message: String) -> void:
_render_active_section()
func _on_access_changed(_status: Dictionary) -> void:
if visible:
_render_active_section()
func _update_responsive_layout() -> void:
var use_compact := size.x < 940
if compact_layout == use_compact:
@@ -564,7 +577,8 @@ func _active_history_summary() -> Dictionary:
func _readiness_message() -> String:
if bool(ExamSimulationService.readiness.get("ready", false)):
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
var tokens := int(AccessService.status.get("examTokens", 0))
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]