Initial STEP 3 study game and questions API
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
extends Node
|
||||
|
||||
signal auth_changed
|
||||
|
||||
var is_logged_in := false
|
||||
var username := ""
|
||||
var role := ""
|
||||
var access_token := ""
|
||||
var refresh_token := ""
|
||||
var expires_at := ""
|
||||
|
||||
func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "") -> void:
|
||||
is_logged_in = not token.is_empty()
|
||||
username = new_username
|
||||
access_token = token
|
||||
refresh_token = new_refresh_token
|
||||
role = new_role
|
||||
expires_at = token_expiration
|
||||
auth_changed.emit()
|
||||
|
||||
|
||||
func clear_session() -> void:
|
||||
is_logged_in = false
|
||||
username = ""
|
||||
role = ""
|
||||
access_token = ""
|
||||
refresh_token = ""
|
||||
expires_at = ""
|
||||
auth_changed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cf03tijcjcp1j
|
||||
@@ -0,0 +1,104 @@
|
||||
extends Node
|
||||
|
||||
signal session_changed
|
||||
|
||||
var active_questions: Array = []
|
||||
var answers: Array = []
|
||||
var current_index := 0
|
||||
var mode := "Tutor"
|
||||
var system_name := "All Systems"
|
||||
var seconds_remaining := 0.0
|
||||
var block_started := false
|
||||
|
||||
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
|
||||
system_name = selected_system
|
||||
mode = selected_mode
|
||||
active_questions = QuestionBankService.build_block(selected_system, block_size)
|
||||
answers.clear()
|
||||
answers.resize(active_questions.size())
|
||||
current_index = 0
|
||||
seconds_remaining = float(active_questions.size() * 90)
|
||||
block_started = true
|
||||
session_changed.emit()
|
||||
|
||||
|
||||
func get_current_question() -> Dictionary:
|
||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||
return {}
|
||||
return active_questions[current_index]
|
||||
|
||||
|
||||
func answer_current(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
|
||||
}
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return answer
|
||||
|
||||
|
||||
func move_previous() -> bool:
|
||||
if current_index > 0:
|
||||
current_index -= 1
|
||||
session_changed.emit()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func move_next() -> bool:
|
||||
if current_index + 1 < active_questions.size():
|
||||
current_index += 1
|
||||
session_changed.emit()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func move_to(index: int) -> bool:
|
||||
if index >= 0 and index < active_questions.size():
|
||||
current_index = index
|
||||
session_changed.emit()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func get_progress_text() -> String:
|
||||
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
|
||||
|
||||
|
||||
func finish_block() -> Dictionary:
|
||||
var final_answers: Array = []
|
||||
for index in range(active_questions.size()):
|
||||
if answers[index] == null:
|
||||
final_answers.append({
|
||||
"question": active_questions[index].duplicate(true),
|
||||
"selected_choice_id": "",
|
||||
"is_correct": false,
|
||||
"eliminated_choice_ids": [],
|
||||
"flagged": false
|
||||
})
|
||||
else:
|
||||
final_answers.append(answers[index])
|
||||
|
||||
var results := ProgressionService.calculate_block_rewards(final_answers, mode, system_name)
|
||||
ProfileService.apply_block_results(results)
|
||||
block_started = false
|
||||
session_changed.emit()
|
||||
return results
|
||||
|
||||
|
||||
func has_answered_current() -> bool:
|
||||
return get_answer_for_index(current_index) != null
|
||||
|
||||
|
||||
func get_answer_for_index(index: int) -> Variant:
|
||||
if index < 0 or index >= answers.size():
|
||||
return null
|
||||
return answers[index]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgbbs67a8wdl4
|
||||
@@ -0,0 +1,105 @@
|
||||
extends Node
|
||||
|
||||
signal profile_changed
|
||||
|
||||
const SAVE_PATH := "user://profile.json"
|
||||
|
||||
var profile: Dictionary = {
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"current_streak": 0,
|
||||
"best_streak": 0,
|
||||
"badges": [],
|
||||
"completed_question_ids": [],
|
||||
"system_stats": {},
|
||||
"block_history": []
|
||||
}
|
||||
|
||||
func _ready() -> void:
|
||||
load_profile()
|
||||
|
||||
|
||||
func load_profile() -> void:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
save_profile()
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("Unable to open profile save.")
|
||||
return
|
||||
|
||||
var parsed = JSON.parse_string(file.get_as_text())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
for key in profile.keys():
|
||||
if parsed.has(key):
|
||||
profile[key] = parsed[key]
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func save_profile() -> void:
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("Unable to write profile save.")
|
||||
return
|
||||
file.store_string(JSON.stringify(profile, "\t"))
|
||||
|
||||
|
||||
func get_profile() -> Dictionary:
|
||||
return profile.duplicate(true)
|
||||
|
||||
|
||||
func apply_block_results(results: Dictionary) -> void:
|
||||
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"]))
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
var summary: Dictionary = {
|
||||
"timestamp": Time.get_datetime_string_from_system(),
|
||||
"mode": results.get("mode", "Tutor"),
|
||||
"system": results.get("system", "All Systems"),
|
||||
"correct": results.get("correct", 0),
|
||||
"total": results.get("total", 0),
|
||||
"xp_earned": results.get("xp_earned", 0)
|
||||
}
|
||||
profile["block_history"].push_front(summary)
|
||||
if profile["block_history"].size() > 12:
|
||||
profile["block_history"].resize(12)
|
||||
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_profile() -> void:
|
||||
profile = {
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"current_streak": 0,
|
||||
"best_streak": 0,
|
||||
"badges": [],
|
||||
"completed_question_ids": [],
|
||||
"system_stats": {},
|
||||
"block_history": []
|
||||
}
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://epx6yqvy5nf6
|
||||
@@ -0,0 +1,67 @@
|
||||
extends Node
|
||||
|
||||
const XP_PER_LEVEL := 500
|
||||
|
||||
var badge_catalog := {
|
||||
"First Block": "Complete your first question block.",
|
||||
"Hot Streak": "Answer 5 questions correctly in a row.",
|
||||
"Tenacious": "Complete 10 total questions.",
|
||||
"Sharp Shooter": "Score at least 80% on a block.",
|
||||
"Clock Ready": "Complete a timed block."
|
||||
}
|
||||
|
||||
func level_for_xp(xp: int) -> int:
|
||||
return int(floor(float(xp) / float(XP_PER_LEVEL))) + 1
|
||||
|
||||
|
||||
func xp_into_level(xp: int) -> int:
|
||||
return xp % XP_PER_LEVEL
|
||||
|
||||
|
||||
func calculate_block_rewards(answers: Array, mode: String, system_name: String) -> Dictionary:
|
||||
var correct := 0
|
||||
var longest_streak := 0
|
||||
var running_streak := int(ProfileService.get_profile().get("current_streak", 0))
|
||||
var ending_streak := running_streak
|
||||
|
||||
for answer in answers:
|
||||
if bool(answer.get("is_correct", false)):
|
||||
correct += 1
|
||||
ending_streak += 1
|
||||
longest_streak = max(longest_streak, ending_streak)
|
||||
else:
|
||||
ending_streak = 0
|
||||
|
||||
var total: int = max(answers.size(), 1)
|
||||
var accuracy := float(correct) / float(total)
|
||||
var xp := answers.size() * 25 + correct * 35 + longest_streak * 10
|
||||
if accuracy >= 0.8:
|
||||
xp += 100
|
||||
if mode == "Timed":
|
||||
xp += 50
|
||||
|
||||
var profile: Dictionary = ProfileService.get_profile()
|
||||
var badges: Array[String] = []
|
||||
_add_badge_if_needed(badges, profile, "First Block", true)
|
||||
_add_badge_if_needed(badges, profile, "Hot Streak", longest_streak >= 5)
|
||||
_add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + answers.size() >= 10)
|
||||
_add_badge_if_needed(badges, profile, "Sharp Shooter", accuracy >= 0.8)
|
||||
_add_badge_if_needed(badges, profile, "Clock Ready", mode == "Timed")
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"system": system_name,
|
||||
"correct": correct,
|
||||
"total": answers.size(),
|
||||
"accuracy": accuracy,
|
||||
"xp_earned": xp,
|
||||
"ending_streak": ending_streak,
|
||||
"longest_streak": longest_streak,
|
||||
"badges_unlocked": badges,
|
||||
"answers": answers
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2w2nbhsf6uv6
|
||||
@@ -0,0 +1,142 @@
|
||||
extends Node
|
||||
|
||||
const QUESTION_FILE := "res://data/questions/sample_questions.json"
|
||||
const DEFAULT_QUESTIONS_API_URL := "http://localhost:5008/api/questions/search"
|
||||
|
||||
signal questions_changed
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
var questions_api_url := DEFAULT_QUESTIONS_API_URL
|
||||
|
||||
func _ready() -> void:
|
||||
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
|
||||
request = HTTPRequest.new()
|
||||
request.request_completed.connect(_on_api_questions_loaded)
|
||||
add_child(request)
|
||||
load_questions()
|
||||
|
||||
|
||||
func load_questions() -> void:
|
||||
questions.clear()
|
||||
var file := FileAccess.open(QUESTION_FILE, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("Unable to open question file: %s" % QUESTION_FILE)
|
||||
return
|
||||
|
||||
var parsed = JSON.parse_string(file.get_as_text())
|
||||
if typeof(parsed) != TYPE_ARRAY:
|
||||
push_error("Question file must contain an array.")
|
||||
return
|
||||
|
||||
for item in parsed:
|
||||
if _is_valid_question(item):
|
||||
questions.append(item)
|
||||
else:
|
||||
push_warning("Skipping invalid question: %s" % str(item.get("id", "unknown")))
|
||||
|
||||
questions_changed.emit()
|
||||
|
||||
|
||||
func load_questions_from_api() -> void:
|
||||
if AuthState.access_token.is_empty():
|
||||
return
|
||||
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % AuthState.access_token
|
||||
])
|
||||
var payload := {
|
||||
"limit": 80
|
||||
}
|
||||
var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
push_warning("Failed to request questions API. Using local question bank.")
|
||||
|
||||
|
||||
func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
push_warning("Questions API request failed. Using local question bank.")
|
||||
return
|
||||
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_ARRAY:
|
||||
push_warning("Questions API response was not an array. Using local question bank.")
|
||||
return
|
||||
|
||||
var api_questions: Array = []
|
||||
for item in parsed:
|
||||
var normalized := _normalize_api_question(item)
|
||||
if _is_valid_question(normalized):
|
||||
api_questions.append(normalized)
|
||||
|
||||
if api_questions.is_empty():
|
||||
push_warning("Questions API returned no usable questions. Using local question bank.")
|
||||
return
|
||||
|
||||
questions = api_questions
|
||||
questions_changed.emit()
|
||||
|
||||
|
||||
func get_all_questions() -> Array:
|
||||
return questions.duplicate(true)
|
||||
|
||||
|
||||
func get_systems() -> Array[String]:
|
||||
var system_names: Array[String] = []
|
||||
for question in questions:
|
||||
var system_name := str(question.get("system", "General"))
|
||||
if not system_names.has(system_name):
|
||||
system_names.append(system_name)
|
||||
system_names.sort()
|
||||
return system_names
|
||||
|
||||
|
||||
func get_questions_for_system(system_name: String) -> Array:
|
||||
if system_name == "All Systems":
|
||||
return get_all_questions()
|
||||
|
||||
var filtered: Array = []
|
||||
for question in questions:
|
||||
if str(question.get("system", "")) == system_name:
|
||||
filtered.append(question.duplicate(true))
|
||||
return filtered
|
||||
|
||||
|
||||
func build_block(system_name: String, block_size: int) -> Array:
|
||||
var pool := get_questions_for_system(system_name)
|
||||
pool.shuffle()
|
||||
return pool.slice(0, min(block_size, pool.size()))
|
||||
|
||||
|
||||
func _is_valid_question(item: Variant) -> bool:
|
||||
if typeof(item) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var required := ["id", "stem", "lead_in", "choices", "correct_choice_id", "explanation", "system", "discipline"]
|
||||
for key in required:
|
||||
if not item.has(key):
|
||||
return false
|
||||
return typeof(item["choices"]) == TYPE_ARRAY and item["choices"].size() >= 2
|
||||
|
||||
|
||||
func _normalize_api_question(item: Variant) -> Dictionary:
|
||||
if typeof(item) != TYPE_DICTIONARY:
|
||||
return {}
|
||||
|
||||
var normalized := {
|
||||
"id": str(item.get("externalId", item.get("id", ""))),
|
||||
"api_id": str(item.get("id", "")),
|
||||
"stem": str(item.get("stem", "")),
|
||||
"lead_in": str(item.get("leadIn", item.get("lead_in", ""))),
|
||||
"choices": item.get("choices", []),
|
||||
"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", {})),
|
||||
"discipline": str(item.get("discipline", "")),
|
||||
"system": str(item.get("system", "")),
|
||||
"difficulty": int(item.get("difficulty", 1)),
|
||||
"labs": item.get("labs", []),
|
||||
"media": item.get("media", []),
|
||||
"tags": item.get("tags", [])
|
||||
}
|
||||
return normalized
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb16xajj1m36q
|
||||
Reference in New Issue
Block a user