Add core game retry flow and question stats

This commit is contained in:
hz
2026-07-10 14:05:57 -05:00
parent 1822c00a3b
commit e991dccbb2
17 changed files with 1036 additions and 80 deletions
+211 -1
View File
@@ -9,10 +9,26 @@ var mode := "Tutor"
var system_name := "All Systems"
var seconds_remaining := 0.0
var block_started := false
var session_kind := "block"
var core_question_pool: Array = []
var core_answer_count := 0
var core_score := 0
var core_energy := 70
var core_target_seconds := 90.0
var core_current_target_seconds := 90.0
var core_current_had_wrong_attempt := false
var current_question_started_at := 0.0
var current_question_pause_started_at := 0.0
var core_status_message := ""
const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0
const CORE_RETRY_TARGET_SECONDS := 30.0
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
system_name = selected_system
mode = selected_mode
session_kind = "block"
active_questions = QuestionBankService.build_block(selected_system, block_size)
answers.clear()
answers.resize(active_questions.size())
@@ -22,6 +38,31 @@ func start_block(selected_system: String, block_size: int, selected_mode: String
session_changed.emit()
func start_core_run() -> bool:
system_name = "All Systems"
mode = "Core"
session_kind = "core"
active_questions.clear()
answers.clear()
core_question_pool.clear()
current_index = -1
seconds_remaining = 0.0
block_started = true
core_answer_count = 0
core_score = 0
core_energy = 70
core_target_seconds = 90.0
core_current_target_seconds = core_target_seconds
core_current_had_wrong_attempt = false
core_status_message = ""
if not advance_core_question():
block_started = false
core_status_message = "All available questions cleared."
session_changed.emit()
return false
return true
func get_current_question() -> Dictionary:
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
return {}
@@ -38,13 +79,57 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict
"selected_choice_id": choice_id,
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
"eliminated_choice_ids": eliminated.duplicate(),
"flagged": flagged
"flagged": flagged,
"elapsed_seconds": get_current_elapsed_seconds(),
"target_seconds": core_target_seconds if session_kind == "core" else 0.0
}
answers[current_index] = answer
if session_kind == "core":
_apply_core_answer_rewards(answer)
session_changed.emit()
return answer
func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
var question := get_current_question()
if question.is_empty():
return {}
var answer := {
"question": question.duplicate(true),
"selected_choice_id": choice_id,
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
"eliminated_choice_ids": eliminated.duplicate(),
"flagged": flagged,
"elapsed_seconds": get_current_elapsed_seconds(),
"target_seconds": core_current_target_seconds
}
if bool(answer.get("is_correct", false)):
var mastered := not core_current_had_wrong_attempt
answers[current_index] = answer
_apply_core_answer_rewards(answer, mastered)
session_changed.emit()
return {
"answer": answer,
"is_correct": true,
"final": true,
"mastered": mastered
}
ProfileService.record_wrong_attempt(question)
core_current_had_wrong_attempt = true
_apply_core_wrong_penalty(answer)
_start_core_retry_window()
session_changed.emit()
return {
"answer": answer,
"is_correct": false,
"final": false,
"mastered": false
}
func move_previous() -> bool:
if current_index > 0:
current_index -= 1
@@ -54,6 +139,8 @@ func move_previous() -> bool:
func move_next() -> bool:
if session_kind == "core":
return advance_core_question()
if current_index + 1 < active_questions.size():
current_index += 1
session_changed.emit()
@@ -70,10 +157,17 @@ func move_to(index: int) -> bool:
func get_progress_text() -> String:
if session_kind == "core":
return str(core_answer_count + 1)
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
func finish_block() -> Dictionary:
if session_kind == "core":
block_started = false
session_changed.emit()
return {}
var final_answers: Array = []
for index in range(active_questions.size()):
if answers[index] == null:
@@ -102,3 +196,119 @@ func get_answer_for_index(index: int) -> Variant:
if index < 0 or index >= answers.size():
return null
return answers[index]
func advance_core_question() -> bool:
var question := _draw_core_question()
if question.is_empty():
core_status_message = "All available questions cleared."
return false
active_questions.append(question)
answers.append(null)
current_index = active_questions.size() - 1
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
core_current_target_seconds = core_target_seconds
core_current_had_wrong_attempt = false
session_changed.emit()
return true
func get_current_elapsed_seconds() -> float:
if current_question_started_at <= 0.0:
return 0.0
var now := current_question_pause_started_at if current_question_pause_started_at > 0.0 else Time.get_ticks_msec() / 1000.0
return max(0.0, now - current_question_started_at)
func get_core_timer_ratio() -> float:
if session_kind != "core" or core_current_target_seconds <= 0.0:
return 1.0
var ratio := 1.0 - (get_current_elapsed_seconds() / core_current_target_seconds)
return clamp(ratio, 0.0, 1.0)
func pause_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at <= 0.0:
current_question_pause_started_at = Time.get_ticks_msec() / 1000.0
func resume_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at > 0.0:
var paused_duration := Time.get_ticks_msec() / 1000.0 - current_question_pause_started_at
current_question_started_at += paused_duration
current_question_pause_started_at = 0.0
func _draw_core_question() -> Dictionary:
if core_question_pool.is_empty():
core_question_pool = _build_core_question_pool()
core_question_pool.shuffle()
if core_question_pool.is_empty():
return {}
var last_id := str(get_current_question().get("id", ""))
for index in range(core_question_pool.size()):
var candidate: Dictionary = core_question_pool[index]
if str(candidate.get("id", "")) != last_id or core_question_pool.size() == 1:
core_question_pool.remove_at(index)
return candidate.duplicate(true)
return {}
func _build_core_question_pool() -> Array:
var pool: Array = []
var profile := ProfileService.get_profile()
var correct_ids: Array = profile.get("correct_question_ids", [])
for question in QuestionBankService.get_all_questions():
var question_id := str(question.get("id", ""))
if question_id == "" or not correct_ids.has(question_id):
pool.append(question)
return pool
func _start_core_retry_window() -> void:
core_current_target_seconds = CORE_RETRY_TARGET_SECONDS
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
func _apply_core_wrong_penalty(_answer: Dictionary) -> void:
core_score -= 25
core_energy = max(0, core_energy - 14)
func _apply_core_answer_rewards(answer: Dictionary, mastered: bool = true) -> void:
core_answer_count += 1
var elapsed := float(answer.get("elapsed_seconds", 0.0))
var target := core_current_target_seconds
if target < CORE_MIN_TARGET_SECONDS:
target = CORE_MIN_TARGET_SECONDS
var is_correct := bool(answer.get("is_correct", false))
var score_delta := 100 if is_correct else 0
if elapsed > target:
var overage_ratio := (elapsed - target) / target
if overage_ratio > 1.0:
overage_ratio = 1.0
score_delta -= int(round(40.0 * overage_ratio))
if not is_correct:
score_delta -= 25
core_score += score_delta
if is_correct:
core_energy = min(100, core_energy + 8)
else:
core_energy = max(0, core_energy - 14)
if elapsed > target:
core_energy = max(0, core_energy - 4)
if mastered and is_correct and elapsed <= target:
core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.97)
elif not mastered or not is_correct or elapsed > target * 1.25:
core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05)
else:
core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.99)
var results := ProgressionService.calculate_question_rewards(answer, elapsed, target, score_delta)
results["mastered"] = mastered
ProfileService.apply_question_results(results)
+110 -7
View File
@@ -11,8 +11,15 @@ var profile: Dictionary = {
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"correct_question_ids": [],
"question_wrong_counts": {},
"missed_question_ids": [],
"system_stats": {},
"block_history": []
"block_history": [],
"daily_date": "",
"daily_questions_answered": 0,
"daily_xp_earned": 0,
"daily_goal_questions": 10
}
func _ready() -> void:
@@ -21,6 +28,7 @@ func _ready() -> void:
func load_profile() -> void:
if not FileAccess.file_exists(SAVE_PATH):
_ensure_daily_current()
save_profile()
return
@@ -34,6 +42,8 @@ func load_profile() -> void:
for key in profile.keys():
if parsed.has(key):
profile[key] = parsed[key]
_ensure_tracking_fields()
_ensure_daily_current()
profile_changed.emit()
@@ -46,14 +56,18 @@ func save_profile() -> void:
func get_profile() -> Dictionary:
_ensure_daily_current()
return profile.duplicate(true)
func apply_block_results(results: Dictionary) -> void:
_ensure_daily_current()
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
var unlocked_badges: Array = results.get("badges_unlocked", [])
for badge in unlocked_badges:
@@ -66,13 +80,10 @@ func apply_block_results(results: Dictionary) -> void:
var question_id := str(question.get("id", ""))
if question_id != "" and not profile["completed_question_ids"].has(question_id):
profile["completed_question_ids"].append(question_id)
_apply_question_tracking(question_id, bool(answer.get("is_correct", false)))
var system_name := str(question.get("system", "General"))
if not profile["system_stats"].has(system_name):
profile["system_stats"][system_name] = {"seen": 0, "correct": 0}
profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1
if bool(answer.get("is_correct", false)):
profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1
_apply_system_result(system_name, bool(answer.get("is_correct", false)))
var summary: Dictionary = {
"timestamp": Time.get_datetime_string_from_system(),
@@ -98,8 +109,100 @@ func reset_profile() -> void:
"best_streak": 0,
"badges": [],
"completed_question_ids": [],
"correct_question_ids": [],
"question_wrong_counts": {},
"missed_question_ids": [],
"system_stats": {},
"block_history": []
"block_history": [],
"daily_date": _today_string(),
"daily_questions_answered": 0,
"daily_xp_earned": 0,
"daily_goal_questions": 10
}
save_profile()
profile_changed.emit()
func apply_question_results(results: Dictionary) -> void:
_ensure_daily_current()
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
var unlocked_badges: Array = results.get("badges_unlocked", [])
for badge in unlocked_badges:
if not profile["badges"].has(badge):
profile["badges"].append(badge)
var answers: Array = results.get("answers", [])
for answer in answers:
var question: Dictionary = answer.get("question", {})
var question_id := str(question.get("id", ""))
if question_id != "" and not profile["completed_question_ids"].has(question_id):
profile["completed_question_ids"].append(question_id)
_apply_question_tracking(question_id, bool(answer.get("is_correct", false)), bool(results.get("mastered", true)))
_apply_system_result(str(question.get("system", "General")), bool(answer.get("is_correct", false)))
save_profile()
profile_changed.emit()
func record_wrong_attempt(question: Dictionary) -> void:
_ensure_daily_current()
var question_id := str(question.get("id", ""))
_apply_question_tracking(question_id, false, false)
save_profile()
profile_changed.emit()
func _ensure_daily_current() -> void:
_ensure_tracking_fields()
var today := _today_string()
var changed := false
if not profile.has("daily_goal_questions"):
profile["daily_goal_questions"] = 10
changed = true
if str(profile.get("daily_date", "")) != today:
profile["daily_date"] = today
profile["daily_questions_answered"] = 0
profile["daily_xp_earned"] = 0
changed = true
if changed:
save_profile()
func _ensure_tracking_fields() -> void:
if not profile.has("correct_question_ids"):
profile["correct_question_ids"] = []
if not profile.has("question_wrong_counts"):
profile["question_wrong_counts"] = {}
if not profile.has("missed_question_ids"):
profile["missed_question_ids"] = []
func _apply_question_tracking(question_id: String, is_correct: bool, mastered: bool = true) -> void:
if question_id == "":
return
if is_correct and mastered:
if not profile["correct_question_ids"].has(question_id):
profile["correct_question_ids"].append(question_id)
elif not is_correct:
if not profile["missed_question_ids"].has(question_id):
profile["missed_question_ids"].append(question_id)
profile["question_wrong_counts"][question_id] = int(profile["question_wrong_counts"].get(question_id, 0)) + 1
func _apply_system_result(system_name: String, is_correct: bool) -> void:
if not profile["system_stats"].has(system_name):
profile["system_stats"][system_name] = {"seen": 0, "correct": 0}
profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1
if is_correct:
profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1
func _today_string() -> String:
var date := Time.get_datetime_dict_from_system()
return "%04d-%02d-%02d" % [int(date["year"]), int(date["month"]), int(date["day"])]
+33
View File
@@ -4,6 +4,7 @@ const XP_PER_LEVEL := 500
var badge_catalog := {
"First Block": "Complete your first question block.",
"First Run": "Answer your first core mode question.",
"Hot Streak": "Answer 5 questions correctly in a row.",
"Tenacious": "Complete 10 total questions.",
"Sharp Shooter": "Score at least 80% on a block.",
@@ -62,6 +63,38 @@ func calculate_block_rewards(answers: Array, mode: String, system_name: String)
}
func calculate_question_rewards(answer: Dictionary, elapsed_seconds: float, target_seconds: float, score_delta: int) -> Dictionary:
var is_correct := bool(answer.get("is_correct", false))
var profile: Dictionary = ProfileService.get_profile()
var ending_streak := int(profile.get("current_streak", 0)) + 1 if is_correct else 0
var xp := 20
if is_correct:
xp += 35
if elapsed_seconds <= target_seconds:
xp += 10
elif elapsed_seconds > target_seconds * 1.5:
xp = max(10, xp - 10)
var badges: Array[String] = []
_add_badge_if_needed(badges, profile, "First Run", true)
_add_badge_if_needed(badges, profile, "Hot Streak", ending_streak >= 5)
_add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + 1 >= 10)
return {
"mode": "Core",
"system": str(answer.get("question", {}).get("system", "All Systems")),
"correct": 1 if is_correct else 0,
"total": 1,
"accuracy": 1.0 if is_correct else 0.0,
"xp_earned": xp,
"score_delta": score_delta,
"ending_streak": ending_streak,
"longest_streak": ending_streak,
"badges_unlocked": badges,
"answers": [answer]
}
func _add_badge_if_needed(badges: Array[String], profile: Dictionary, badge_name: String, condition: bool) -> void:
if condition and not profile.get("badges", []).has(badge_name):
badges.append(badge_name)
+65 -3
View File
@@ -1,15 +1,20 @@
extends Node
const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search"
const RETRY_DELAYS := [2, 5, 10, 20, 30]
signal questions_changed
signal questions_load_failed(message: String)
var questions: Array = []
var request: HTTPRequest
var retry_timer: Timer
var questions_api_url := DEFAULT_QUESTIONS_API_URL
var is_loading := false
var last_error := ""
var load_state := "idle"
var retry_attempt := 0
var next_retry_seconds := 0
func _ready() -> void:
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
@@ -17,13 +22,24 @@ func _ready() -> void:
request.request_completed.connect(_on_api_questions_loaded)
add_child(request)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.timeout.connect(_on_retry_timer_timeout)
add_child(retry_timer)
func load_questions_from_api() -> void:
func load_questions_from_api(reset_retry: bool = true) -> void:
if AuthState.access_token.is_empty():
_set_load_error("Login required before loading questions.")
_set_load_error("Login required before loading questions.", false)
return
if reset_retry:
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
is_loading = true
load_state = "retrying" if retry_attempt > 0 else "loading"
last_error = ""
questions.clear()
questions_changed.emit()
@@ -63,6 +79,31 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
questions = api_questions
last_error = ""
load_state = "ready"
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
questions_changed.emit()
func retry_now() -> void:
retry_attempt = 0
next_retry_seconds = 0
retry_timer.stop()
load_questions_from_api(false)
func clear_questions() -> void:
if request != null:
request.cancel_request()
if retry_timer != null:
retry_timer.stop()
questions.clear()
is_loading = false
last_error = ""
load_state = "idle"
retry_attempt = 0
next_retry_seconds = 0
questions_changed.emit()
@@ -101,14 +142,34 @@ func has_questions() -> bool:
return not questions.is_empty()
func _set_load_error(message: String) -> void:
func _set_load_error(message: String, schedule_retry: bool = true) -> void:
is_loading = false
last_error = message
push_warning(message)
questions_load_failed.emit(message)
if schedule_retry and not AuthState.access_token.is_empty():
_schedule_retry()
else:
load_state = "failed"
next_retry_seconds = 0
questions_changed.emit()
func _schedule_retry() -> void:
var delay_index := retry_attempt
if delay_index >= RETRY_DELAYS.size():
delay_index = RETRY_DELAYS.size() - 1
next_retry_seconds = int(RETRY_DELAYS[delay_index])
retry_attempt += 1
load_state = "retrying"
retry_timer.start(float(next_retry_seconds))
func _on_retry_timer_timeout() -> void:
next_retry_seconds = 0
load_questions_from_api(false)
func _is_valid_question(item: Variant) -> bool:
if typeof(item) != TYPE_DICTIONARY:
return false
@@ -132,6 +193,7 @@ func _normalize_api_question(item: Variant) -> Dictionary:
"correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))),
"explanation": str(item.get("explanation", "")),
"choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})),
"choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})),
"discipline": str(item.get("discipline", "")),
"system": str(item.get("system", "")),
"difficulty": int(item.get("difficulty", 1)),