749 lines
24 KiB
GDScript
749 lines
24 KiB
GDScript
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 session_initial_seconds := 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_question_total_elapsed_seconds := 0.0
|
|
var core_question_total_active_since := 0.0
|
|
var core_question_total_frozen := false
|
|
var core_status_message := ""
|
|
var simulation_block_index := 0
|
|
var simulation_total_blocks := 12
|
|
var simulation_deadline_unix := 0.0
|
|
var review_first_submission_recorded := false
|
|
var core_block_size := DEFAULT_CORE_BLOCK_SIZE
|
|
|
|
const CORE_MIN_TARGET_SECONDS := 45.0
|
|
const CORE_MAX_TARGET_SECONDS := 150.0
|
|
const CORE_RETRY_TARGET_SECONDS := 30.0
|
|
const DEFAULT_CORE_BLOCK_SIZE := 10
|
|
const CORE_BLOCK_SIZE_MIN := 5
|
|
const CORE_BLOCK_SIZE_MAX := 40
|
|
const SETTINGS_PATH := "user://settings.cfg"
|
|
const SETTINGS_SECTION := "session"
|
|
const SETTINGS_KEY_CORE_BLOCK_SIZE := "core_block_size"
|
|
|
|
|
|
func _ready() -> void:
|
|
_load_core_block_size()
|
|
|
|
|
|
func set_core_block_size(value: int) -> void:
|
|
var clamped := clampi(value, CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX)
|
|
if clamped == core_block_size:
|
|
return
|
|
core_block_size = clamped
|
|
var config := ConfigFile.new()
|
|
config.load(SETTINGS_PATH)
|
|
config.set_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, core_block_size)
|
|
if config.save(SETTINGS_PATH) != OK:
|
|
push_warning("Unable to save core question block size preference.")
|
|
|
|
|
|
func _load_core_block_size() -> void:
|
|
var config := ConfigFile.new()
|
|
if config.load(SETTINGS_PATH) == OK:
|
|
core_block_size = clampi(
|
|
int(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, DEFAULT_CORE_BLOCK_SIZE)),
|
|
CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX)
|
|
|
|
|
|
func start_dynamic_question(question: Dictionary) -> bool:
|
|
if question.is_empty():
|
|
return false
|
|
system_name = str(question.get("system", "All Systems"))
|
|
mode = "Tutor"
|
|
session_kind = "dynamic"
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
seconds_remaining = 0.0
|
|
session_initial_seconds = 90.0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
block_started = true
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func replace_dynamic_question(question: Dictionary) -> bool:
|
|
if session_kind != "dynamic" or question.is_empty():
|
|
return false
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
session_changed.emit()
|
|
return true
|
|
|
|
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)
|
|
for index in range(active_questions.size()):
|
|
active_questions[index] = _prepare_question_choices(active_questions[index])
|
|
answers.clear()
|
|
answers.resize(active_questions.size())
|
|
current_index = 0
|
|
seconds_remaining = float(active_questions.size() * 90)
|
|
session_initial_seconds = seconds_remaining
|
|
block_started = true
|
|
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
|
|
session_initial_seconds = core_current_target_seconds
|
|
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_question_total_elapsed_seconds = 0.0
|
|
core_question_total_active_since = 0.0
|
|
core_question_total_frozen = 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 start_bookmark_question(question: Dictionary) -> bool:
|
|
if question.is_empty():
|
|
return false
|
|
system_name = str(question.get("system", "All Systems"))
|
|
mode = "Tutor"
|
|
session_kind = "bookmark"
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
seconds_remaining = 0.0
|
|
session_initial_seconds = 90.0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
block_started = true
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func replace_bookmark_question(question: Dictionary) -> bool:
|
|
if session_kind != "bookmark" or question.is_empty():
|
|
return false
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func start_review_question(question: Dictionary) -> bool:
|
|
if question.is_empty():
|
|
return false
|
|
system_name = str(question.get("system", "All Systems"))
|
|
mode = "Tutor"
|
|
session_kind = "review"
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
seconds_remaining = 0.0
|
|
session_initial_seconds = 90.0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
review_first_submission_recorded = false
|
|
block_started = true
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func replace_review_question(question: Dictionary) -> bool:
|
|
if session_kind != "review" or question.is_empty():
|
|
return false
|
|
active_questions = [_prepare_question_choices(question)]
|
|
answers = [null]
|
|
current_index = 0
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = 0.0
|
|
review_first_submission_recorded = false
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void:
|
|
system_name = "All Systems"
|
|
mode = "Simulation"
|
|
session_kind = "simulation"
|
|
active_questions = questions.duplicate(true)
|
|
for index in range(active_questions.size()):
|
|
active_questions[index] = _prepare_question_choices(active_questions[index])
|
|
answers.clear()
|
|
answers.resize(active_questions.size())
|
|
current_index = 0
|
|
simulation_block_index = block_index
|
|
simulation_total_blocks = total_blocks
|
|
simulation_deadline_unix = deadline_unix
|
|
seconds_remaining = maxf(0.0, deadline_unix - Time.get_unix_time_from_system())
|
|
session_initial_seconds = maxf(seconds_remaining, float(active_questions.size() * 90))
|
|
block_started = true
|
|
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
|
session_changed.emit()
|
|
|
|
|
|
func restore_simulation_responses(saved_responses: Array) -> void:
|
|
if session_kind != "simulation":
|
|
return
|
|
for response in saved_responses:
|
|
if typeof(response) != TYPE_DICTIONARY:
|
|
continue
|
|
var question_id := str(response.get("questionExternalId", ""))
|
|
for index in range(active_questions.size()):
|
|
if str(active_questions[index].get("id", "")) == question_id:
|
|
answers[index] = {
|
|
"question": active_questions[index].duplicate(true),
|
|
"selected_choice_id": str(response.get("choiceId", "")),
|
|
"is_correct": false,
|
|
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
|
"flagged": bool(response.get("flagged", false)),
|
|
"elapsed_seconds": int(response.get("elapsedSeconds", 0)),
|
|
"is_submitted": _simulation_response_is_submitted(response)
|
|
}
|
|
break
|
|
|
|
|
|
func _simulation_response_is_submitted(response: Dictionary) -> bool:
|
|
var submitted: Variant = response.get("isSubmitted", null)
|
|
if submitted == null:
|
|
return not str(response.get("choiceId", "")).is_empty()
|
|
return bool(submitted)
|
|
|
|
|
|
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, is_submitted := false) -> 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", "")),
|
|
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and (session_kind != "core" or not core_current_had_wrong_attempt),
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"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 == "simulation":
|
|
answer["is_correct"] = false
|
|
answer["is_submitted"] = is_submitted
|
|
answers[current_index] = answer
|
|
session_changed.emit()
|
|
return 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", "")),
|
|
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not core_current_had_wrong_attempt,
|
|
"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
|
|
_freeze_core_question_total_timer()
|
|
_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 submit_tutor_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt: bool) -> Dictionary:
|
|
var question := get_current_question()
|
|
if question.is_empty() or mode != "Tutor" or session_kind not in ["block", "dynamic"]:
|
|
return {}
|
|
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
|
var answer := {
|
|
"question": question.duplicate(true),
|
|
"selected_choice_id": choice_id,
|
|
"is_correct": current_correct and not had_wrong_attempt,
|
|
"first_attempt_is_correct": current_correct and not had_wrong_attempt,
|
|
"resolved_correct": current_correct,
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"flagged": flagged,
|
|
"elapsed_seconds": get_current_elapsed_seconds(),
|
|
"target_seconds": 0.0
|
|
}
|
|
if current_correct:
|
|
answers[current_index] = answer
|
|
session_changed.emit()
|
|
return {"answer": answer, "is_correct": current_correct, "final": current_correct}
|
|
|
|
|
|
func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt := false) -> Dictionary:
|
|
var question := get_current_question()
|
|
if session_kind != "bookmark" or 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", "")) and not had_wrong_attempt,
|
|
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"flagged": flagged,
|
|
"elapsed_seconds": get_current_elapsed_seconds(),
|
|
"target_seconds": 0.0
|
|
}
|
|
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
|
if current_correct:
|
|
answers[current_index] = answer
|
|
session_changed.emit()
|
|
return {"answer": answer, "is_correct": current_correct, "final": current_correct}
|
|
|
|
|
|
func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt := false) -> Dictionary:
|
|
var question := get_current_question()
|
|
if session_kind != "review" or question.is_empty():
|
|
return {}
|
|
var first_submission := not review_first_submission_recorded
|
|
var answer := {
|
|
"question": question.duplicate(true),
|
|
"selected_choice_id": choice_id,
|
|
"is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
|
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"flagged": flagged,
|
|
"elapsed_seconds": get_current_elapsed_seconds(),
|
|
"target_seconds": 0.0
|
|
}
|
|
review_first_submission_recorded = true
|
|
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
|
if current_correct:
|
|
answers[current_index] = answer
|
|
session_changed.emit()
|
|
return {
|
|
"answer": answer,
|
|
"is_correct": current_correct,
|
|
"final": current_correct,
|
|
"first_submission": first_submission
|
|
}
|
|
|
|
|
|
func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary:
|
|
var question := get_current_question()
|
|
if session_kind != "bookmark" or question.is_empty():
|
|
return {}
|
|
var answer := {
|
|
"question": question.duplicate(true),
|
|
"selected_choice_id": "",
|
|
"is_correct": false,
|
|
"is_skip": true,
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"flagged": flagged,
|
|
"elapsed_seconds": get_current_elapsed_seconds(),
|
|
"target_seconds": 0.0
|
|
}
|
|
answers[current_index] = answer
|
|
session_changed.emit()
|
|
return answer
|
|
|
|
|
|
func skip_core_question(eliminated: Array, flagged: bool) -> Dictionary:
|
|
var question := get_current_question()
|
|
if question.is_empty():
|
|
return {}
|
|
|
|
var answer := {
|
|
"question": question.duplicate(true),
|
|
"selected_choice_id": "",
|
|
"is_correct": false,
|
|
"is_skip": true,
|
|
"eliminated_choice_ids": eliminated.duplicate(),
|
|
"flagged": flagged,
|
|
"elapsed_seconds": get_current_elapsed_seconds(),
|
|
"target_seconds": core_current_target_seconds
|
|
}
|
|
answers[current_index] = answer
|
|
ProfileService.record_wrong_attempt(question)
|
|
_apply_core_wrong_penalty(answer)
|
|
core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05)
|
|
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 session_kind == "core":
|
|
return advance_core_question()
|
|
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:
|
|
if session_kind == "core":
|
|
return str(core_answer_count + 1)
|
|
if session_kind == "simulation":
|
|
return "Block %d/%d | Question %d/%d" % [simulation_block_index + 1, simulation_total_blocks, current_index + 1, active_questions.size()]
|
|
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
|
|
|
|
|
|
func finish_block() -> Dictionary:
|
|
if session_kind in ["core", "bookmark", "review"]:
|
|
block_started = false
|
|
session_changed.emit()
|
|
return {}
|
|
|
|
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]
|
|
|
|
|
|
func update_question_choice_percentages(question_id: String, percentages: Dictionary) -> void:
|
|
for index in range(active_questions.size()):
|
|
var question: Dictionary = active_questions[index]
|
|
if str(question.get("id", "")) == question_id:
|
|
question["choice_selection_percentages"] = percentages
|
|
active_questions[index] = question
|
|
break
|
|
|
|
|
|
func update_question_elo(question_id: String, question_elo: int) -> void:
|
|
for index in range(active_questions.size()):
|
|
var question: Dictionary = active_questions[index]
|
|
if str(question.get("id", "")) == question_id:
|
|
question["question_elo"] = question_elo
|
|
active_questions[index] = question
|
|
break
|
|
|
|
|
|
func advance_core_question() -> bool:
|
|
var question := _draw_core_question()
|
|
if question.is_empty():
|
|
core_status_message = "All available questions cleared."
|
|
return false
|
|
return append_core_question(question)
|
|
|
|
|
|
func append_core_question(question: Dictionary) -> bool:
|
|
if question.is_empty():
|
|
return false
|
|
active_questions.append(_prepare_question_choices(question.duplicate(true)))
|
|
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_question_total_elapsed_seconds = 0.0
|
|
core_question_total_active_since = current_question_started_at
|
|
core_question_total_frozen = false
|
|
core_current_had_wrong_attempt = false
|
|
session_changed.emit()
|
|
return true
|
|
|
|
|
|
func get_core_excluded_question_ids() -> Array:
|
|
var excluded: Array = ProfileService.get_profile().get("correct_question_ids", []).duplicate()
|
|
for question in active_questions:
|
|
var question_id := str(question.get("id", ""))
|
|
if question_id != "" and not excluded.has(question_id):
|
|
excluded.append(question_id)
|
|
return excluded
|
|
|
|
|
|
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 get_core_question_total_elapsed_seconds() -> float:
|
|
if session_kind != "core":
|
|
return 0.0
|
|
var elapsed := core_question_total_elapsed_seconds
|
|
if core_question_total_active_since > 0.0:
|
|
elapsed += Time.get_ticks_msec() / 1000.0 - core_question_total_active_since
|
|
return maxf(0.0, elapsed)
|
|
|
|
|
|
func get_session_timer_ratio() -> float:
|
|
if session_kind == "core":
|
|
return get_core_timer_ratio()
|
|
if session_initial_seconds <= 0.0:
|
|
return 1.0
|
|
return clamp(seconds_remaining / session_initial_seconds, 0.0, 1.0)
|
|
|
|
|
|
func pause_current_question_timer() -> void:
|
|
if session_kind in ["core", "review"] and current_question_pause_started_at <= 0.0:
|
|
var now := Time.get_ticks_msec() / 1000.0
|
|
current_question_pause_started_at = now
|
|
if session_kind == "core":
|
|
_pause_core_question_total_timer(now)
|
|
|
|
|
|
func resume_current_question_timer() -> void:
|
|
if session_kind in ["core", "review"] and current_question_pause_started_at > 0.0:
|
|
var now := Time.get_ticks_msec() / 1000.0
|
|
var paused_duration := now - current_question_pause_started_at
|
|
current_question_started_at += paused_duration
|
|
current_question_pause_started_at = 0.0
|
|
if session_kind == "core" and not core_question_total_frozen:
|
|
core_question_total_active_since = now
|
|
|
|
|
|
func _pause_core_question_total_timer(now: float) -> void:
|
|
if core_question_total_active_since <= 0.0:
|
|
return
|
|
core_question_total_elapsed_seconds += maxf(0.0, now - core_question_total_active_since)
|
|
core_question_total_active_since = 0.0
|
|
|
|
|
|
func _freeze_core_question_total_timer() -> void:
|
|
_pause_core_question_total_timer(Time.get_ticks_msec() / 1000.0)
|
|
core_question_total_frozen = true
|
|
|
|
|
|
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 _prepare_question_choices(candidate.duplicate(true))
|
|
return {}
|
|
|
|
|
|
func _prepare_question_choices(question: Dictionary) -> Dictionary:
|
|
var existing: Array = question.get("presented_choices", [])
|
|
if not existing.is_empty():
|
|
return question
|
|
|
|
var all_choices: Array = question.get("choices", []).duplicate(true)
|
|
var correct_id := str(question.get("correct_choice_id", ""))
|
|
var percentages: Dictionary = question.get("choice_selection_percentages", {})
|
|
var correct_choice: Dictionary = {}
|
|
var distractors: Array = []
|
|
for choice in all_choices:
|
|
if typeof(choice) != TYPE_DICTIONARY:
|
|
continue
|
|
if str(choice.get("id", "")) == correct_id:
|
|
correct_choice = choice
|
|
else:
|
|
distractors.append(choice)
|
|
|
|
var presented: Array = _select_weighted_distractors(distractors, percentages, 4)
|
|
if not correct_choice.is_empty():
|
|
presented.append(correct_choice)
|
|
elif not all_choices.is_empty():
|
|
presented = all_choices.slice(0, min(5, all_choices.size()))
|
|
presented.shuffle()
|
|
question["presented_choices"] = presented
|
|
return question
|
|
|
|
|
|
func _select_weighted_distractors(distractors: Array, percentages: Dictionary, count: int) -> Array:
|
|
var remaining: Array = distractors.duplicate(true)
|
|
var selected: Array = []
|
|
while selected.size() < count and not remaining.is_empty():
|
|
var total_weight := 0.0
|
|
for choice in remaining:
|
|
total_weight += _distractor_weight(str(choice.get("id", "")), percentages)
|
|
var threshold := randf() * total_weight
|
|
var cumulative := 0.0
|
|
var selected_index := remaining.size() - 1
|
|
for index in range(remaining.size()):
|
|
cumulative += _distractor_weight(str(remaining[index].get("id", "")), percentages)
|
|
if threshold < cumulative:
|
|
selected_index = index
|
|
break
|
|
selected.append(remaining[selected_index])
|
|
remaining.remove_at(selected_index)
|
|
return selected
|
|
|
|
|
|
func _distractor_weight(choice_id: String, percentages: Dictionary) -> float:
|
|
var selection_rate := clampf(float(percentages.get(choice_id, 0)), 0.0, 100.0)
|
|
return 1.0 + 5.0 * selection_rate / 100.0
|
|
|
|
|
|
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)
|