Add daily study missions
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class DailyMissionPolicyTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void DueReviewsArePrioritizedAndCappedAtTen()
|
||||
{
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(17), Questions(), Now);
|
||||
Assert.Equal(DailyMissionTypes.DueReviews, candidates[0].Type);
|
||||
Assert.Equal(10, candidates[0].Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoReviewsFallsBackToCoverageGaps()
|
||||
{
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), Questions(), Now);
|
||||
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DueReviews);
|
||||
Assert.True(candidates.Count >= 3);
|
||||
Assert.Contains(candidates.Take(3), value => value.Type == DailyMissionTypes.DifficultyFourCorrect);
|
||||
Assert.All(candidates.Where(value => value.Type == DailyMissionTypes.WeakSystemCorrect),
|
||||
value => Assert.Equal(3, value.Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardMissionRequiresThreeEligiblePublishedQuestions()
|
||||
{
|
||||
var questions = Questions();
|
||||
questions.RemoveAll(value => value.Step3System == Step3SystemCatalog.All[1] && value.Difficulty >= 4);
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), questions, Now);
|
||||
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DifficultyFourCorrect
|
||||
&& value.Step3System == Step3SystemCatalog.All[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionProgressRequiresCorrectEligibleActivityAfterMissionStart()
|
||||
{
|
||||
var system = Step3SystemCatalog.All[0];
|
||||
var mission = new DailyMission
|
||||
{
|
||||
Type = DailyMissionTypes.DifficultyFourCorrect, Step3System = system, StartsUtc = Now
|
||||
};
|
||||
var question = new Question { Step3System = system, Difficulty = 4 };
|
||||
Assert.True(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = false, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, IsSkip = true, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now.AddSeconds(-1) }, question));
|
||||
question.Difficulty = 3;
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
|
||||
}
|
||||
|
||||
private static DashboardResponse Dashboard(int pending) => new()
|
||||
{
|
||||
MissedQuestionCount = pending,
|
||||
Systems = Step3SystemCatalog.All.Take(4).Select((system, index) => new DashboardSystemPerformance
|
||||
{
|
||||
System = system, MasteryState = index == 0 ? MasteryStates.Unseen : MasteryStates.Learning,
|
||||
MasteryScore = index * 10, DistinctQuestions = index, Difficulty4PlusSuccessful = index
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static List<Question> Questions() => Step3SystemCatalog.All.Take(4)
|
||||
.SelectMany(system => Enumerable.Range(0, 6).Select(index => new Question
|
||||
{
|
||||
ExternalId = $"{system}-{index}", Step3System = system, Difficulty = index < 3 ? 4 : 2,
|
||||
IsPublished = true
|
||||
})).ToList();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/daily-missions")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class DailyMissionsController(
|
||||
DailyMissionStore missions, DashboardStore dashboard, QuestionStore questions) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await missions.GetOrCreateAsync(UserId(), await dashboard.GetAsync(UserId(), now),
|
||||
await questions.GetPublishedAsync()));
|
||||
}
|
||||
|
||||
[HttpPost("{missionId}/replace")]
|
||||
public async Task<IActionResult> Replace(string missionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await missions.ReplaceAsync(UserId(), missionId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (ArgumentException exception) { return NotFound(exception.Message); }
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly CreditStore _credits;
|
||||
private readonly MasteryAwardService _masteryAwards;
|
||||
private readonly StudySessionStore _studySessions;
|
||||
private readonly DailyMissionStore _dailyMissions;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -34,7 +35,8 @@ public class QuestionsController : ControllerBase
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions)
|
||||
StudySessionStore studySessions,
|
||||
DailyMissionStore dailyMissions)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -47,6 +49,7 @@ public class QuestionsController : ControllerBase
|
||||
_credits = credits;
|
||||
_masteryAwards = masteryAwards;
|
||||
_studySessions = studySessions;
|
||||
_dailyMissions = dailyMissions;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -232,6 +235,8 @@ public class QuestionsController : ControllerBase
|
||||
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
||||
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
|
||||
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
|
||||
return Ok(new FirstQuestionAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -243,7 +248,8 @@ public class QuestionsController : ControllerBase
|
||||
Credits = await _credits.GetStatusAsync(userId),
|
||||
CreditAwards = creditAwards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary
|
||||
SessionSummary = studySession?.Summary,
|
||||
DailyMissions = dailyMissions?.State
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed class ReviewQueueController(
|
||||
QuestionBookmarkStore bookmarks,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions) : ControllerBase
|
||||
StudySessionStore studySessions,
|
||||
DailyMissionStore dailyMissions) : ControllerBase
|
||||
{
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||
@@ -107,6 +108,8 @@ public sealed class ReviewQueueController(
|
||||
awards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
||||
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
|
||||
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
|
||||
return Ok(new ReviewQueueAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -116,7 +119,8 @@ public sealed class ReviewQueueController(
|
||||
Credits = await credits.GetStatusAsync(userId),
|
||||
CreditAwards = awards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary
|
||||
SessionSummary = studySession?.Summary,
|
||||
DailyMissions = missionResult?.State
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ public static class CreditReasonCodes
|
||||
public const string MasteryMastered = "mastery_mastered";
|
||||
public const string ExamSimulationComplete = "exam_simulation_complete";
|
||||
public const string SessionGoalComplete = "session_goal_complete";
|
||||
public const string DailyMissionComplete = "daily_mission_complete";
|
||||
public const string Purchase = "purchase";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class DailyMissionTypes
|
||||
{
|
||||
public const string DueReviews = "due_reviews";
|
||||
public const string WeakSystemCorrect = "weak_system_correct";
|
||||
public const string DifficultyFourCorrect = "difficulty_four_correct";
|
||||
}
|
||||
|
||||
public sealed class DailyMission
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Type { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int DifficultyMin { get; set; }
|
||||
public int Target { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public DateTime StartsUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public List<string> AppliedEventIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DailyMissionSet
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string DateUtc { get; set; } = "";
|
||||
public List<DailyMission> Missions { get; set; } = [];
|
||||
public List<DailyMission> ReplacementCandidates { get; set; } = [];
|
||||
public bool ReplacementUsed { get; set; }
|
||||
public bool ExpirationRecorded { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DailyMissionResponse
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int DifficultyMin { get; set; }
|
||||
public int Target { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DailyMissionSetResponse
|
||||
{
|
||||
public string DateUtc { get; set; } = "";
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public bool ReplacementAvailable { get; set; }
|
||||
public List<DailyMissionResponse> Missions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DailyMissionEnvelope
|
||||
{
|
||||
public DailyMissionSetResponse State { get; set; } = new();
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
@@ -149,6 +149,7 @@ public class FirstQuestionAttemptResponse
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
|
||||
@@ -86,6 +86,7 @@ public sealed class ReviewQueueAttemptResponse
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueRecordResult
|
||||
|
||||
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<StudySessionStore>();
|
||||
builder.Services.AddSingleton<DailyMissionStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
||||
builder.Services.AddSingleton<FlashcardDeckStore>();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DailyMissionStore
|
||||
{
|
||||
private readonly IMongoCollection<DailyMissionSet> _sets;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
|
||||
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
|
||||
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await RecordExpiredAsync(userId, now);
|
||||
var key = DateKey(now);
|
||||
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (existing is not null) return Envelope(existing);
|
||||
var candidates = BuildCandidates(dashboard, published, now);
|
||||
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
|
||||
var set = new DailyMissionSet
|
||||
{
|
||||
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
|
||||
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
|
||||
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
|
||||
};
|
||||
try
|
||||
{
|
||||
await _sets.InsertOneAsync(set);
|
||||
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
|
||||
}
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
var index = set.Missions.FindIndex(value => value.Id == missionId);
|
||||
if (index < 0) throw new ArgumentException("Mission not found.");
|
||||
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
|
||||
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
|
||||
var replacement = set.ReplacementCandidates[0];
|
||||
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
|
||||
set.Missions[index] = replacement;
|
||||
set.ReplacementCandidates.RemoveAt(0);
|
||||
set.ReplacementUsed = true;
|
||||
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
|
||||
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
_metrics.DailyMissionReplaced();
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
|
||||
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesQuestion(mission, attempt, question));
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
|
||||
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesReview(mission, attempt));
|
||||
|
||||
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
|
||||
{
|
||||
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
|
||||
var system = Step3SystemCatalog.Normalize(question);
|
||||
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|
||||
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
|
||||
&& mission.Step3System == system && question.Difficulty >= 4;
|
||||
}
|
||||
|
||||
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
|
||||
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
|
||||
|
||||
private async Task<DailyMissionEnvelope?> RecordAsync(
|
||||
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
|
||||
&& value.StartsUtc <= eventUtc && qualifies(value)))
|
||||
{
|
||||
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
|
||||
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
|
||||
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
|
||||
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
|
||||
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
var awards = new List<CreditAward>();
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
|
||||
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
|
||||
var completed = await _sets.UpdateOneAsync(filter,
|
||||
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
|
||||
if (completed.ModifiedCount != 1) continue;
|
||||
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
|
||||
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
_metrics.DailyMissionCompleted(mission.Type);
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
|
||||
}
|
||||
|
||||
public static List<DailyMission> BuildCandidates(
|
||||
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
|
||||
{
|
||||
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
|
||||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||||
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
|
||||
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
|
||||
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
|
||||
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
|
||||
var result = new List<DailyMission>();
|
||||
if (dashboard.MissedQuestionCount > 0)
|
||||
result.Add(New(DailyMissionTypes.DueReviews,
|
||||
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
|
||||
Math.Min(10, dashboard.MissedQuestionCount), now));
|
||||
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
|
||||
New(DailyMissionTypes.WeakSystemCorrect,
|
||||
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
|
||||
var hard = systems.Where(value => availability[value.System].Hard >= 3)
|
||||
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
|
||||
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
|
||||
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
|
||||
var weakList = weak.ToList();
|
||||
var hardList = hard.ToList();
|
||||
if (weakList.Count > 0) result.Add(weakList[0]);
|
||||
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
|
||||
?? hardList.FirstOrDefault();
|
||||
if (firstHard is not null) result.Add(firstHard);
|
||||
result.AddRange(weakList.Skip(1));
|
||||
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
|
||||
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
|
||||
}
|
||||
|
||||
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
|
||||
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
|
||||
Target = target, StartsUtc = now };
|
||||
|
||||
private static int MasteryRank(string state) => state switch
|
||||
{
|
||||
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||
MasteryStates.Strong => 3, _ => 4
|
||||
};
|
||||
|
||||
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task RecordExpiredAsync(string userId, DateTime now)
|
||||
{
|
||||
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
||||
&& !value.ExpirationRecorded).ToListAsync();
|
||||
foreach (var set in expired)
|
||||
{
|
||||
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
|
||||
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
|
||||
if (changed.ModifiedCount != 1) continue;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
|
||||
_metrics.DailyMissionExpired(mission.Type);
|
||||
}
|
||||
}
|
||||
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
|
||||
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
|
||||
{
|
||||
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
|
||||
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
|
||||
&& set.Missions.Any(value => value.CompletedUtc is null),
|
||||
Missions = set.Missions.Select(value => new DailyMissionResponse
|
||||
{
|
||||
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
|
||||
DifficultyMin = value.DifficultyMin, Target = value.Target,
|
||||
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,10 @@ public sealed class ServiceMetrics
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionAssigned = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
|
||||
private long _dailyMissionReplaced;
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
@@ -54,6 +58,10 @@ public sealed class ServiceMetrics
|
||||
}
|
||||
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void DailyMissionAssigned(string type) => _dailyMissionAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
@@ -85,6 +93,16 @@ public sealed class ServiceMetrics
|
||||
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
|
||||
}
|
||||
output.AppendLine("# TYPE step3_daily_missions_assigned_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_completed_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter");
|
||||
foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order())
|
||||
{
|
||||
AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced));
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
@@ -116,4 +134,7 @@ public sealed class ServiceMetrics
|
||||
|
||||
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
|
||||
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
|
||||
private static void AppendMissionSample(StringBuilder output, string name, string type, long value) =>
|
||||
output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions"
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -158,7 +158,7 @@ social notifications.
|
||||
- [x] Define the mastery model and expose topic-level progress.
|
||||
- [x] Add server-authoritative RZ Coin awards for approved learning events.
|
||||
- [x] Add user-selected session goals and an end-of-session summary.
|
||||
- [ ] Add daily missions with one replacement per day.
|
||||
- [x] Add daily missions with one replacement per day.
|
||||
|
||||
### Phase 2: progression
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||
CreditsService="*res://scripts/autoload/credits_service.gd"
|
||||
StudySessionService="*res://scripts/autoload/study_session_service.gd"
|
||||
DailyMissionService="*res://scripts/autoload/daily_mission_service.gd"
|
||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Node
|
||||
|
||||
signal missions_changed
|
||||
signal missions_failed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/daily-missions"
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var request: HTTPRequest
|
||||
var state: Dictionary = {}
|
||||
var loading := false
|
||||
var pending_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/daily-missions")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
set_process(true)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("refresh", HTTPClient.METHOD_GET, "")
|
||||
|
||||
|
||||
func replace(mission_id: String) -> void:
|
||||
if loading or mission_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("replace", HTTPClient.METHOD_POST, "/%s/replace" % mission_id.uri_encode())
|
||||
|
||||
|
||||
func apply_response(parsed: Dictionary) -> void:
|
||||
var next_state: Variant = parsed.get("dailyMissions", null)
|
||||
if typeof(next_state) == TYPE_DICTIONARY:
|
||||
state = next_state
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _send(action: String, method: int, suffix: String) -> void:
|
||||
loading = true
|
||||
pending_action = action
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
var error := request.request(api_url + suffix, headers, method)
|
||||
if error != OK:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
missions_failed.emit("Daily missions could not be loaded.")
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
missions_failed.emit("Daily mission request failed.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY:
|
||||
missions_failed.emit("The daily mission response was invalid.")
|
||||
return
|
||||
state = parsed.get("state", {})
|
||||
CreditsService.apply_award_response(parsed)
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if state.is_empty() or loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z"))
|
||||
if expires > 0 and Time.get_unix_time_from_system() >= expires:
|
||||
state = {}
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
state = {}
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request.cancel_request()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwafdsrqdxleo
|
||||
@@ -552,6 +552,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
# Let Tutor users inspect the final answer before the summary replaces the player.
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
|
||||
@@ -198,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
||||
var synced_is_correct := false
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
||||
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
|
||||
@@ -23,6 +23,7 @@ var focus_content: VBoxContainer
|
||||
var review_list: VBoxContainer
|
||||
var recent_list: VBoxContainer
|
||||
var weekly_summary: VBoxContainer
|
||||
var mission_content: VBoxContainer
|
||||
var metric_values: Dictionary = {}
|
||||
var metric_details: Dictionary = {}
|
||||
var metric_trends: Dictionary = {}
|
||||
@@ -34,6 +35,7 @@ var pending_review_launch := false
|
||||
var review_queue_service: Variant
|
||||
var trend_mode := "rating"
|
||||
var mastery_rows: Array[BoxContainer] = []
|
||||
var mission_rows: Array[BoxContainer] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -41,6 +43,8 @@ func _ready() -> void:
|
||||
review_queue_service = get_node("/root/ReviewQueueService")
|
||||
DashboardService.dashboard_changed.connect(_render)
|
||||
DashboardService.dashboard_failed.connect(_show_error)
|
||||
DailyMissionService.missions_changed.connect(_render_missions)
|
||||
DailyMissionService.missions_failed.connect(_show_mission_error)
|
||||
review_queue_service.question_loaded.connect(_on_review_question_loaded)
|
||||
review_queue_service.question_failed.connect(_on_review_question_failed)
|
||||
review_queue_service.pass_completed.connect(_on_review_pass_completed)
|
||||
@@ -55,6 +59,7 @@ func on_show(_payload: Variant = null) -> void:
|
||||
else:
|
||||
_render()
|
||||
DashboardService.refresh()
|
||||
DailyMissionService.refresh()
|
||||
|
||||
|
||||
func _build() -> void:
|
||||
@@ -65,6 +70,7 @@ func _build() -> void:
|
||||
metric_details.clear()
|
||||
metric_trends.clear()
|
||||
mastery_rows.clear()
|
||||
mission_rows.clear()
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -82,6 +88,9 @@ func _build() -> void:
|
||||
stat_card_grid.add_theme_constant_override("h_separation", 12)
|
||||
stat_card_grid.add_theme_constant_override("v_separation", 12)
|
||||
content.add_child(stat_card_grid)
|
||||
mission_content = VBoxContainer.new()
|
||||
mission_content.add_theme_constant_override("separation", 10)
|
||||
content.add_child(_card(mission_content, 16))
|
||||
|
||||
qbank_sparkline = DashboardSparkline.new()
|
||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
@@ -211,6 +220,88 @@ func _render() -> void:
|
||||
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
||||
_render_weekly(data, weekly)
|
||||
_render_recent(data.get("recentActivity", []))
|
||||
_render_missions()
|
||||
|
||||
|
||||
func _render_missions() -> void:
|
||||
if mission_content == null:
|
||||
return
|
||||
_clear(mission_content)
|
||||
mission_rows.clear()
|
||||
var header := HBoxContainer.new()
|
||||
header.add_child(_section_title("Daily missions"))
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
header.add_child(spacer)
|
||||
header.add_child(_label("Resets at 00:00 UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
mission_content.add_child(header)
|
||||
var missions: Array = DailyMissionService.state.get("missions", [])
|
||||
if missions.is_empty():
|
||||
mission_content.add_child(_label("Loading today's focused study missions...", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
return
|
||||
var replacement_available := bool(DailyMissionService.state.get("replacementAvailable", false))
|
||||
for mission_value in missions:
|
||||
var mission: Dictionary = mission_value
|
||||
var row := BoxContainer.new()
|
||||
row.vertical = size.x < 700.0
|
||||
mission_rows.append(row)
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
var details := VBoxContainer.new()
|
||||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var completed := bool(mission.get("completed", false))
|
||||
var title := _label(str(mission.get("title", "Study mission")), 14,
|
||||
Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
details.add_child(title)
|
||||
var progress_value := mini(int(mission.get("progress", 0)), int(mission.get("target", 1)))
|
||||
var target := maxi(1, int(mission.get("target", 1)))
|
||||
var progress := ProgressBar.new()
|
||||
progress.show_percentage = false
|
||||
progress.max_value = target
|
||||
progress.value = progress_value
|
||||
progress.custom_minimum_size.y = 7
|
||||
_style_progress(progress, Brand.SUCCESS if completed else Brand.ROYAL_BLUE)
|
||||
details.add_child(progress)
|
||||
details.add_child(_label("Complete - 3 RZ Coins earned" if completed else "%d of %d" % [progress_value, target],
|
||||
11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
row.add_child(details)
|
||||
var launch := _button("Review" if str(mission.get("type", "")) == "due_reviews" else "Start")
|
||||
launch.disabled = completed
|
||||
launch.pressed.connect(_launch_mission.bind(mission))
|
||||
row.add_child(launch)
|
||||
if replacement_available and not completed:
|
||||
var replace := Button.new()
|
||||
replace.text = "Replace"
|
||||
replace.flat = true
|
||||
replace.pressed.connect(_replace_mission.bind(str(mission.get("id", ""))))
|
||||
row.add_child(replace)
|
||||
mission_content.add_child(row)
|
||||
|
||||
|
||||
func _launch_mission(mission: Dictionary) -> void:
|
||||
if str(mission.get("type", "")) == "due_reviews":
|
||||
_start_review_queue()
|
||||
return
|
||||
main.show_screen("setup", {
|
||||
"mastery_target": true,
|
||||
"step3_system": str(mission.get("step3System", "")),
|
||||
"question_count": 10,
|
||||
"difficulty_min": int(mission.get("difficultyMin", 0)),
|
||||
"difficulty_max": 5 if int(mission.get("difficultyMin", 0)) > 0 else 0
|
||||
})
|
||||
|
||||
|
||||
func _replace_mission(mission_id: String) -> void:
|
||||
DailyMissionService.replace(mission_id)
|
||||
_render_missions()
|
||||
|
||||
|
||||
func _show_mission_error(message: String) -> void:
|
||||
if mission_content == null:
|
||||
return
|
||||
_clear(mission_content)
|
||||
mission_content.add_child(_section_title("Daily missions"))
|
||||
mission_content.add_child(_label(message, 13, Brand.MAGENTA))
|
||||
|
||||
|
||||
func _trend_series(trend: Array, field: String) -> Array:
|
||||
@@ -837,6 +928,8 @@ func _update_responsive() -> void:
|
||||
for child in row.get_children():
|
||||
if child is Button:
|
||||
child.custom_minimum_size.x = 0.0 if narrow else 155.0
|
||||
for row in mission_rows:
|
||||
row.vertical = narrow
|
||||
|
||||
|
||||
func _clear(container: Container) -> void:
|
||||
|
||||
@@ -9,8 +9,15 @@ func _initialize() -> void:
|
||||
|
||||
func _run() -> void:
|
||||
var dashboard_service = root.get_node("DashboardService")
|
||||
var mission_service = root.get_node("DailyMissionService")
|
||||
var prior_data: Dictionary = dashboard_service.data
|
||||
var prior_missions: Dictionary = mission_service.state
|
||||
dashboard_service.data = _dashboard_data()
|
||||
mission_service.state = {"replacementAvailable": true, "missions": [
|
||||
{"id": "m1", "type": "due_reviews", "title": "Complete 3 scheduled reviews", "target": 3, "progress": 1, "completed": false},
|
||||
{"id": "m2", "type": "weak_system_correct", "title": "Get 3 correct", "target": 3, "progress": 3, "completed": true},
|
||||
{"id": "m3", "type": "difficulty_four_correct", "title": "Get 3 hard correct", "target": 3, "progress": 0, "completed": false}
|
||||
]}
|
||||
var dashboard = load("res://scripts/ui/dashboard_screen.gd").new()
|
||||
dashboard.size = Vector2(1280, 900)
|
||||
root.add_child(dashboard)
|
||||
@@ -22,9 +29,13 @@ func _run() -> void:
|
||||
_assert(dashboard.mastery_rows.size() == 14, "dashboard should render one mastery action row per system")
|
||||
var first_mastery_row: BoxContainer = dashboard.mastery_rows[0]
|
||||
_assert(not first_mastery_row.vertical, "desktop mastery rows should remain horizontal")
|
||||
_assert(dashboard.mission_rows.size() == 3, "dashboard should render all three daily missions")
|
||||
var first_mission_row: BoxContainer = dashboard.mission_rows[0]
|
||||
_assert(not first_mission_row.vertical, "desktop mission rows should remain horizontal")
|
||||
dashboard.size = Vector2(640, 900)
|
||||
dashboard._update_responsive()
|
||||
_assert(first_mastery_row.vertical, "narrow mastery rows should stack to avoid clipping")
|
||||
_assert(first_mission_row.vertical, "narrow mission rows should stack to avoid clipping")
|
||||
_assert(dashboard.rating_toggle.button_pressed, "rating should be the default trend mode")
|
||||
_assert(dashboard.trend_chart.mode == "rating", "chart should initially render rating history")
|
||||
dashboard._set_trend_mode("accuracy")
|
||||
@@ -33,6 +44,7 @@ func _run() -> void:
|
||||
|
||||
dashboard.queue_free()
|
||||
dashboard_service.data = prior_data
|
||||
mission_service.state = prior_missions
|
||||
await process_frame
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user