diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 45f1a09..10b1d99 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; using QuestionsApi.Models; using QuestionsApi.Services; +using System.Security.Claims; namespace QuestionsApi.Controllers; @@ -11,10 +12,12 @@ namespace QuestionsApi.Controllers; public class QuestionsController : ControllerBase { private readonly QuestionStore _questions; + private readonly QuestionAttemptStore _attempts; - public QuestionsController(QuestionStore questions) + public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts) { _questions = questions; + _attempts = attempts; } [HttpGet("systems")] @@ -30,7 +33,12 @@ public class QuestionsController : ControllerBase public async Task Search([FromBody] QuestionSearchRequest req) { var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); - return Ok(questions.Select(QuestionResponse.FromModel).ToList()); + var percentages = await _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId)); + return Ok(questions.Select(q => + { + percentages.TryGetValue(q.ExternalId, out var questionPercentages); + return QuestionResponse.FromModel(q, questionPercentages); + }).ToList()); } [HttpGet("{id}")] @@ -41,7 +49,43 @@ public class QuestionsController : ControllerBase if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound(); - return Ok(QuestionResponse.FromModel(question)); + var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId); + return Ok(QuestionResponse.FromModel(question, percentages)); + } + + [HttpPost("attempts/first")] + [Authorize(Roles = "USER,SUPER")] + public async Task RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req) + { + if (string.IsNullOrWhiteSpace(req.QuestionExternalId)) + return BadRequest("questionExternalId required"); + if (string.IsNullOrWhiteSpace(req.ChoiceId)) + return BadRequest("choiceId required"); + + var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim()); + if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + return NotFound(); + if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim())) + return BadRequest("choiceId must match a choice id"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + await _attempts.RecordFirstAttemptAsync( + question.ExternalId, + userId, + req.ChoiceId.Trim(), + req.ChoiceId.Trim() == question.CorrectChoiceId); + + return Ok(new FirstQuestionAttemptResponse + { + QuestionExternalId = question.ExternalId, + ChoiceSelectionPercentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId) + }); } [HttpPost] diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs new file mode 100644 index 0000000..65d6832 --- /dev/null +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -0,0 +1,17 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public class QuestionAttempt +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string QuestionExternalId { get; set; } = ""; + public string UserId { get; set; } = ""; + public string ChoiceId { get; set; } = ""; + public bool IsCorrect { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 2841a74..816d2ee 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -28,3 +28,9 @@ public class UpsertQuestionRequest public List Tags { get; set; } = []; public bool IsPublished { get; set; } = true; } + +public class FirstQuestionAttemptRequest +{ + public string QuestionExternalId { get; set; } = ""; + public string ChoiceId { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index a79b7bf..6af59b3 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -18,7 +18,7 @@ public class QuestionResponse public List Media { get; set; } = []; public List Tags { get; set; } = []; - public static QuestionResponse FromModel(Question question) => new() + public static QuestionResponse FromModel(Question question, Dictionary? liveChoiceSelectionPercentages = null) => new() { Id = question.Id ?? "", ExternalId = question.ExternalId, @@ -28,7 +28,9 @@ public class QuestionResponse CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, ChoiceExplanations = question.ChoiceExplanations, - ChoiceSelectionPercentages = question.ChoiceSelectionPercentages, + ChoiceSelectionPercentages = liveChoiceSelectionPercentages is { Count: > 0 } + ? liveChoiceSelectionPercentages + : question.ChoiceSelectionPercentages, Discipline = question.Discipline, System = question.System, Difficulty = question.Difficulty, @@ -37,3 +39,9 @@ public class QuestionResponse Tags = question.Tags }; } + +public class FirstQuestionAttemptResponse +{ + public string QuestionExternalId { get; set; } = ""; + public Dictionary ChoiceSelectionPercentages { get; set; } = []; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 158deda..368b9c7 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs new file mode 100644 index 0000000..fc9613a --- /dev/null +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -0,0 +1,79 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public class QuestionAttemptStore +{ + private readonly IMongoCollection _col; + + public QuestionAttemptStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("QuestionAttempts"); + + var firstAttemptIndex = Builders.IndexKeys + .Ascending(a => a.QuestionExternalId) + .Ascending(a => a.UserId); + _col.Indexes.CreateOne(new CreateIndexModel( + firstAttemptIndex, + new CreateIndexOptions { Unique = true })); + + var questionIndex = Builders.IndexKeys.Ascending(a => a.QuestionExternalId); + _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); + } + + public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect) + { + var attempt = new QuestionAttempt + { + QuestionExternalId = questionExternalId, + UserId = userId, + ChoiceId = choiceId, + IsCorrect = isCorrect, + CreatedUtc = DateTime.UtcNow + }; + + try + { + await _col.InsertOneAsync(attempt); + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + } + } + + public async Task> GetPercentagesForQuestionAsync(string questionExternalId) + { + var results = await GetPercentagesByQuestionAsync([questionExternalId]); + return results.GetValueOrDefault(questionExternalId, []); + } + + public async Task>> GetPercentagesByQuestionAsync(IEnumerable questionExternalIds) + { + var ids = questionExternalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); + if (ids.Count == 0) + return []; + + var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync(); + var output = new Dictionary>(); + + foreach (var group in attempts.GroupBy(a => a.QuestionExternalId)) + { + var total = group.Count(); + if (total == 0) + continue; + + output[group.Key] = group + .GroupBy(a => a.ChoiceId) + .ToDictionary( + choiceGroup => choiceGroup.Key, + choiceGroup => (int)Math.Round(choiceGroup.Count() * 100.0 / total)); + } + + return output; + } +} diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index da71016..fd835c2 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -50,6 +50,9 @@ public class QuestionStore public async Task GetByIdAsync(string id) => await _col.Find(q => q.Id == id).FirstOrDefaultAsync(); + public async Task GetByExternalIdAsync(string externalId) => + await _col.Find(q => q.ExternalId == externalId).FirstOrDefaultAsync(); + public async Task CreateAsync(UpsertQuestionRequest req) { var question = FromRequest(req); diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 8b0ab29..909b375 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -198,6 +198,15 @@ func get_answer_for_index(index: int) -> Variant: 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 advance_core_question() -> bool: var question := _draw_core_question() if question.is_empty(): diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd index d7b8273..4fe9f2a 100644 --- a/scripts/autoload/profile_service.gd +++ b/scripts/autoload/profile_service.gd @@ -123,6 +123,16 @@ func reset_profile() -> void: profile_changed.emit() +func reset_qbank_memory() -> void: + _ensure_tracking_fields() + profile["completed_question_ids"] = [] + profile["correct_question_ids"] = [] + profile["question_wrong_counts"] = {} + profile["missed_question_ids"] = [] + 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)) diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index ca54d22..8504732 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -1,15 +1,19 @@ extends Node const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" +const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first" const RETRY_DELAYS := [2, 5, 10, 20, 30] signal questions_changed signal questions_load_failed(message: String) +signal choice_percentages_updated(question_id: String, percentages: Dictionary) var questions: Array = [] var request: HTTPRequest +var attempt_request: HTTPRequest var retry_timer: Timer var questions_api_url := DEFAULT_QUESTIONS_API_URL +var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL var is_loading := false var last_error := "" var load_state := "idle" @@ -18,10 +22,15 @@ var next_retry_seconds := 0 func _ready() -> void: questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) + question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)) request = HTTPRequest.new() request.request_completed.connect(_on_api_questions_loaded) add_child(request) + attempt_request = HTTPRequest.new() + attempt_request.request_completed.connect(_on_first_attempt_recorded) + add_child(attempt_request) + retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.timeout.connect(_on_retry_timer_timeout) @@ -96,6 +105,8 @@ func retry_now() -> void: func clear_questions() -> void: if request != null: request.cancel_request() + if attempt_request != null: + attempt_request.cancel_request() if retry_timer != null: retry_timer.stop() questions.clear() @@ -107,6 +118,23 @@ func clear_questions() -> void: questions_changed.emit() +func record_first_attempt(question_id: String, choice_id: String) -> void: + if AuthState.access_token.is_empty() or question_id == "" or choice_id == "": + return + + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + var payload := { + "questionExternalId": question_id, + "choiceId": choice_id + } + var err := attempt_request.request(question_attempt_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if err != OK: + push_warning("Failed to record first question attempt.") + + func get_all_questions() -> Array: return questions.duplicate(true) @@ -170,6 +198,34 @@ func _on_retry_timer_timeout() -> void: load_questions_from_api(false) +func _on_first_attempt_recorded(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("Question attempt API request failed with status %d." % response_code) + return + + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Question attempt API response was not an object.") + return + + var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) + var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) + if question_id == "": + return + + _update_question_percentages(question_id, percentages) + choice_percentages_updated.emit(question_id, percentages) + + +func _update_question_percentages(question_id: String, percentages: Dictionary) -> void: + for index in range(questions.size()): + var question: Dictionary = questions[index] + if str(question.get("id", "")) == question_id: + question["choice_selection_percentages"] = percentages + questions[index] = question + return + + func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 31dc71f..eff5322 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -192,6 +192,13 @@ func _build_app_menu() -> void: resume.pressed.connect(_close_app_menu) content.add_child(resume) + var profile := Button.new() + profile.text = "Profile" + profile.custom_minimum_size = Vector2(0, 48) + Style.style_chip(profile, Style.CYAN) + profile.pressed.connect(_open_profile_from_menu) + content.add_child(profile) + menu_exit_button = Button.new() menu_exit_button.text = "Exit Run" menu_exit_button.custom_minimum_size = Vector2(0, 48) @@ -232,3 +239,10 @@ func _exit_run() -> void: if BlockSessionService.block_started: BlockSessionService.finish_block() show_screen("dashboard") + + +func _open_profile_from_menu() -> void: + _close_app_menu() + if BlockSessionService.block_started: + BlockSessionService.finish_block() + show_screen("profile") diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 08165f2..e688311 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -4,6 +4,8 @@ const Style := preload("res://scripts/ui/ui_style.gd") var main: Control var profile_text: RichTextLabel +var reset_qbank_button: Button +var reset_qbank_pending := false func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) @@ -38,10 +40,17 @@ func _ready() -> void: on_show() ) row.add_child(reset) + + reset_qbank_button = Button.new() + reset_qbank_button.text = "Reset QBank" + Style.style_chip(reset_qbank_button, Style.AMBER) + reset_qbank_button.pressed.connect(_on_reset_qbank_pressed) + row.add_child(reset_qbank_button) add_child(row) func on_show(_payload: Variant = null) -> void: + _reset_qbank_confirmation() var profile := ProfileService.get_profile() var xp_into := ProgressionService.xp_into_level(int(profile["xp"])) var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions, +%d XP\n\n[b]Badges[/b]\n" % [ @@ -82,3 +91,24 @@ func on_show(_payload: Variant = null) -> void: block.get("xp_earned", 0) ] profile_text.text = text + + +func _on_reset_qbank_pressed() -> void: + if not reset_qbank_pending: + reset_qbank_pending = true + reset_qbank_button.text = "Confirm Reset QBank" + Style.style_chip(reset_qbank_button, Style.RED) + return + + ProfileService.reset_qbank_memory() + if BlockSessionService.core_status_message == "All available questions cleared.": + BlockSessionService.core_status_message = "" + _reset_qbank_confirmation() + on_show() + + +func _reset_qbank_confirmation() -> void: + reset_qbank_pending = false + if reset_qbank_button != null: + reset_qbank_button.text = "Reset QBank" + Style.style_chip(reset_qbank_button, Style.AMBER) diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 545d031..e1984e1 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -39,6 +39,8 @@ var shake_origin := Vector2.ZERO func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) + if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): + QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) @@ -322,6 +324,9 @@ func _submit_or_next() -> void: if selected_choice == "": return if BlockSessionService.session_kind == "core": + var attempted_choice := selected_choice + var attempted_question_id := str(BlockSessionService.get_current_question().get("id", "")) + QuestionBankService.record_first_attempt(attempted_question_id, attempted_choice) var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if result.is_empty(): return @@ -330,7 +335,7 @@ func _submit_or_next() -> void: current_answer = answer _trigger_correct_feedback() return - _lock_wrong_choice(selected_choice) + _lock_wrong_choice(attempted_choice) selected_choice = "" current_answer = {} next_button.text = "Submit" @@ -615,6 +620,15 @@ func _get_choice_selection_percentage(choice_id: String) -> int: return clamp(int(percentages.get(choice_id, -1)), 0, 100) +func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void: + if not visible: + return + if str(BlockSessionService.get_current_question().get("id", "")) != question_id: + return + BlockSessionService.update_question_choice_percentages(question_id, percentages) + _apply_saved_choice_state() + + func _strike_text(value: String) -> String: var result := "" for character in value: