Add server-side ELO ratings
This commit is contained in:
@@ -13,11 +13,13 @@ public class QuestionsController : ControllerBase
|
||||
{
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly RatingStore _ratings;
|
||||
|
||||
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts)
|
||||
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
_ratings = ratings;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -34,10 +36,11 @@ public class QuestionsController : ControllerBase
|
||||
{
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
var percentages = await _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
return Ok(questions.Select(q =>
|
||||
{
|
||||
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
|
||||
return QuestionResponse.FromModel(q, questionPercentages);
|
||||
return QuestionResponse.FromModel(q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000));
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
@@ -50,7 +53,8 @@ public class QuestionsController : ControllerBase
|
||||
return NotFound();
|
||||
|
||||
var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId);
|
||||
return Ok(QuestionResponse.FromModel(question, percentages));
|
||||
var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId);
|
||||
return Ok(QuestionResponse.FromModel(question, percentages, questionElo));
|
||||
}
|
||||
|
||||
[HttpPost("attempts/first")]
|
||||
@@ -75,18 +79,60 @@ public class QuestionsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await _attempts.RecordFirstAttemptAsync(
|
||||
var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
req.ChoiceId.Trim(),
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId);
|
||||
var rating = await _ratings.ApplyFirstResultAsync(
|
||||
userId,
|
||||
question,
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
wasFirstAttempt);
|
||||
|
||||
return Ok(new FirstQuestionAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
|
||||
question,
|
||||
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId))
|
||||
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{externalId}/skip")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Skip(string externalId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(externalId))
|
||||
return BadRequest("externalId required");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
|
||||
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
|
||||
var userId = CurrentUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
var rating = await _ratings.ApplyFirstResultAsync(userId, question, false, wasFirstAttempt);
|
||||
|
||||
return Ok(new SkipQuestionResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
CorrectChoiceId = question.CorrectChoiceId,
|
||||
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
|
||||
question,
|
||||
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,4 +229,10 @@ public class QuestionsController : ControllerBase
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string CurrentUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class RatingsController : ControllerBase
|
||||
{
|
||||
private readonly RatingStore _ratings;
|
||||
|
||||
public RatingsController(RatingStore ratings)
|
||||
{
|
||||
_ratings = ratings;
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
return Ok(await _ratings.GetProfileAsync(userId));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class Question
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public string PerformanceCategory { get; set; } = "";
|
||||
public int Difficulty { get; set; } = 1;
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
|
||||
@@ -13,5 +13,6 @@ public class QuestionAttempt
|
||||
public string UserId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public bool IsCorrect { get; set; }
|
||||
public bool IsSkip { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public class UpsertQuestionRequest
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public string PerformanceCategory { get; set; } = "";
|
||||
public int Difficulty { get; set; } = 1;
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
|
||||
@@ -13,12 +13,17 @@ public class QuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public string PerformanceCategory { get; set; } = "";
|
||||
public int Difficulty { get; set; }
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
public static QuestionResponse FromModel(Question question, Dictionary<string, int>? liveChoiceSelectionPercentages = null) => new()
|
||||
public static QuestionResponse FromModel(
|
||||
Question question,
|
||||
Dictionary<string, int>? liveChoiceSelectionPercentages = null,
|
||||
int questionElo = 1000) => new()
|
||||
{
|
||||
Id = question.Id ?? "",
|
||||
ExternalId = question.ExternalId,
|
||||
@@ -31,7 +36,9 @@ public class QuestionResponse
|
||||
ChoiceSelectionPercentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages),
|
||||
Discipline = question.Discipline,
|
||||
System = question.System,
|
||||
PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline),
|
||||
Difficulty = question.Difficulty,
|
||||
QuestionElo = questionElo,
|
||||
Labs = question.Labs,
|
||||
Media = question.Media,
|
||||
Tags = question.Tags
|
||||
@@ -52,4 +59,59 @@ public class FirstQuestionAttemptResponse
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string CorrectChoiceId { get; set; } = "";
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
{
|
||||
public const string Uncategorized = "Uncategorized";
|
||||
|
||||
public static readonly string[] Categories =
|
||||
[
|
||||
"Renal/Urinary & Male/Female Systems & Pregnancy",
|
||||
"Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety",
|
||||
"Immune/Blood & Lymphoreticular/Endocrine/Multisystem",
|
||||
"Biostatistics & Epidemiology/Population Health",
|
||||
"Musculoskeletal/Skin & Subcutaneous Tissue",
|
||||
"Cardiovascular System",
|
||||
"Nervous System & Special Senses",
|
||||
"Respiratory System",
|
||||
"Gastrointestinal System",
|
||||
Uncategorized
|
||||
];
|
||||
|
||||
public static string Normalize(string? explicitCategory, string? system, string? discipline)
|
||||
{
|
||||
foreach (var value in new[] { explicitCategory, system, discipline })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
var normalized = Categories.FirstOrDefault(c => string.Equals(c, value.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (normalized is not null)
|
||||
return normalized;
|
||||
|
||||
var lower = value.ToLowerInvariant();
|
||||
if (lower.Contains("cardio")) return "Cardiovascular System";
|
||||
if (lower.Contains("resp")) return "Respiratory System";
|
||||
if (lower.Contains("gastro") || lower.Contains("hepato")) return "Gastrointestinal System";
|
||||
if (lower.Contains("neuro") || lower.Contains("special sense")) return "Nervous System & Special Senses";
|
||||
if (lower.Contains("renal") || lower.Contains("urinary") || lower.Contains("pregnan") || lower.Contains("reproductive")) return "Renal/Urinary & Male/Female Systems & Pregnancy";
|
||||
if (lower.Contains("behavior") || lower.Contains("ethic") || lower.Contains("communication") || lower.Contains("safety")) return "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety";
|
||||
if (lower.Contains("immune") || lower.Contains("blood") || lower.Contains("endocrine") || lower.Contains("multisystem")) return "Immune/Blood & Lymphoreticular/Endocrine/Multisystem";
|
||||
if (lower.Contains("biostat") || lower.Contains("epidemi") || lower.Contains("population")) return "Biostatistics & Epidemiology/Population Health";
|
||||
if (lower.Contains("musculo") || lower.Contains("skin") || lower.Contains("derm")) return "Musculoskeletal/Skin & Subcutaneous Tissue";
|
||||
}
|
||||
|
||||
return Uncategorized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public class UserRating
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string UserId { get; set; } = "";
|
||||
public int OverallElo { get; set; } = 1000;
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class QuestionRating
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int AttemptCount { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RatingChangeResponse
|
||||
{
|
||||
public bool Rated { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public int UserOverallElo { get; set; }
|
||||
public int UserOverallDelta { get; set; }
|
||||
public int UserCategoryElo { get; set; }
|
||||
public int UserCategoryDelta { get; set; }
|
||||
public int QuestionElo { get; set; }
|
||||
public int QuestionEloDelta { get; set; }
|
||||
}
|
||||
|
||||
public class RatingProfileResponse
|
||||
{
|
||||
public int OverallElo { get; set; }
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<RatingStore>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
|
||||
@@ -26,7 +26,7 @@ public class QuestionAttemptStore
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
}
|
||||
|
||||
public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect)
|
||||
public async Task<bool> RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
@@ -34,15 +34,18 @@ public class QuestionAttemptStore
|
||||
UserId = userId,
|
||||
ChoiceId = choiceId,
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = isSkip,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _col.InsertOneAsync(attempt);
|
||||
return true;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ public class QuestionAttemptStore
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync();
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip).ToListAsync();
|
||||
var output = new Dictionary<string, Dictionary<string, int>>();
|
||||
|
||||
foreach (var group in attempts.GroupBy(a => a.QuestionExternalId))
|
||||
|
||||
@@ -139,6 +139,7 @@ public class QuestionStore
|
||||
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
|
||||
Discipline = req.Discipline.Trim(),
|
||||
System = req.System.Trim(),
|
||||
PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
|
||||
Difficulty = req.Difficulty,
|
||||
Labs = req.Labs,
|
||||
Media = req.Media,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public class RatingStore
|
||||
{
|
||||
private const int BaseElo = 1000;
|
||||
private const int MinElo = 100;
|
||||
|
||||
private readonly IMongoCollection<UserRating> _users;
|
||||
private readonly IMongoCollection<QuestionRating> _questions;
|
||||
|
||||
public RatingStore(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);
|
||||
_users = db.GetCollection<UserRating>("UserRatings");
|
||||
_questions = db.GetCollection<QuestionRating>("QuestionRatings");
|
||||
|
||||
_users.Indexes.CreateOne(new CreateIndexModel<UserRating>(
|
||||
Builders<UserRating>.IndexKeys.Ascending(r => r.UserId),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
_questions.Indexes.CreateOne(new CreateIndexModel<QuestionRating>(
|
||||
Builders<QuestionRating>.IndexKeys.Ascending(r => r.QuestionExternalId),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
return new RatingProfileResponse
|
||||
{
|
||||
OverallElo = rating.OverallElo,
|
||||
RatedQuestionCount = rating.RatedQuestionCount,
|
||||
CategoryElos = rating.CategoryElos
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
|
||||
var ratings = await _questions.Find(r => ids.Contains(r.QuestionExternalId)).ToListAsync();
|
||||
return ids.ToDictionary(
|
||||
id => id,
|
||||
id => ratings.FirstOrDefault(r => r.QuestionExternalId == id)?.Elo ?? BaseElo);
|
||||
}
|
||||
|
||||
public async Task<int> GetQuestionEloAsync(string questionExternalId)
|
||||
{
|
||||
var rating = await GetOrCreateQuestionRatingAsync(questionExternalId);
|
||||
return rating.Elo;
|
||||
}
|
||||
|
||||
public async Task<RatingChangeResponse> ApplyFirstResultAsync(string userId, Question question, bool isCorrect, bool shouldRate)
|
||||
{
|
||||
var userRating = await GetOrCreateUserRatingAsync(userId);
|
||||
var questionRating = await GetOrCreateQuestionRatingAsync(question.ExternalId);
|
||||
var category = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline);
|
||||
|
||||
if (!shouldRate)
|
||||
{
|
||||
return new RatingChangeResponse
|
||||
{
|
||||
Rated = false,
|
||||
Category = category,
|
||||
UserOverallElo = userRating.OverallElo,
|
||||
UserCategoryElo = userRating.CategoryElos.GetValueOrDefault(category, BaseElo),
|
||||
QuestionElo = questionRating.Elo
|
||||
};
|
||||
}
|
||||
|
||||
var actual = isCorrect ? 1.0 : 0.0;
|
||||
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
||||
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
||||
|
||||
var oldOverall = userRating.OverallElo;
|
||||
var oldCategory = userRating.CategoryElos.GetValueOrDefault(category, BaseElo);
|
||||
var oldQuestion = questionRating.Elo;
|
||||
|
||||
var overallExpected = ExpectedScore(oldOverall, oldQuestion);
|
||||
var categoryExpected = ExpectedScore(oldCategory, oldQuestion);
|
||||
var overallDelta = (int)Math.Round(userK * (actual - overallExpected));
|
||||
var categoryDelta = (int)Math.Round(userK * (actual - categoryExpected));
|
||||
var questionDelta = (int)Math.Round(questionK * (overallExpected - actual));
|
||||
|
||||
userRating.OverallElo = Math.Max(MinElo, oldOverall + overallDelta);
|
||||
userRating.CategoryElos[category] = Math.Max(MinElo, oldCategory + categoryDelta);
|
||||
userRating.RatedQuestionCount++;
|
||||
userRating.UpdatedUtc = DateTime.UtcNow;
|
||||
|
||||
questionRating.Elo = Math.Max(MinElo, oldQuestion + questionDelta);
|
||||
questionRating.AttemptCount++;
|
||||
questionRating.UpdatedUtc = DateTime.UtcNow;
|
||||
|
||||
await _users.ReplaceOneAsync(r => r.UserId == userRating.UserId, userRating, new ReplaceOptions { IsUpsert = true });
|
||||
await _questions.ReplaceOneAsync(r => r.QuestionExternalId == questionRating.QuestionExternalId, questionRating, new ReplaceOptions { IsUpsert = true });
|
||||
|
||||
return new RatingChangeResponse
|
||||
{
|
||||
Rated = true,
|
||||
Category = category,
|
||||
UserOverallElo = userRating.OverallElo,
|
||||
UserOverallDelta = userRating.OverallElo - oldOverall,
|
||||
UserCategoryElo = userRating.CategoryElos[category],
|
||||
UserCategoryDelta = userRating.CategoryElos[category] - oldCategory,
|
||||
QuestionElo = questionRating.Elo,
|
||||
QuestionEloDelta = questionRating.Elo - oldQuestion
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<UserRating> GetOrCreateUserRatingAsync(string userId)
|
||||
{
|
||||
var rating = await _users.Find(r => r.UserId == userId).FirstOrDefaultAsync();
|
||||
if (rating is null)
|
||||
{
|
||||
rating = new UserRating { UserId = userId, CategoryElos = DefaultCategoryElos() };
|
||||
await _users.InsertOneAsync(rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var category in RatingCategory.Categories)
|
||||
{
|
||||
if (!rating.CategoryElos.ContainsKey(category))
|
||||
{
|
||||
rating.CategoryElos[category] = BaseElo;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
await _users.ReplaceOneAsync(r => r.UserId == userId, rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
||||
{
|
||||
var rating = await _questions.Find(r => r.QuestionExternalId == questionExternalId).FirstOrDefaultAsync();
|
||||
if (rating is not null)
|
||||
return rating;
|
||||
|
||||
rating = new QuestionRating { QuestionExternalId = questionExternalId };
|
||||
await _questions.InsertOneAsync(rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DefaultCategoryElos() =>
|
||||
RatingCategory.Categories.ToDictionary(category => category, _ => BaseElo);
|
||||
|
||||
private static double ExpectedScore(int playerElo, int questionElo) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (questionElo - playerElo) / 400.0));
|
||||
}
|
||||
@@ -130,6 +130,29 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) ->
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -207,6 +230,15 @@ func update_question_choice_percentages(question_id: String, percentages: Dictio
|
||||
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():
|
||||
|
||||
@@ -62,12 +62,9 @@ func get_profile() -> Dictionary:
|
||||
|
||||
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:
|
||||
@@ -91,7 +88,7 @@ func apply_block_results(results: Dictionary) -> void:
|
||||
"system": results.get("system", "All Systems"),
|
||||
"correct": results.get("correct", 0),
|
||||
"total": results.get("total", 0),
|
||||
"xp_earned": results.get("xp_earned", 0)
|
||||
"xp_earned": 0
|
||||
}
|
||||
profile["block_history"].push_front(summary)
|
||||
if profile["block_history"].size() > 12:
|
||||
@@ -135,12 +132,9 @@ func reset_qbank_memory() -> void:
|
||||
|
||||
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:
|
||||
|
||||
@@ -2,27 +2,42 @@ 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 DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions"
|
||||
const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me"
|
||||
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)
|
||||
signal ratings_changed
|
||||
signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary)
|
||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
var attempt_request: HTTPRequest
|
||||
var rating_request: HTTPRequest
|
||||
var skip_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 question_api_base_url := DEFAULT_QUESTION_API_BASE_URL
|
||||
var ratings_api_url := DEFAULT_RATINGS_API_URL
|
||||
var is_loading := false
|
||||
var last_error := ""
|
||||
var load_state := "idle"
|
||||
var retry_attempt := 0
|
||||
var next_retry_seconds := 0
|
||||
var overall_elo := 1000
|
||||
var rated_question_count := 0
|
||||
var category_elos := {}
|
||||
var last_rating_change := {}
|
||||
|
||||
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))
|
||||
question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL))
|
||||
ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL))
|
||||
request = HTTPRequest.new()
|
||||
request.request_completed.connect(_on_api_questions_loaded)
|
||||
add_child(request)
|
||||
@@ -31,6 +46,14 @@ func _ready() -> void:
|
||||
attempt_request.request_completed.connect(_on_first_attempt_recorded)
|
||||
add_child(attempt_request)
|
||||
|
||||
rating_request = HTTPRequest.new()
|
||||
rating_request.request_completed.connect(_on_ratings_loaded)
|
||||
add_child(rating_request)
|
||||
|
||||
skip_request = HTTPRequest.new()
|
||||
skip_request.request_completed.connect(_on_question_skipped)
|
||||
add_child(skip_request)
|
||||
|
||||
retry_timer = Timer.new()
|
||||
retry_timer.one_shot = true
|
||||
retry_timer.timeout.connect(_on_retry_timer_timeout)
|
||||
@@ -52,6 +75,7 @@ func load_questions_from_api(reset_retry: bool = true) -> void:
|
||||
last_error = ""
|
||||
questions.clear()
|
||||
questions_changed.emit()
|
||||
load_ratings_from_api()
|
||||
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -107,6 +131,10 @@ func clear_questions() -> void:
|
||||
request.cancel_request()
|
||||
if attempt_request != null:
|
||||
attempt_request.cancel_request()
|
||||
if rating_request != null:
|
||||
rating_request.cancel_request()
|
||||
if skip_request != null:
|
||||
skip_request.cancel_request()
|
||||
if retry_timer != null:
|
||||
retry_timer.stop()
|
||||
questions.clear()
|
||||
@@ -115,7 +143,12 @@ func clear_questions() -> void:
|
||||
load_state = "idle"
|
||||
retry_attempt = 0
|
||||
next_retry_seconds = 0
|
||||
overall_elo = 1000
|
||||
rated_question_count = 0
|
||||
category_elos = {}
|
||||
last_rating_change = {}
|
||||
questions_changed.emit()
|
||||
ratings_changed.emit()
|
||||
|
||||
|
||||
func record_first_attempt(question_id: String, choice_id: String) -> void:
|
||||
@@ -135,6 +168,31 @@ func record_first_attempt(question_id: String, choice_id: String) -> void:
|
||||
push_warning("Failed to record first question attempt.")
|
||||
|
||||
|
||||
func skip_question(question_id: String) -> void:
|
||||
if AuthState.access_token.is_empty() or question_id == "":
|
||||
return
|
||||
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % AuthState.access_token
|
||||
])
|
||||
var url := "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
var err := skip_request.request(url, headers, HTTPClient.METHOD_POST, "")
|
||||
if err != OK:
|
||||
push_warning("Failed to record skipped question.")
|
||||
|
||||
|
||||
func load_ratings_from_api() -> void:
|
||||
if AuthState.access_token.is_empty():
|
||||
return
|
||||
var headers := PackedStringArray([
|
||||
"Authorization: Bearer %s" % AuthState.access_token
|
||||
])
|
||||
var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
push_warning("Failed to request rating profile.")
|
||||
|
||||
|
||||
func get_all_questions() -> Array:
|
||||
return questions.duplicate(true)
|
||||
|
||||
@@ -214,7 +272,53 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
return
|
||||
|
||||
_update_question_percentages(question_id, percentages)
|
||||
var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000)))
|
||||
var rating: Dictionary = parsed.get("rating", {})
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
question_rating_updated.emit(question_id, question_elo, rating)
|
||||
|
||||
|
||||
func _on_question_skipped(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 skip 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 skip API response was not an object.")
|
||||
return
|
||||
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", "")))
|
||||
var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {}))
|
||||
var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000)))
|
||||
var rating: Dictionary = parsed.get("rating", {})
|
||||
if question_id == "":
|
||||
return
|
||||
|
||||
_update_question_percentages(question_id, percentages)
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
question_skipped.emit(question_id, correct_choice_id, question_elo, rating)
|
||||
|
||||
|
||||
func _on_ratings_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("Ratings 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("Ratings API response was not an object.")
|
||||
return
|
||||
|
||||
overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000)))
|
||||
rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0)))
|
||||
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
|
||||
ratings_changed.emit()
|
||||
|
||||
|
||||
func _update_question_percentages(question_id: String, percentages: Dictionary) -> void:
|
||||
@@ -226,6 +330,27 @@ func _update_question_percentages(question_id: String, percentages: Dictionary)
|
||||
return
|
||||
|
||||
|
||||
func _update_question_elo(question_id: String, question_elo: int) -> void:
|
||||
for index in range(questions.size()):
|
||||
var question: Dictionary = questions[index]
|
||||
if str(question.get("id", "")) == question_id:
|
||||
question["question_elo"] = question_elo
|
||||
questions[index] = question
|
||||
return
|
||||
|
||||
|
||||
func _apply_rating_change(rating: Dictionary) -> void:
|
||||
if rating.is_empty():
|
||||
return
|
||||
last_rating_change = rating
|
||||
overall_elo = int(rating.get("userOverallElo", rating.get("user_overall_elo", overall_elo)))
|
||||
var category := str(rating.get("category", ""))
|
||||
if category != "":
|
||||
category_elos[category] = int(rating.get("userCategoryElo", rating.get("user_category_elo", category_elos.get(category, 1000))))
|
||||
rated_question_count += 1 if bool(rating.get("rated", false)) else 0
|
||||
ratings_changed.emit()
|
||||
|
||||
|
||||
func _is_valid_question(item: Variant) -> bool:
|
||||
if typeof(item) != TYPE_DICTIONARY:
|
||||
return false
|
||||
@@ -252,7 +377,9 @@ func _normalize_api_question(item: Variant) -> Dictionary:
|
||||
"choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})),
|
||||
"discipline": str(item.get("discipline", "")),
|
||||
"system": str(item.get("system", "")),
|
||||
"performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))),
|
||||
"difficulty": int(item.get("difficulty", 1)),
|
||||
"question_elo": int(item.get("questionElo", item.get("question_elo", 1000))),
|
||||
"labs": item.get("labs", []),
|
||||
"media": item.get("media", []),
|
||||
"tags": item.get("tags", [])
|
||||
|
||||
@@ -5,7 +5,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
var main: Control
|
||||
var status_label: Label
|
||||
var daily_label: Label
|
||||
var xp_label: Label
|
||||
var elo_label: Label
|
||||
var streak_label: Label
|
||||
var bank_label: Label
|
||||
var error_label: Label
|
||||
@@ -17,6 +17,8 @@ func _ready() -> void:
|
||||
add_theme_constant_override("separation", 18)
|
||||
if not QuestionBankService.questions_changed.is_connected(_refresh):
|
||||
QuestionBankService.questions_changed.connect(_refresh)
|
||||
if not QuestionBankService.ratings_changed.is_connected(_refresh):
|
||||
QuestionBankService.ratings_changed.connect(_refresh)
|
||||
if not ProfileService.profile_changed.is_connected(_refresh):
|
||||
ProfileService.profile_changed.connect(_refresh)
|
||||
|
||||
@@ -36,7 +38,7 @@ func _ready() -> void:
|
||||
add_child(summary)
|
||||
|
||||
daily_label = _add_metric(summary, "Today")
|
||||
xp_label = _add_metric(summary, "Daily XP")
|
||||
elo_label = _add_metric(summary, "ELO")
|
||||
streak_label = _add_metric(summary, "Streak")
|
||||
bank_label = _add_metric(summary, "Question Bank")
|
||||
|
||||
@@ -115,7 +117,10 @@ func _refresh() -> void:
|
||||
if progress > 100:
|
||||
progress = 100
|
||||
daily_label.text = "Today\n%d/%d questions\n%d%% complete" % [answered, goal, progress]
|
||||
xp_label.text = "Daily XP\n%d XP earned" % int(profile.get("daily_xp_earned", 0))
|
||||
elo_label.text = "ELO\n%d overall\n%d rated" % [
|
||||
QuestionBankService.overall_elo,
|
||||
QuestionBankService.rated_question_count
|
||||
]
|
||||
streak_label.text = "Streak\n%d current\n%d best" % [
|
||||
int(profile.get("current_streak", 0)),
|
||||
int(profile.get("best_streak", 0))
|
||||
|
||||
+3
-3
@@ -131,6 +131,7 @@ func _build_ui() -> void:
|
||||
screens["profile"] = profile
|
||||
|
||||
ProfileService.profile_changed.connect(_refresh_header_stats)
|
||||
QuestionBankService.ratings_changed.connect(_refresh_header_stats)
|
||||
AuthState.auth_changed.connect(_refresh_header_stats)
|
||||
_refresh_header_stats()
|
||||
_build_app_menu()
|
||||
@@ -148,9 +149,8 @@ func _refresh_header_stats() -> void:
|
||||
if header_stats == null:
|
||||
return
|
||||
var profile := ProfileService.get_profile()
|
||||
header_stats.text = "LV %d | %d XP | Streak %d" % [
|
||||
profile.get("level", 1),
|
||||
profile.get("xp", 0),
|
||||
header_stats.text = "ELO %d | Streak %d" % [
|
||||
QuestionBankService.overall_elo,
|
||||
profile.get("current_streak", 0)
|
||||
]
|
||||
if user_label != null:
|
||||
|
||||
@@ -10,7 +10,7 @@ func _ready() -> void:
|
||||
add_theme_constant_override("separation", 18)
|
||||
|
||||
var headline := Label.new()
|
||||
headline.text = "Answer board-style questions. Earn XP. Unlock the map."
|
||||
headline.text = "Answer board-style questions. Raise your ELO. Unlock the map."
|
||||
headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(headline, 38, Style.TEXT)
|
||||
add_child(headline)
|
||||
@@ -31,7 +31,7 @@ func _ready() -> void:
|
||||
hero.add_child(hero_content)
|
||||
|
||||
var loop := Label.new()
|
||||
loop.text = "XP Rush | Streak Heat | System Zones | Timed Blocks"
|
||||
loop.text = "ELO Climb | Streak Heat | System Zones | Timed Blocks"
|
||||
Style.set_label(loop, 24, Style.CYAN)
|
||||
hero_content.add_child(loop)
|
||||
|
||||
@@ -72,9 +72,8 @@ func _ready() -> void:
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
var profile := ProfileService.get_profile()
|
||||
stats_label.text = "Level %d | %d XP | Current streak: %d | Badges: %d" % [
|
||||
profile["level"],
|
||||
profile["xp"],
|
||||
stats_label.text = "ELO %d | Current streak: %d | Badges: %d" % [
|
||||
QuestionBankService.overall_elo,
|
||||
profile["current_streak"],
|
||||
profile["badges"].size()
|
||||
]
|
||||
|
||||
@@ -52,23 +52,21 @@ func _ready() -> void:
|
||||
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" % [
|
||||
profile["level"],
|
||||
profile["xp"],
|
||||
xp_into,
|
||||
ProgressionService.XP_PER_LEVEL,
|
||||
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
|
||||
QuestionBankService.overall_elo,
|
||||
QuestionBankService.rated_question_count,
|
||||
profile["current_streak"],
|
||||
profile["best_streak"],
|
||||
profile.get("daily_questions_answered", 0),
|
||||
profile.get("daily_goal_questions", 10),
|
||||
profile.get("daily_xp_earned", 0)
|
||||
profile.get("daily_goal_questions", 10)
|
||||
]
|
||||
if profile["badges"].is_empty():
|
||||
text += "No badges yet.\n"
|
||||
if QuestionBankService.category_elos.is_empty():
|
||||
text += "No rated categories yet.\n"
|
||||
else:
|
||||
for badge in profile["badges"]:
|
||||
text += "- %s\n" % badge
|
||||
var categories := QuestionBankService.category_elos.keys()
|
||||
categories.sort()
|
||||
for category in categories:
|
||||
text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])]
|
||||
|
||||
text += "\n[b]System Accuracy[/b]\n"
|
||||
if profile["system_stats"].is_empty():
|
||||
@@ -83,12 +81,11 @@ func on_show(_payload: Variant = null) -> void:
|
||||
|
||||
text += "\n[b]Recent Blocks[/b]\n"
|
||||
for block in profile["block_history"]:
|
||||
text += "- %s: %s %d/%d, +%d XP\n" % [
|
||||
text += "- %s: %s %d/%d\n" % [
|
||||
block.get("timestamp", ""),
|
||||
block.get("system", ""),
|
||||
block.get("correct", 0),
|
||||
block.get("total", 0),
|
||||
block.get("xp_earned", 0)
|
||||
block.get("total", 0)
|
||||
]
|
||||
profile_text.text = text
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 0.6
|
||||
|
||||
var main: Control
|
||||
var choice_buttons: Array[Button] = []
|
||||
var choice_percent_labels: Array[Label] = []
|
||||
var eliminated: Array = []
|
||||
var locked_wrong_choice_ids: Array = []
|
||||
var flagged := false
|
||||
@@ -23,7 +24,9 @@ var timer_label: Label
|
||||
var stem_label: RichTextLabel
|
||||
var lead_label: Label
|
||||
var explanation_label: RichTextLabel
|
||||
var question_elo_label: Label
|
||||
var next_button: Button
|
||||
var skip_button: Button
|
||||
var flag_button: Button
|
||||
var report_button: Button
|
||||
var nav_palette: HBoxContainer
|
||||
@@ -41,6 +44,10 @@ func _ready() -> void:
|
||||
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)
|
||||
if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated):
|
||||
QuestionBankService.question_rating_updated.connect(_on_question_rating_updated)
|
||||
if not QuestionBankService.question_skipped.is_connected(_on_question_skipped):
|
||||
QuestionBankService.question_skipped.connect(_on_question_skipped)
|
||||
|
||||
var top := HBoxContainer.new()
|
||||
top.add_theme_constant_override("separation", 12)
|
||||
@@ -125,6 +132,12 @@ func _ready() -> void:
|
||||
Style.set_label(lead_label, 21, Style.TEXT)
|
||||
question_content.add_child(lead_label)
|
||||
|
||||
question_elo_label = Label.new()
|
||||
question_elo_label.visible = false
|
||||
question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
Style.set_label(question_elo_label, 16, Style.AMBER)
|
||||
question_content.add_child(question_elo_label)
|
||||
|
||||
for index in range(5):
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(0, 64)
|
||||
@@ -133,6 +146,19 @@ func _ready() -> void:
|
||||
button.pressed.connect(func(i := index): _select_choice(i))
|
||||
button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i))
|
||||
choice_buttons.append(button)
|
||||
|
||||
var percent_label := Label.new()
|
||||
percent_label.visible = false
|
||||
percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
percent_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
percent_label.offset_left = 0
|
||||
percent_label.offset_right = -18
|
||||
Style.set_label(percent_label, 18, Style.TEXT)
|
||||
button.add_child(percent_label)
|
||||
choice_percent_labels.append(percent_label)
|
||||
|
||||
question_content.add_child(button)
|
||||
|
||||
explanation_label = RichTextLabel.new()
|
||||
@@ -145,6 +171,13 @@ func _ready() -> void:
|
||||
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 10)
|
||||
skip_button = Button.new()
|
||||
skip_button.text = "Skip"
|
||||
skip_button.custom_minimum_size = Vector2(120, 48)
|
||||
Style.style_chip(skip_button, Style.AMBER)
|
||||
skip_button.pressed.connect(_skip_current_question)
|
||||
actions.add_child(skip_button)
|
||||
|
||||
next_button = Button.new()
|
||||
next_button.text = "Submit"
|
||||
next_button.custom_minimum_size = Vector2(140, 48)
|
||||
@@ -215,7 +248,12 @@ func _load_question() -> void:
|
||||
flag_button.button_pressed = false
|
||||
_refresh_toolbar_state()
|
||||
explanation_label.visible = false
|
||||
question_elo_label.visible = false
|
||||
question_elo_label.text = ""
|
||||
next_button.text = "Submit"
|
||||
next_button.disabled = false
|
||||
skip_button.disabled = false
|
||||
skip_button.visible = BlockSessionService.session_kind == "core"
|
||||
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if question.is_empty():
|
||||
@@ -255,6 +293,7 @@ func _load_question() -> void:
|
||||
else:
|
||||
_apply_saved_choice_state()
|
||||
next_button.text = "Next"
|
||||
_update_question_elo_label(true)
|
||||
|
||||
_refresh_nav_palette()
|
||||
_refresh_toolbar_state()
|
||||
@@ -276,6 +315,25 @@ func _select_choice(index: int) -> void:
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _skip_current_question() -> void:
|
||||
if pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core":
|
||||
return
|
||||
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||
if question_id == "":
|
||||
return
|
||||
current_answer = BlockSessionService.skip_core_question(eliminated, flagged)
|
||||
if current_answer.is_empty():
|
||||
return
|
||||
QuestionBankService.skip_question(question_id)
|
||||
selected_choice = ""
|
||||
next_button.disabled = true
|
||||
skip_button.disabled = true
|
||||
pending_correct_advance = true
|
||||
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
|
||||
_show_explanation()
|
||||
_update_question_elo_label(true)
|
||||
|
||||
|
||||
func _choice_gui_input(event: InputEvent, index: int) -> void:
|
||||
if pending_correct_advance:
|
||||
return
|
||||
@@ -333,6 +391,7 @@ func _submit_or_next() -> void:
|
||||
var answer: Dictionary = result.get("answer", {})
|
||||
if bool(result.get("is_correct", false)):
|
||||
current_answer = answer
|
||||
_update_question_elo_label(true)
|
||||
_trigger_correct_feedback()
|
||||
return
|
||||
_lock_wrong_choice(attempted_choice)
|
||||
@@ -361,6 +420,7 @@ func _show_explanation() -> void:
|
||||
for index in range(question.get("choices", []).size()):
|
||||
var choice: Dictionary = question["choices"][index]
|
||||
var id := str(choice.get("id", ""))
|
||||
_set_choice_button_text(index, choice)
|
||||
if id == correct_id:
|
||||
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
|
||||
elif id == selected_choice:
|
||||
@@ -369,6 +429,7 @@ func _show_explanation() -> void:
|
||||
var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect"
|
||||
explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")]
|
||||
explanation_label.visible = true
|
||||
_update_question_elo_label(true)
|
||||
_refresh_nav_palette()
|
||||
|
||||
|
||||
@@ -434,6 +495,8 @@ func _trigger_correct_feedback() -> void:
|
||||
red_fog_time = 0.0
|
||||
green_fog_time = FEEDBACK_FADE_SECONDS
|
||||
next_button.disabled = true
|
||||
if skip_button != null:
|
||||
skip_button.disabled = true
|
||||
for button in choice_buttons:
|
||||
button.disabled = true
|
||||
if feedback_fog != null:
|
||||
@@ -462,6 +525,8 @@ func _update_answer_feedback(delta: float) -> void:
|
||||
if correct_advance_delay <= 0.0:
|
||||
pending_correct_advance = false
|
||||
next_button.disabled = false
|
||||
if skip_button != null:
|
||||
skip_button.disabled = false
|
||||
_advance_or_finish()
|
||||
|
||||
|
||||
@@ -474,6 +539,8 @@ func _reset_wrong_feedback() -> void:
|
||||
correct_advance_delay = 0.0
|
||||
pending_correct_advance = false
|
||||
next_button.disabled = false
|
||||
if skip_button != null:
|
||||
skip_button.disabled = false
|
||||
if feedback_fog != null:
|
||||
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
|
||||
|
||||
@@ -596,22 +663,38 @@ func _apply_saved_choice_state() -> void:
|
||||
else:
|
||||
choice_buttons[index].modulate = Color.WHITE
|
||||
|
||||
if pending_correct_advance:
|
||||
_apply_correct_answer_preview()
|
||||
|
||||
|
||||
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
|
||||
var choice_id := str(choice.get("id", ""))
|
||||
var choice_text := str(choice.get("text", ""))
|
||||
_set_choice_percent_label(index, "")
|
||||
if locked_wrong_choice_ids.has(choice_id):
|
||||
var text := "%s. %s" % [choice_id, _strike_text(choice_text)]
|
||||
var percentage := _get_choice_selection_percentage(choice_id)
|
||||
if percentage >= 0:
|
||||
text += "\n %d%%" % percentage
|
||||
choice_buttons[index].text = text
|
||||
_set_choice_percent_label(index, "%d%%" % percentage)
|
||||
choice_buttons[index].text = "%s. %s" % [choice_id, _strike_text(choice_text)]
|
||||
elif _should_show_correct_choice_percentage(choice_id):
|
||||
var correct_percentage := _get_choice_selection_percentage(choice_id)
|
||||
if correct_percentage >= 0:
|
||||
_set_choice_percent_label(index, "%d%%" % correct_percentage)
|
||||
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
|
||||
elif eliminated.has(choice_id):
|
||||
choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)]
|
||||
else:
|
||||
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
|
||||
|
||||
|
||||
func _set_choice_percent_label(index: int, text: String) -> void:
|
||||
if index < 0 or index >= choice_percent_labels.size():
|
||||
return
|
||||
var label := choice_percent_labels[index]
|
||||
label.text = text
|
||||
label.visible = text != ""
|
||||
|
||||
|
||||
func _get_choice_selection_percentage(choice_id: String) -> int:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
var percentages: Dictionary = question.get("choice_selection_percentages", {})
|
||||
@@ -622,6 +705,21 @@ func _get_choice_selection_percentage(choice_id: String) -> int:
|
||||
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
|
||||
|
||||
|
||||
func _should_show_correct_choice_percentage(choice_id: String) -> bool:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty())
|
||||
|
||||
|
||||
func _apply_correct_answer_preview() -> void:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
var correct_id := str(question.get("correct_choice_id", ""))
|
||||
var choices: Array = question.get("choices", [])
|
||||
for index in range(choices.size()):
|
||||
if str(choices[index].get("id", "")) == correct_id:
|
||||
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
|
||||
return
|
||||
|
||||
|
||||
func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -631,6 +729,37 @@ func _on_choice_percentages_updated(question_id: String, percentages: Dictionary
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void:
|
||||
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
||||
return
|
||||
BlockSessionService.update_question_elo(question_id, question_elo)
|
||||
_update_question_elo_label(not current_answer.is_empty() or pending_correct_advance)
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void:
|
||||
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
||||
return
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if correct_choice_id != "":
|
||||
question["correct_choice_id"] = correct_choice_id
|
||||
BlockSessionService.update_question_elo(question_id, question_elo)
|
||||
_update_question_elo_label(true)
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _update_question_elo_label(show_label: bool) -> void:
|
||||
if question_elo_label == null:
|
||||
return
|
||||
if not show_label:
|
||||
question_elo_label.visible = false
|
||||
return
|
||||
var question := BlockSessionService.get_current_question()
|
||||
var question_elo := int(question.get("question_elo", 1000))
|
||||
question_elo_label.text = "Question ELO %d" % question_elo
|
||||
question_elo_label.visible = true
|
||||
|
||||
|
||||
func _strike_text(value: String) -> String:
|
||||
var result := ""
|
||||
for character in value:
|
||||
|
||||
@@ -48,11 +48,10 @@ func _ready() -> void:
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
var results: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
var accuracy := int(round(float(results.get("accuracy", 0.0)) * 100.0))
|
||||
summary_label.text = "%d/%d correct (%d%%) | +%d XP | Streak: %d | New badges: %s" % [
|
||||
summary_label.text = "%d/%d correct (%d%%) | Streak: %d | New badges: %s" % [
|
||||
results.get("correct", 0),
|
||||
results.get("total", 0),
|
||||
accuracy,
|
||||
results.get("xp_earned", 0),
|
||||
results.get("ending_streak", 0),
|
||||
", ".join(results.get("badges_unlocked", [])) if results.get("badges_unlocked", []).size() > 0 else "None"
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ func _ready() -> void:
|
||||
add_child(title)
|
||||
|
||||
var help := Label.new()
|
||||
help.text = "Choose a system zone. Strong scores unlock momentum badges and level progress."
|
||||
help.text = "Choose a system zone. Strong scores raise your rating and build momentum."
|
||||
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(help, 16, Style.MUTED)
|
||||
add_child(help)
|
||||
|
||||
Reference in New Issue
Block a user