Add daily study missions
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
extends Node
|
||||
|
||||
signal missions_changed
|
||||
signal missions_failed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/daily-missions"
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var request: HTTPRequest
|
||||
var state: Dictionary = {}
|
||||
var loading := false
|
||||
var pending_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/daily-missions")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
set_process(true)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("refresh", HTTPClient.METHOD_GET, "")
|
||||
|
||||
|
||||
func replace(mission_id: String) -> void:
|
||||
if loading or mission_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("replace", HTTPClient.METHOD_POST, "/%s/replace" % mission_id.uri_encode())
|
||||
|
||||
|
||||
func apply_response(parsed: Dictionary) -> void:
|
||||
var next_state: Variant = parsed.get("dailyMissions", null)
|
||||
if typeof(next_state) == TYPE_DICTIONARY:
|
||||
state = next_state
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _send(action: String, method: int, suffix: String) -> void:
|
||||
loading = true
|
||||
pending_action = action
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
var error := request.request(api_url + suffix, headers, method)
|
||||
if error != OK:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
missions_failed.emit("Daily missions could not be loaded.")
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
missions_failed.emit("Daily mission request failed.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY:
|
||||
missions_failed.emit("The daily mission response was invalid.")
|
||||
return
|
||||
state = parsed.get("state", {})
|
||||
CreditsService.apply_award_response(parsed)
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if state.is_empty() or loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z"))
|
||||
if expires > 0 and Time.get_unix_time_from_system() >= expires:
|
||||
state = {}
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
state = {}
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request.cancel_request()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwafdsrqdxleo
|
||||
@@ -552,6 +552,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
# Let Tutor users inspect the final answer before the summary replaces the player.
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
|
||||
@@ -198,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
||||
var synced_is_correct := false
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
||||
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
|
||||
@@ -23,6 +23,7 @@ var focus_content: VBoxContainer
|
||||
var review_list: VBoxContainer
|
||||
var recent_list: VBoxContainer
|
||||
var weekly_summary: VBoxContainer
|
||||
var mission_content: VBoxContainer
|
||||
var metric_values: Dictionary = {}
|
||||
var metric_details: Dictionary = {}
|
||||
var metric_trends: Dictionary = {}
|
||||
@@ -34,6 +35,7 @@ var pending_review_launch := false
|
||||
var review_queue_service: Variant
|
||||
var trend_mode := "rating"
|
||||
var mastery_rows: Array[BoxContainer] = []
|
||||
var mission_rows: Array[BoxContainer] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -41,6 +43,8 @@ func _ready() -> void:
|
||||
review_queue_service = get_node("/root/ReviewQueueService")
|
||||
DashboardService.dashboard_changed.connect(_render)
|
||||
DashboardService.dashboard_failed.connect(_show_error)
|
||||
DailyMissionService.missions_changed.connect(_render_missions)
|
||||
DailyMissionService.missions_failed.connect(_show_mission_error)
|
||||
review_queue_service.question_loaded.connect(_on_review_question_loaded)
|
||||
review_queue_service.question_failed.connect(_on_review_question_failed)
|
||||
review_queue_service.pass_completed.connect(_on_review_pass_completed)
|
||||
@@ -55,6 +59,7 @@ func on_show(_payload: Variant = null) -> void:
|
||||
else:
|
||||
_render()
|
||||
DashboardService.refresh()
|
||||
DailyMissionService.refresh()
|
||||
|
||||
|
||||
func _build() -> void:
|
||||
@@ -65,6 +70,7 @@ func _build() -> void:
|
||||
metric_details.clear()
|
||||
metric_trends.clear()
|
||||
mastery_rows.clear()
|
||||
mission_rows.clear()
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -82,6 +88,9 @@ func _build() -> void:
|
||||
stat_card_grid.add_theme_constant_override("h_separation", 12)
|
||||
stat_card_grid.add_theme_constant_override("v_separation", 12)
|
||||
content.add_child(stat_card_grid)
|
||||
mission_content = VBoxContainer.new()
|
||||
mission_content.add_theme_constant_override("separation", 10)
|
||||
content.add_child(_card(mission_content, 16))
|
||||
|
||||
qbank_sparkline = DashboardSparkline.new()
|
||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
@@ -211,6 +220,88 @@ func _render() -> void:
|
||||
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
||||
_render_weekly(data, weekly)
|
||||
_render_recent(data.get("recentActivity", []))
|
||||
_render_missions()
|
||||
|
||||
|
||||
func _render_missions() -> void:
|
||||
if mission_content == null:
|
||||
return
|
||||
_clear(mission_content)
|
||||
mission_rows.clear()
|
||||
var header := HBoxContainer.new()
|
||||
header.add_child(_section_title("Daily missions"))
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
header.add_child(spacer)
|
||||
header.add_child(_label("Resets at 00:00 UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
mission_content.add_child(header)
|
||||
var missions: Array = DailyMissionService.state.get("missions", [])
|
||||
if missions.is_empty():
|
||||
mission_content.add_child(_label("Loading today's focused study missions...", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
return
|
||||
var replacement_available := bool(DailyMissionService.state.get("replacementAvailable", false))
|
||||
for mission_value in missions:
|
||||
var mission: Dictionary = mission_value
|
||||
var row := BoxContainer.new()
|
||||
row.vertical = size.x < 700.0
|
||||
mission_rows.append(row)
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
var details := VBoxContainer.new()
|
||||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var completed := bool(mission.get("completed", false))
|
||||
var title := _label(str(mission.get("title", "Study mission")), 14,
|
||||
Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
details.add_child(title)
|
||||
var progress_value := mini(int(mission.get("progress", 0)), int(mission.get("target", 1)))
|
||||
var target := maxi(1, int(mission.get("target", 1)))
|
||||
var progress := ProgressBar.new()
|
||||
progress.show_percentage = false
|
||||
progress.max_value = target
|
||||
progress.value = progress_value
|
||||
progress.custom_minimum_size.y = 7
|
||||
_style_progress(progress, Brand.SUCCESS if completed else Brand.ROYAL_BLUE)
|
||||
details.add_child(progress)
|
||||
details.add_child(_label("Complete - 3 RZ Coins earned" if completed else "%d of %d" % [progress_value, target],
|
||||
11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
row.add_child(details)
|
||||
var launch := _button("Review" if str(mission.get("type", "")) == "due_reviews" else "Start")
|
||||
launch.disabled = completed
|
||||
launch.pressed.connect(_launch_mission.bind(mission))
|
||||
row.add_child(launch)
|
||||
if replacement_available and not completed:
|
||||
var replace := Button.new()
|
||||
replace.text = "Replace"
|
||||
replace.flat = true
|
||||
replace.pressed.connect(_replace_mission.bind(str(mission.get("id", ""))))
|
||||
row.add_child(replace)
|
||||
mission_content.add_child(row)
|
||||
|
||||
|
||||
func _launch_mission(mission: Dictionary) -> void:
|
||||
if str(mission.get("type", "")) == "due_reviews":
|
||||
_start_review_queue()
|
||||
return
|
||||
main.show_screen("setup", {
|
||||
"mastery_target": true,
|
||||
"step3_system": str(mission.get("step3System", "")),
|
||||
"question_count": 10,
|
||||
"difficulty_min": int(mission.get("difficultyMin", 0)),
|
||||
"difficulty_max": 5 if int(mission.get("difficultyMin", 0)) > 0 else 0
|
||||
})
|
||||
|
||||
|
||||
func _replace_mission(mission_id: String) -> void:
|
||||
DailyMissionService.replace(mission_id)
|
||||
_render_missions()
|
||||
|
||||
|
||||
func _show_mission_error(message: String) -> void:
|
||||
if mission_content == null:
|
||||
return
|
||||
_clear(mission_content)
|
||||
mission_content.add_child(_section_title("Daily missions"))
|
||||
mission_content.add_child(_label(message, 13, Brand.MAGENTA))
|
||||
|
||||
|
||||
func _trend_series(trend: Array, field: String) -> Array:
|
||||
@@ -837,6 +928,8 @@ func _update_responsive() -> void:
|
||||
for child in row.get_children():
|
||||
if child is Button:
|
||||
child.custom_minimum_size.x = 0.0 if narrow else 155.0
|
||||
for row in mission_rows:
|
||||
row.vertical = narrow
|
||||
|
||||
|
||||
func _clear(container: Container) -> void:
|
||||
|
||||
Reference in New Issue
Block a user