commit 277076a24581c5925a2d1c6cfbd278abab9b8616 Author: hz Date: Thu Jul 9 20:50:33 2026 -0500 Initial STEP 3 study game and questions API diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.github/workflows/questions-api.yml b/.github/workflows/questions-api.yml new file mode 100644 index 0000000..27bc015 --- /dev/null +++ b/.github/workflows/questions-api.yml @@ -0,0 +1,65 @@ +name: Questions API + +on: + push: + branches: [main] + paths: + - "api/QuestionsApi/**" + - ".github/workflows/questions-api.yml" + pull_request: + paths: + - "api/QuestionsApi/**" + - ".github/workflows/questions-api.yml" + +env: + DOTNET_VERSION: "8.0.x" + IMAGE_NAME: ghcr.io/${{ github.repository }}/questions-api + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore api/QuestionsApi/QuestionsApi.csproj + + - name: Build + run: dotnet build api/QuestionsApi/QuestionsApi.csproj --configuration Release --no-restore + + - name: Publish + run: dotnet publish api/QuestionsApi/QuestionsApi.csproj --configuration Release --no-build --output publish/questions-api + + - name: Upload publish artifact + uses: actions/upload-artifact@v4 + with: + name: questions-api-publish + path: publish/questions-api + + - name: Log in to GitHub Container Registry + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + if: github.event_name == 'push' + uses: docker/build-push-action@v6 + with: + context: api/QuestionsApi + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.IMAGE_NAME }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb21f03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Godot 4+ specific ignores +.godot/ +/android/ + +# .NET build output +**/bin/ +**/obj/ + +# Local editor/user files +*.user +*.suo diff --git a/README.md b/README.md new file mode 100644 index 0000000..a357e48 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# STEP 3 Arcade + +Godot study game with a bundled ASP.NET Core Questions API. + +## Local API + +Run the Questions API: + +```powershell +dotnet run --project api/QuestionsApi/QuestionsApi.csproj +``` + +For deployed environments, set these as environment variables: + +```text +MongoDB__ConnectionString +MongoDB__DatabaseName +Jwt__Key +Jwt__Issuer +Jwt__Audience +``` + +Default local endpoint: + +```text +http://localhost:5008/api/questions/search +``` + +The Godot client reads this from `project.godot`: + +```text +step3/questions_api_url +``` + +## Push Build + +`.github/workflows/questions-api.yml` builds and publishes the Questions API Docker image to GitHub Container Registry on pushes to `main`. + +Set deployment hosting to consume: + +```text +ghcr.io///questions-api:latest +``` diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs new file mode 100644 index 0000000..0ee2fe3 --- /dev/null +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; +using QuestionsApi.Models; +using QuestionsApi.Services; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class QuestionsController : ControllerBase +{ + private readonly QuestionStore _questions; + + public QuestionsController(QuestionStore questions) + { + _questions = questions; + } + + [HttpGet("systems")] + [Authorize(Roles = "USER,SUPER")] + public async Task Systems() + { + var systems = await _questions.GetSystemsAsync(User.IsInRole("SUPER")); + return Ok(systems); + } + + [HttpPost("search")] + [Authorize(Roles = "USER,SUPER")] + public async Task Search([FromBody] QuestionSearchRequest req) + { + var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); + return Ok(questions.Select(QuestionResponse.FromModel).ToList()); + } + + [HttpGet("{id}")] + [Authorize(Roles = "USER,SUPER")] + public async Task Get(string id) + { + var question = await _questions.GetByIdAsync(id); + if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + return NotFound(); + + return Ok(QuestionResponse.FromModel(question)); + } + + [HttpPost] + [Authorize(Roles = "SUPER")] + public async Task Create([FromBody] UpsertQuestionRequest req) + { + var validation = ValidateQuestion(req); + if (validation is not null) + return BadRequest(validation); + + var created = await _questions.CreateAsync(req); + if (created is null) + return Conflict("Question externalId already exists"); + + return Ok(QuestionResponse.FromModel(created)); + } + + [HttpPost("bulk")] + [Authorize(Roles = "SUPER")] + public async Task BulkUpsert([FromBody] BulkUpsertQuestionsRequest req) + { + if (req.Questions.Count == 0) + return BadRequest("questions required"); + + foreach (var question in req.Questions) + { + var validation = ValidateQuestion(question); + if (validation is not null) + return BadRequest($"{question.ExternalId}: {validation}"); + } + + var upserted = await _questions.UpsertManyByExternalIdAsync(req.Questions); + return Ok(new { upserted }); + } + + [HttpPut("{id}")] + [Authorize(Roles = "SUPER")] + public async Task Update(string id, [FromBody] UpsertQuestionRequest req) + { + var validation = ValidateQuestion(req); + if (validation is not null) + return BadRequest(validation); + + try + { + var updated = await _questions.UpdateAsync(id, req); + if (updated is null) + return NotFound(); + + return Ok(QuestionResponse.FromModel(updated)); + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return Conflict("Question externalId already exists"); + } + } + + [HttpDelete("{id}")] + [Authorize(Roles = "SUPER")] + public async Task Delete(string id) + { + var deleted = await _questions.DeleteAsync(id); + if (!deleted) + return NotFound(); + + return Ok("Deleted"); + } + + private static string? ValidateQuestion(UpsertQuestionRequest req) + { + if (string.IsNullOrWhiteSpace(req.ExternalId)) + return "externalId required"; + if (string.IsNullOrWhiteSpace(req.Stem)) + return "stem required"; + if (string.IsNullOrWhiteSpace(req.LeadIn)) + return "leadIn required"; + if (req.Choices.Count < 2) + return "at least two choices required"; + if (string.IsNullOrWhiteSpace(req.CorrectChoiceId)) + return "correctChoiceId required"; + if (!req.Choices.Any(c => c.Id == req.CorrectChoiceId)) + return "correctChoiceId must match a choice id"; + if (string.IsNullOrWhiteSpace(req.Explanation)) + return "explanation required"; + if (string.IsNullOrWhiteSpace(req.System)) + return "system required"; + if (string.IsNullOrWhiteSpace(req.Discipline)) + return "discipline required"; + if (req.Difficulty is < 1 or > 5) + return "difficulty must be between 1 and 5"; + + return null; + } +} diff --git a/api/QuestionsApi/Dockerfile b/api/QuestionsApi/Dockerfile new file mode 100644 index 0000000..cbfec21 --- /dev/null +++ b/api/QuestionsApi/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["QuestionsApi.csproj", "./"] +RUN dotnet restore "QuestionsApi.csproj" + +COPY . . +RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "QuestionsApi.dll"] diff --git a/api/QuestionsApi/Models/BulkUpsertQuestionsRequest.cs b/api/QuestionsApi/Models/BulkUpsertQuestionsRequest.cs new file mode 100644 index 0000000..e7ddda6 --- /dev/null +++ b/api/QuestionsApi/Models/BulkUpsertQuestionsRequest.cs @@ -0,0 +1,6 @@ +namespace QuestionsApi.Models; + +public class BulkUpsertQuestionsRequest +{ + public List Questions { get; set; } = []; +} diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs new file mode 100644 index 0000000..8f385b8 --- /dev/null +++ b/api/QuestionsApi/Models/Question.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public class Question +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string ExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string Explanation { get; set; } = ""; + public Dictionary ChoiceExplanations { get; set; } = []; + public string Discipline { get; set; } = ""; + public string System { get; set; } = ""; + public int Difficulty { get; set; } = 1; + public List Labs { get; set; } = []; + public List Media { get; set; } = []; + public List Tags { get; set; } = []; + public bool IsPublished { get; set; } = true; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public class QuestionChoice +{ + public string Id { get; set; } = ""; + public string Text { get; set; } = ""; +} + +public class QuestionLab +{ + public string Name { get; set; } = ""; + public string Value { get; set; } = ""; + public string Unit { get; set; } = ""; + public string Reference { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs new file mode 100644 index 0000000..81465d5 --- /dev/null +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -0,0 +1,29 @@ +namespace QuestionsApi.Models; + +public class QuestionSearchRequest +{ + public string? System { get; set; } + public string? Discipline { get; set; } + public List Tags { get; set; } = []; + public int? DifficultyMin { get; set; } + public int? DifficultyMax { get; set; } + public int Limit { get; set; } = 40; +} + +public class UpsertQuestionRequest +{ + public string ExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string Explanation { get; set; } = ""; + public Dictionary ChoiceExplanations { get; set; } = []; + public string Discipline { get; set; } = ""; + public string System { get; set; } = ""; + public int Difficulty { get; set; } = 1; + public List Labs { get; set; } = []; + public List Media { get; set; } = []; + public List Tags { get; set; } = []; + public bool IsPublished { get; set; } = true; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs new file mode 100644 index 0000000..ccdbc1a --- /dev/null +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -0,0 +1,37 @@ +namespace QuestionsApi.Models; + +public class QuestionResponse +{ + public string Id { get; set; } = ""; + public string ExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string Explanation { get; set; } = ""; + public Dictionary ChoiceExplanations { get; set; } = []; + public string Discipline { get; set; } = ""; + public string System { get; set; } = ""; + public int Difficulty { get; set; } + public List Labs { get; set; } = []; + public List Media { get; set; } = []; + public List Tags { get; set; } = []; + + public static QuestionResponse FromModel(Question question) => new() + { + Id = question.Id ?? "", + ExternalId = question.ExternalId, + Stem = question.Stem, + LeadIn = question.LeadIn, + Choices = question.Choices, + CorrectChoiceId = question.CorrectChoiceId, + Explanation = question.Explanation, + ChoiceExplanations = question.ChoiceExplanations, + Discipline = question.Discipline, + System = question.System, + Difficulty = question.Difficulty, + Labs = question.Labs, + Media = question.Media, + Tags = question.Tags + }; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs new file mode 100644 index 0000000..158deda --- /dev/null +++ b/api/QuestionsApi/Program.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using QuestionsApi.Services; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +builder.Services.AddSingleton(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Questions API", Version = "v1" }); + c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Paste your access token here (no 'Bearer ' prefix needed)." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() + } + }); +}); + +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "promiscuity"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = issuer, + ValidateAudience = true, ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var exception = feature?.Error; + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + var traceId = context.TraceIdentifier; + + if (exception is not null) + { + logger.LogError( + exception, + "Unhandled exception for {Method} {Path}. TraceId={TraceId}", + context.Request.Method, + context.Request.Path, + traceId + ); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new + { + type = "https://httpstatuses.com/500", + title = "Internal Server Error", + status = 500, + detail = exception?.Message ?? "An unexpected server error occurred.", + traceId + }); + }); +}); + +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseSwagger(); +app.UseSwaggerUI(o => +{ + o.SwaggerEndpoint("/swagger/v1/swagger.json", "Questions API v1"); + o.RoutePrefix = "swagger"; +}); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/api/QuestionsApi/QuestionsApi.csproj b/api/QuestionsApi/QuestionsApi.csproj new file mode 100644 index 0000000..0cb0950 --- /dev/null +++ b/api/QuestionsApi/QuestionsApi.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/api/QuestionsApi/README.md b/api/QuestionsApi/README.md new file mode 100644 index 0000000..333e809 --- /dev/null +++ b/api/QuestionsApi/README.md @@ -0,0 +1,16 @@ +# QuestionsApi + +STEP-style question bank service for study games and clients. + +## Endpoints + +- `GET /healthz` Health check. +- `GET /api/questions/systems` List available systems. +- `POST /api/questions/search` Search or build a block of questions. +- `GET /api/questions/{id}` Get one question. +- `POST /api/questions` Create question (SUPER only). +- `POST /api/questions/bulk` Upsert questions by `externalId` (SUPER only). +- `PUT /api/questions/{id}` Update question (SUPER only). +- `DELETE /api/questions/{id}` Delete question (SUPER only). + +Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`. diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs new file mode 100644 index 0000000..2664514 --- /dev/null +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -0,0 +1,144 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public class QuestionStore +{ + private readonly IMongoCollection _col; + + public QuestionStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("Questions"); + + var externalIdIndex = Builders.IndexKeys.Ascending(q => q.ExternalId); + _col.Indexes.CreateOne(new CreateIndexModel(externalIdIndex, new CreateIndexOptions { Unique = true })); + var systemIndex = Builders.IndexKeys.Ascending(q => q.System).Ascending(q => q.IsPublished); + _col.Indexes.CreateOne(new CreateIndexModel(systemIndex)); + var tagsIndex = Builders.IndexKeys.Ascending(q => q.Tags); + _col.Indexes.CreateOne(new CreateIndexModel(tagsIndex)); + } + + public async Task> SearchAsync(QuestionSearchRequest req, bool includeUnpublished) + { + var builder = Builders.Filter; + var filters = new List>(); + + if (!includeUnpublished) + filters.Add(builder.Eq(q => q.IsPublished, true)); + if (!string.IsNullOrWhiteSpace(req.System) && req.System != "All Systems") + filters.Add(builder.Eq(q => q.System, req.System.Trim())); + if (!string.IsNullOrWhiteSpace(req.Discipline)) + filters.Add(builder.Eq(q => q.Discipline, req.Discipline.Trim())); + if (req.Tags.Count > 0) + filters.Add(builder.All(q => q.Tags, req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()))); + if (req.DifficultyMin is not null) + filters.Add(builder.Gte(q => q.Difficulty, req.DifficultyMin.Value)); + if (req.DifficultyMax is not null) + filters.Add(builder.Lte(q => q.Difficulty, req.DifficultyMax.Value)); + + var filter = filters.Count == 0 ? builder.Empty : builder.And(filters); + var limit = Math.Clamp(req.Limit, 1, 80); + var questions = await _col.Find(filter).Limit(limit).ToListAsync(); + return questions.OrderBy(_ => Guid.NewGuid()).ToList(); + } + + public async Task GetByIdAsync(string id) => + await _col.Find(q => q.Id == id).FirstOrDefaultAsync(); + + public async Task CreateAsync(UpsertQuestionRequest req) + { + var question = FromRequest(req); + question.CreatedUtc = DateTime.UtcNow; + question.UpdatedUtc = DateTime.UtcNow; + + try + { + await _col.InsertOneAsync(question); + return question; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return null; + } + } + + public async Task UpdateAsync(string id, UpsertQuestionRequest req) + { + var question = FromRequest(req); + question.Id = id; + question.UpdatedUtc = DateTime.UtcNow; + + var existing = await GetByIdAsync(id); + if (existing is null) + return null; + + question.CreatedUtc = existing.CreatedUtc; + var result = await _col.ReplaceOneAsync(q => q.Id == id, question); + return result.MatchedCount == 0 ? null : question; + } + + public async Task UpsertManyByExternalIdAsync(IEnumerable requests) + { + var count = 0; + foreach (var req in requests) + { + var question = FromRequest(req); + question.UpdatedUtc = DateTime.UtcNow; + + var filter = Builders.Filter.Eq(q => q.ExternalId, question.ExternalId); + var existing = await _col.Find(filter).FirstOrDefaultAsync(); + if (existing is not null) + { + question.Id = existing.Id; + question.CreatedUtc = existing.CreatedUtc; + } + else + { + question.CreatedUtc = DateTime.UtcNow; + } + + await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true }); + count++; + } + + return count; + } + + public async Task DeleteAsync(string id) + { + var result = await _col.DeleteOneAsync(q => q.Id == id); + return result.DeletedCount > 0; + } + + public async Task> GetSystemsAsync(bool includeUnpublished) + { + var filter = includeUnpublished + ? Builders.Filter.Empty + : Builders.Filter.Eq(q => q.IsPublished, true); + var systems = await _col.Distinct(q => q.System, filter).ToListAsync(); + return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList(); + } + + private static Question FromRequest(UpsertQuestionRequest req) => new() + { + ExternalId = req.ExternalId.Trim(), + Stem = req.Stem.Trim(), + LeadIn = req.LeadIn.Trim(), + Choices = req.Choices, + CorrectChoiceId = req.CorrectChoiceId.Trim(), + Explanation = req.Explanation.Trim(), + ChoiceExplanations = req.ChoiceExplanations, + Discipline = req.Discipline.Trim(), + System = req.System.Trim(), + Difficulty = req.Difficulty, + Labs = req.Labs, + Media = req.Media, + Tags = req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + IsPublished = req.IsPublished + }; +} diff --git a/api/QuestionsApi/appsettings.Development.json b/api/QuestionsApi/appsettings.Development.json new file mode 100644 index 0000000..63d796c --- /dev/null +++ b/api/QuestionsApi/appsettings.Development.json @@ -0,0 +1,6 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } }, + "MongoDB": { "ConnectionString": "mongodb://127.0.0.1:27017", "DatabaseName": "step3" }, + "Jwt": { "Key": "CHANGE_ME_USE_USER_SECRETS_OR_ENV", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } } +} diff --git a/api/QuestionsApi/appsettings.json b/api/QuestionsApi/appsettings.json new file mode 100644 index 0000000..e6ab6f6 --- /dev/null +++ b/api/QuestionsApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } }, + "MongoDB": { "ConnectionString": "mongodb://127.0.0.1:27017", "DatabaseName": "step3" }, + "Jwt": { "Key": "CHANGE_ME_USE_ENV_Jwt__Key", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/data/questions/sample_questions.json b/data/questions/sample_questions.json new file mode 100644 index 0000000..1635f24 --- /dev/null +++ b/data/questions/sample_questions.json @@ -0,0 +1,166 @@ +[ + { + "id": "sample-001", + "stem": "A 67-year-old man comes to the emergency department because of 2 hours of substernal chest pressure and diaphoresis. He has hypertension, type 2 diabetes mellitus, and chronic kidney disease. Blood pressure is 88/54 mm Hg and pulse is 112/min. ECG shows ST-segment elevation in leads II, III, and aVF. He receives aspirin and is taken for urgent coronary angiography.", + "lead_in": "Which of the following findings would most strongly suggest right ventricular infarction?", + "choices": [ + { "id": "A", "text": "Diffuse wheezing and pulsus paradoxus" }, + { "id": "B", "text": "Hypotension with clear lung fields and elevated jugular venous pressure" }, + { "id": "C", "text": "New holosystolic murmur radiating to the axilla" }, + { "id": "D", "text": "Pulmonary edema with severe hypertension" }, + { "id": "E", "text": "Wide pulse pressure and bounding peripheral pulses" } + ], + "correct_choice_id": "B", + "explanation": "Inferior myocardial infarction can involve the right ventricle. Right ventricular infarction classically causes hypotension, elevated jugular venous pressure, and clear lungs due to impaired right-sided filling without primary left-sided pulmonary congestion.", + "choice_explanations": { + "A": "This pattern suggests obstructive airway disease or tamponade physiology, not the classic right ventricular infarction triad.", + "B": "Correct. Hypotension, jugular venous distention, and clear lungs are classic for right ventricular infarction.", + "C": "A new holosystolic murmur radiating to the axilla suggests acute mitral regurgitation.", + "D": "Pulmonary edema with hypertension suggests left-sided heart failure or hypertensive emergency.", + "E": "A wide pulse pressure suggests aortic regurgitation or high-output states." + }, + "discipline": "Medicine", + "system": "Cardiovascular", + "difficulty": 2, + "labs": [], + "media": [], + "tags": ["acute coronary syndrome", "inferior MI", "shock"] + }, + { + "id": "sample-002", + "stem": "A 29-year-old woman at 34 weeks' gestation is brought to labor and delivery because of a generalized tonic-clonic seizure. She has had headache and visual spots for 1 day. Blood pressure is 172/112 mm Hg. Urine dipstick shows 3+ protein. Fetal heart tracing is category II.", + "lead_in": "Which of the following is the most appropriate immediate treatment?", + "choices": [ + { "id": "A", "text": "Diazepam infusion" }, + { "id": "B", "text": "Immediate cesarean delivery before medication" }, + { "id": "C", "text": "Intravenous magnesium sulfate" }, + { "id": "D", "text": "Oral labetalol and outpatient follow-up" }, + { "id": "E", "text": "Warfarin anticoagulation" } + ], + "correct_choice_id": "C", + "explanation": "Eclampsia is preeclampsia with seizures. Magnesium sulfate is first-line seizure treatment and prevention. Severe hypertension should also be controlled, and delivery is indicated after maternal stabilization.", + "choice_explanations": { + "A": "Benzodiazepines are not first-line for eclamptic seizures unless seizures persist despite magnesium.", + "B": "Delivery is definitive, but maternal stabilization comes first.", + "C": "Correct. Magnesium sulfate is first-line therapy for eclampsia.", + "D": "This patient has eclampsia and severe-range blood pressure requiring inpatient emergency treatment.", + "E": "Warfarin has no role and is generally avoided in pregnancy." + }, + "discipline": "Obstetrics/Gynecology", + "system": "Pregnancy", + "difficulty": 1, + "labs": [], + "media": [], + "tags": ["eclampsia", "hypertension", "pregnancy"] + }, + { + "id": "sample-003", + "stem": "A 52-year-old man is seen for follow-up after a hemicolectomy for stage III colon cancer. He is starting adjuvant chemotherapy. He asks what symptom should make him call the office urgently while receiving fluorouracil-based therapy.", + "lead_in": "Which of the following adverse effects is most important to report immediately?", + "choices": [ + { "id": "A", "text": "Mild hair thinning" }, + { "id": "B", "text": "Metallic taste during infusion" }, + { "id": "C", "text": "New chest pain or shortness of breath" }, + { "id": "D", "text": "Transient darkening of the nails" }, + { "id": "E", "text": "Warmth at the infusion site lasting 5 minutes" } + ], + "correct_choice_id": "C", + "explanation": "Fluoropyrimidines can cause coronary vasospasm and cardiotoxicity. New chest pain or shortness of breath during treatment warrants urgent evaluation.", + "choice_explanations": { + "A": "Mild alopecia can occur but is not typically urgent.", + "B": "Taste changes can occur with chemotherapy and are usually not emergent.", + "C": "Correct. Chest pain or dyspnea can indicate fluoropyrimidine cardiotoxicity.", + "D": "Nail changes may occur with chemotherapy but are not the highest-risk symptom listed.", + "E": "Brief warmth can be benign, though persistent pain or swelling should be reported." + }, + "discipline": "Medicine", + "system": "Oncology", + "difficulty": 3, + "labs": [], + "media": [], + "tags": ["colon cancer", "chemotherapy", "toxicity"] + }, + { + "id": "sample-004", + "stem": "A 44-year-old woman with opioid use disorder is admitted with fever and pleuritic chest pain. Temperature is 39.1 C, pulse is 118/min, and blood pressure is 104/66 mm Hg. CT chest shows multiple peripheral cavitary nodules. Blood cultures grow methicillin-sensitive Staphylococcus aureus.", + "lead_in": "Which of the following is the most likely cardiac finding?", + "choices": [ + { "id": "A", "text": "Aortic valve vegetation with splenic infarcts" }, + { "id": "B", "text": "Mitral stenosis with opening snap" }, + { "id": "C", "text": "Tricuspid valve vegetation with septic pulmonary emboli" }, + { "id": "D", "text": "Ventricular septal rupture after myocardial infarction" }, + { "id": "E", "text": "Viral myocarditis with global hypokinesis" } + ], + "correct_choice_id": "C", + "explanation": "Injection drug use is associated with right-sided infective endocarditis, especially tricuspid valve infection. Septic emboli travel to the lungs and can cause peripheral cavitary nodules.", + "choice_explanations": { + "A": "Left-sided endocarditis more often causes systemic emboli such as splenic or renal infarcts.", + "B": "Mitral stenosis does not explain bacteremia and septic pulmonary emboli.", + "C": "Correct. Tricuspid endocarditis causes septic pulmonary emboli.", + "D": "Ventricular septal rupture causes acute heart failure and a harsh murmur after MI.", + "E": "Myocarditis does not typically cause cavitary septic pulmonary emboli." + }, + "discipline": "Medicine", + "system": "Infectious Disease", + "difficulty": 2, + "labs": [], + "media": [], + "tags": ["endocarditis", "injection drug use", "staphylococcus aureus"] + }, + { + "id": "sample-005", + "stem": "A 7-year-old boy is brought to the clinic because of repetitive blinking and throat clearing for 8 months. His teacher reports that the behaviors increase during tests and decrease when he is focused on drawing. He is otherwise healthy and doing well in school.", + "lead_in": "Which of the following is the most appropriate initial management?", + "choices": [ + { "id": "A", "text": "Begin haloperidol immediately" }, + { "id": "B", "text": "Cognitive behavioral intervention for tics and reassurance" }, + { "id": "C", "text": "Electroencephalography" }, + { "id": "D", "text": "Emergency psychiatric hospitalization" }, + { "id": "E", "text": "High-dose corticosteroids" } + ], + "correct_choice_id": "B", + "explanation": "Persistent motor and vocal tics can be managed initially with education, reassurance, and behavioral therapy when symptoms are not severely impairing. Medication is reserved for more functionally impairing cases.", + "choice_explanations": { + "A": "Antipsychotics can reduce severe tics but are not first-line for mild symptoms.", + "B": "Correct. Behavioral therapy and reassurance are appropriate initial management.", + "C": "The history is typical for tics, not seizures.", + "D": "There is no indication of acute danger requiring hospitalization.", + "E": "Steroids are not indicated." + }, + "discipline": "Pediatrics", + "system": "Psychiatry/Behavioral Health", + "difficulty": 1, + "labs": [], + "media": [], + "tags": ["tic disorder", "behavioral therapy", "pediatrics"] + }, + { + "id": "sample-006", + "stem": "A 61-year-old man with cirrhosis is admitted for confusion. He has asterixis and a distended abdomen. Diagnostic paracentesis shows 620 neutrophils/mm3. Culture results are pending. He has no abdominal rigidity.", + "lead_in": "Which of the following is the most appropriate next step in management?", + "choices": [ + { "id": "A", "text": "Cefotaxime and albumin" }, + { "id": "B", "text": "Colonoscopy" }, + { "id": "C", "text": "Emergent laparotomy" }, + { "id": "D", "text": "High-dose prednisone" }, + { "id": "E", "text": "Therapeutic anticoagulation" } + ], + "correct_choice_id": "A", + "explanation": "Spontaneous bacterial peritonitis is diagnosed when ascitic neutrophils are at least 250/mm3. Treatment includes a third-generation cephalosporin and albumin in selected high-risk patients to reduce renal failure and mortality.", + "choice_explanations": { + "A": "Correct. This patient has spontaneous bacterial peritonitis.", + "B": "Colonoscopy is not the acute treatment.", + "C": "Surgery is considered for secondary peritonitis, especially with peritoneal signs or polymicrobial infection.", + "D": "Steroids are not indicated for SBP.", + "E": "Anticoagulation is not the immediate treatment for infected ascites." + }, + "discipline": "Medicine", + "system": "Gastroenterology", + "difficulty": 2, + "labs": [ + { "name": "Ascitic fluid neutrophils", "value": "620", "unit": "/mm3", "reference": "<250" } + ], + "media": [], + "tags": ["cirrhosis", "ascites", "SBP"] + } +] diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..b39d32e --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpvu3y6muov2o" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..f58e73a --- /dev/null +++ b/project.godot @@ -0,0 +1,41 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="step3" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.6", "Forward Plus") +config/icon="res://icon.svg" + +[autoload] + +AuthState="*res://scripts/autoload/auth_state.gd" +QuestionBankService="*res://scripts/autoload/question_bank_service.gd" +ProgressionService="*res://scripts/autoload/progression_service.gd" +ProfileService="*res://scripts/autoload/profile_service.gd" +BlockSessionService="*res://scripts/autoload/block_session_service.gd" + +[display] + +window/size/viewport_width=1920 +window/size/viewport_height=1080 + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" + +[step3] + +questions_api_url="http://localhost:5008/api/questions/search" diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..13a89d0 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3 uid="uid://step3main"] + +[ext_resource type="Script" path="res://scripts/ui/main.gd" id="1_main"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_main") diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd new file mode 100644 index 0000000..3c34f7b --- /dev/null +++ b/scripts/autoload/auth_state.gd @@ -0,0 +1,29 @@ +extends Node + +signal auth_changed + +var is_logged_in := false +var username := "" +var role := "" +var access_token := "" +var refresh_token := "" +var expires_at := "" + +func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "") -> void: + is_logged_in = not token.is_empty() + username = new_username + access_token = token + refresh_token = new_refresh_token + role = new_role + expires_at = token_expiration + auth_changed.emit() + + +func clear_session() -> void: + is_logged_in = false + username = "" + role = "" + access_token = "" + refresh_token = "" + expires_at = "" + auth_changed.emit() diff --git a/scripts/autoload/auth_state.gd.uid b/scripts/autoload/auth_state.gd.uid new file mode 100644 index 0000000..075f9db --- /dev/null +++ b/scripts/autoload/auth_state.gd.uid @@ -0,0 +1 @@ +uid://cf03tijcjcp1j diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd new file mode 100644 index 0000000..247e57d --- /dev/null +++ b/scripts/autoload/block_session_service.gd @@ -0,0 +1,104 @@ +extends Node + +signal session_changed + +var active_questions: Array = [] +var answers: Array = [] +var current_index := 0 +var mode := "Tutor" +var system_name := "All Systems" +var seconds_remaining := 0.0 +var block_started := false + +func start_block(selected_system: String, block_size: int, selected_mode: String) -> void: + system_name = selected_system + mode = selected_mode + active_questions = QuestionBankService.build_block(selected_system, block_size) + answers.clear() + answers.resize(active_questions.size()) + current_index = 0 + seconds_remaining = float(active_questions.size() * 90) + block_started = true + session_changed.emit() + + +func get_current_question() -> Dictionary: + if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): + return {} + return active_questions[current_index] + + +func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: + var question := get_current_question() + if question.is_empty(): + return {} + + var answer := { + "question": question.duplicate(true), + "selected_choice_id": choice_id, + "is_correct": choice_id == str(question.get("correct_choice_id", "")), + "eliminated_choice_ids": eliminated.duplicate(), + "flagged": flagged + } + answers[current_index] = answer + session_changed.emit() + return answer + + +func move_previous() -> bool: + if current_index > 0: + current_index -= 1 + session_changed.emit() + return true + return false + + +func move_next() -> bool: + if current_index + 1 < active_questions.size(): + current_index += 1 + session_changed.emit() + return true + return false + + +func move_to(index: int) -> bool: + if index >= 0 and index < active_questions.size(): + current_index = index + session_changed.emit() + return true + return false + + +func get_progress_text() -> String: + return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()] + + +func finish_block() -> Dictionary: + var final_answers: Array = [] + for index in range(active_questions.size()): + if answers[index] == null: + final_answers.append({ + "question": active_questions[index].duplicate(true), + "selected_choice_id": "", + "is_correct": false, + "eliminated_choice_ids": [], + "flagged": false + }) + else: + final_answers.append(answers[index]) + + var results := ProgressionService.calculate_block_rewards(final_answers, mode, system_name) + ProfileService.apply_block_results(results) + block_started = false + session_changed.emit() + return results + + +func has_answered_current() -> bool: + return get_answer_for_index(current_index) != null + + +func get_answer_for_index(index: int) -> Variant: + if index < 0 or index >= answers.size(): + return null + return answers[index] diff --git a/scripts/autoload/block_session_service.gd.uid b/scripts/autoload/block_session_service.gd.uid new file mode 100644 index 0000000..1038b62 --- /dev/null +++ b/scripts/autoload/block_session_service.gd.uid @@ -0,0 +1 @@ +uid://dgbbs67a8wdl4 diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd new file mode 100644 index 0000000..b2e7450 --- /dev/null +++ b/scripts/autoload/profile_service.gd @@ -0,0 +1,105 @@ +extends Node + +signal profile_changed + +const SAVE_PATH := "user://profile.json" + +var profile: Dictionary = { + "xp": 0, + "level": 1, + "current_streak": 0, + "best_streak": 0, + "badges": [], + "completed_question_ids": [], + "system_stats": {}, + "block_history": [] +} + +func _ready() -> void: + load_profile() + + +func load_profile() -> void: + if not FileAccess.file_exists(SAVE_PATH): + save_profile() + return + + var file := FileAccess.open(SAVE_PATH, FileAccess.READ) + if file == null: + push_error("Unable to open profile save.") + return + + var parsed = JSON.parse_string(file.get_as_text()) + if typeof(parsed) == TYPE_DICTIONARY: + for key in profile.keys(): + if parsed.has(key): + profile[key] = parsed[key] + profile_changed.emit() + + +func save_profile() -> void: + var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if file == null: + push_error("Unable to write profile save.") + return + file.store_string(JSON.stringify(profile, "\t")) + + +func get_profile() -> Dictionary: + return profile.duplicate(true) + + +func apply_block_results(results: Dictionary) -> void: + profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0)) + profile["level"] = ProgressionService.level_for_xp(int(profile["xp"])) + profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) + profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"])) + + var unlocked_badges: Array = results.get("badges_unlocked", []) + for badge in unlocked_badges: + if not profile["badges"].has(badge): + profile["badges"].append(badge) + + var answers: Array = results.get("answers", []) + for answer in answers: + var question: Dictionary = answer.get("question", {}) + var question_id := str(question.get("id", "")) + if question_id != "" and not profile["completed_question_ids"].has(question_id): + profile["completed_question_ids"].append(question_id) + + var system_name := str(question.get("system", "General")) + if not profile["system_stats"].has(system_name): + profile["system_stats"][system_name] = {"seen": 0, "correct": 0} + profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1 + if bool(answer.get("is_correct", false)): + profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1 + + var summary: Dictionary = { + "timestamp": Time.get_datetime_string_from_system(), + "mode": results.get("mode", "Tutor"), + "system": results.get("system", "All Systems"), + "correct": results.get("correct", 0), + "total": results.get("total", 0), + "xp_earned": results.get("xp_earned", 0) + } + profile["block_history"].push_front(summary) + if profile["block_history"].size() > 12: + profile["block_history"].resize(12) + + save_profile() + profile_changed.emit() + + +func reset_profile() -> void: + profile = { + "xp": 0, + "level": 1, + "current_streak": 0, + "best_streak": 0, + "badges": [], + "completed_question_ids": [], + "system_stats": {}, + "block_history": [] + } + save_profile() + profile_changed.emit() diff --git a/scripts/autoload/profile_service.gd.uid b/scripts/autoload/profile_service.gd.uid new file mode 100644 index 0000000..14585b5 --- /dev/null +++ b/scripts/autoload/profile_service.gd.uid @@ -0,0 +1 @@ +uid://epx6yqvy5nf6 diff --git a/scripts/autoload/progression_service.gd b/scripts/autoload/progression_service.gd new file mode 100644 index 0000000..8a14bfb --- /dev/null +++ b/scripts/autoload/progression_service.gd @@ -0,0 +1,67 @@ +extends Node + +const XP_PER_LEVEL := 500 + +var badge_catalog := { + "First Block": "Complete your first question block.", + "Hot Streak": "Answer 5 questions correctly in a row.", + "Tenacious": "Complete 10 total questions.", + "Sharp Shooter": "Score at least 80% on a block.", + "Clock Ready": "Complete a timed block." +} + +func level_for_xp(xp: int) -> int: + return int(floor(float(xp) / float(XP_PER_LEVEL))) + 1 + + +func xp_into_level(xp: int) -> int: + return xp % XP_PER_LEVEL + + +func calculate_block_rewards(answers: Array, mode: String, system_name: String) -> Dictionary: + var correct := 0 + var longest_streak := 0 + var running_streak := int(ProfileService.get_profile().get("current_streak", 0)) + var ending_streak := running_streak + + for answer in answers: + if bool(answer.get("is_correct", false)): + correct += 1 + ending_streak += 1 + longest_streak = max(longest_streak, ending_streak) + else: + ending_streak = 0 + + var total: int = max(answers.size(), 1) + var accuracy := float(correct) / float(total) + var xp := answers.size() * 25 + correct * 35 + longest_streak * 10 + if accuracy >= 0.8: + xp += 100 + if mode == "Timed": + xp += 50 + + var profile: Dictionary = ProfileService.get_profile() + var badges: Array[String] = [] + _add_badge_if_needed(badges, profile, "First Block", true) + _add_badge_if_needed(badges, profile, "Hot Streak", longest_streak >= 5) + _add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + answers.size() >= 10) + _add_badge_if_needed(badges, profile, "Sharp Shooter", accuracy >= 0.8) + _add_badge_if_needed(badges, profile, "Clock Ready", mode == "Timed") + + return { + "mode": mode, + "system": system_name, + "correct": correct, + "total": answers.size(), + "accuracy": accuracy, + "xp_earned": xp, + "ending_streak": ending_streak, + "longest_streak": longest_streak, + "badges_unlocked": badges, + "answers": answers + } + + +func _add_badge_if_needed(badges: Array[String], profile: Dictionary, badge_name: String, condition: bool) -> void: + if condition and not profile.get("badges", []).has(badge_name): + badges.append(badge_name) diff --git a/scripts/autoload/progression_service.gd.uid b/scripts/autoload/progression_service.gd.uid new file mode 100644 index 0000000..e666a88 --- /dev/null +++ b/scripts/autoload/progression_service.gd.uid @@ -0,0 +1 @@ +uid://d2w2nbhsf6uv6 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd new file mode 100644 index 0000000..4d6876f --- /dev/null +++ b/scripts/autoload/question_bank_service.gd @@ -0,0 +1,142 @@ +extends Node + +const QUESTION_FILE := "res://data/questions/sample_questions.json" +const DEFAULT_QUESTIONS_API_URL := "http://localhost:5008/api/questions/search" + +signal questions_changed + +var questions: Array = [] +var request: HTTPRequest +var questions_api_url := DEFAULT_QUESTIONS_API_URL + +func _ready() -> void: + questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) + request = HTTPRequest.new() + request.request_completed.connect(_on_api_questions_loaded) + add_child(request) + load_questions() + + +func load_questions() -> void: + questions.clear() + var file := FileAccess.open(QUESTION_FILE, FileAccess.READ) + if file == null: + push_error("Unable to open question file: %s" % QUESTION_FILE) + return + + var parsed = JSON.parse_string(file.get_as_text()) + if typeof(parsed) != TYPE_ARRAY: + push_error("Question file must contain an array.") + return + + for item in parsed: + if _is_valid_question(item): + questions.append(item) + else: + push_warning("Skipping invalid question: %s" % str(item.get("id", "unknown"))) + + questions_changed.emit() + + +func load_questions_from_api() -> void: + if AuthState.access_token.is_empty(): + return + + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + var payload := { + "limit": 80 + } + var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if err != OK: + push_warning("Failed to request questions API. Using local question bank.") + + +func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Questions API request failed. Using local question bank.") + return + + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_ARRAY: + push_warning("Questions API response was not an array. Using local question bank.") + return + + var api_questions: Array = [] + for item in parsed: + var normalized := _normalize_api_question(item) + if _is_valid_question(normalized): + api_questions.append(normalized) + + if api_questions.is_empty(): + push_warning("Questions API returned no usable questions. Using local question bank.") + return + + questions = api_questions + questions_changed.emit() + + +func get_all_questions() -> Array: + return questions.duplicate(true) + + +func get_systems() -> Array[String]: + var system_names: Array[String] = [] + for question in questions: + var system_name := str(question.get("system", "General")) + if not system_names.has(system_name): + system_names.append(system_name) + system_names.sort() + return system_names + + +func get_questions_for_system(system_name: String) -> Array: + if system_name == "All Systems": + return get_all_questions() + + var filtered: Array = [] + for question in questions: + if str(question.get("system", "")) == system_name: + filtered.append(question.duplicate(true)) + return filtered + + +func build_block(system_name: String, block_size: int) -> Array: + var pool := get_questions_for_system(system_name) + pool.shuffle() + return pool.slice(0, min(block_size, pool.size())) + + +func _is_valid_question(item: Variant) -> bool: + if typeof(item) != TYPE_DICTIONARY: + return false + var required := ["id", "stem", "lead_in", "choices", "correct_choice_id", "explanation", "system", "discipline"] + for key in required: + if not item.has(key): + return false + return typeof(item["choices"]) == TYPE_ARRAY and item["choices"].size() >= 2 + + +func _normalize_api_question(item: Variant) -> Dictionary: + if typeof(item) != TYPE_DICTIONARY: + return {} + + var normalized := { + "id": str(item.get("externalId", item.get("id", ""))), + "api_id": str(item.get("id", "")), + "stem": str(item.get("stem", "")), + "lead_in": str(item.get("leadIn", item.get("lead_in", ""))), + "choices": item.get("choices", []), + "correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))), + "explanation": str(item.get("explanation", "")), + "choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})), + "discipline": str(item.get("discipline", "")), + "system": str(item.get("system", "")), + "difficulty": int(item.get("difficulty", 1)), + "labs": item.get("labs", []), + "media": item.get("media", []), + "tags": item.get("tags", []) + } + return normalized diff --git a/scripts/autoload/question_bank_service.gd.uid b/scripts/autoload/question_bank_service.gd.uid new file mode 100644 index 0000000..724ec67 --- /dev/null +++ b/scripts/autoload/question_bank_service.gd.uid @@ -0,0 +1 @@ +uid://bb16xajj1m36q diff --git a/scripts/ui/block_setup.gd b/scripts/ui/block_setup.gd new file mode 100644 index 0000000..41e8098 --- /dev/null +++ b/scripts/ui/block_setup.gd @@ -0,0 +1,70 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var selected_system := "All Systems" +var system_label: Label +var size_spin: SpinBox +var mode_option: OptionButton + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 16) + + var title := Label.new() + title.text = "Block Setup" + Style.set_label(title, 34, Style.TEXT) + add_child(title) + + system_label = Label.new() + Style.set_label(system_label, 22, Style.CYAN) + add_child(system_label) + + var size_row := HBoxContainer.new() + size_row.add_child(Label.new()) + size_row.get_child(0).text = "Question count" + size_spin = SpinBox.new() + size_spin.min_value = 1 + size_spin.max_value = 40 + size_spin.value = 6 + size_spin.custom_minimum_size = Vector2(110, 44) + size_row.add_child(size_spin) + add_child(size_row) + + var mode_row := HBoxContainer.new() + var mode_label := Label.new() + mode_label.text = "Mode" + mode_row.add_child(mode_label) + mode_option = OptionButton.new() + mode_option.add_item("Tutor") + mode_option.add_item("Timed") + mode_option.custom_minimum_size = Vector2(180, 44) + mode_row.add_child(mode_option) + add_child(mode_row) + + var note := Label.new() + note.text = "Tutor mode shows explanations after each answer. Timed mode scores the block at the end." + note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(note, 16, Style.MUTED) + add_child(note) + + var start := Button.new() + start.text = "Launch Block" + start.custom_minimum_size = Vector2(220, 54) + Style.style_primary_button(start) + start.pressed.connect(_start_block) + add_child(start) + + +func on_show(payload: Variant = null) -> void: + selected_system = str(payload) if payload != null else "All Systems" + system_label.text = "Zone: %s" % selected_system + var available := QuestionBankService.get_questions_for_system(selected_system).size() + size_spin.max_value = max(1, available) + size_spin.value = min(size_spin.value, available) + + +func _start_block() -> void: + BlockSessionService.start_block(selected_system, int(size_spin.value), mode_option.get_item_text(mode_option.selected)) + main.show_screen("player") diff --git a/scripts/ui/block_setup.gd.uid b/scripts/ui/block_setup.gd.uid new file mode 100644 index 0000000..9c631c8 --- /dev/null +++ b/scripts/ui/block_setup.gd.uid @@ -0,0 +1 @@ +uid://ckykfr70d812h diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd new file mode 100644 index 0000000..cd0b83d --- /dev/null +++ b/scripts/ui/login_screen.gd @@ -0,0 +1,163 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") +const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login" + +var main: Control +var username_input: LineEdit +var password_input: LineEdit +var login_button: Button +var error_label: Label +var login_request: HTTPRequest + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 18) + + var spacer_top := Control.new() + spacer_top.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(spacer_top) + + var center := CenterContainer.new() + center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(520, 430) + panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("2bdbff"))) + center.add_child(panel) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 14) + panel.add_child(content) + + var title := Label.new() + title.text = "STEP 3 Arcade" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(title, 36, Style.TEXT) + content.add_child(title) + + var subtitle := Label.new() + subtitle.text = "Sign in with your Promiscuity account to start studying." + subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(subtitle, 16, Style.MUTED) + content.add_child(subtitle) + + var username_label := Label.new() + username_label.text = "Username" + Style.set_label(username_label, 15, Style.CYAN) + content.add_child(username_label) + + username_input = LineEdit.new() + username_input.placeholder_text = "enter username" + username_input.custom_minimum_size = Vector2(0, 44) + username_input.text_submitted.connect(_on_input_submitted) + content.add_child(username_input) + + var password_label := Label.new() + password_label.text = "Password" + Style.set_label(password_label, 15, Style.CYAN) + content.add_child(password_label) + + password_input = LineEdit.new() + password_input.placeholder_text = "enter password" + password_input.secret = true + password_input.custom_minimum_size = Vector2(0, 44) + password_input.text_submitted.connect(_on_input_submitted) + content.add_child(password_input) + + login_button = Button.new() + login_button.text = "Log In" + login_button.custom_minimum_size = Vector2(0, 50) + Style.style_primary_button(login_button) + login_button.pressed.connect(_on_login_pressed) + content.add_child(login_button) + + error_label = Label.new() + error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(error_label, 15, Style.RED) + content.add_child(error_label) + + var spacer_bottom := Control.new() + spacer_bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(spacer_bottom) + + login_request = HTTPRequest.new() + login_request.name = "LoginRequest" + login_request.request_completed.connect(_on_login_request_completed) + add_child(login_request) + + +func on_show(_payload: Variant = null) -> void: + error_label.text = "" + login_button.disabled = false + login_button.text = "Log In" + username_input.grab_focus() + + +func _on_login_pressed() -> void: + var username := username_input.text.strip_edges() + var password := password_input.text + if username.is_empty() or password.is_empty(): + _show_error("Username and password required.") + return + + login_button.disabled = true + login_button.text = "Signing in..." + error_label.text = "" + + var payload := { + "username": username, + "password": password, + } + var headers := PackedStringArray(["Content-Type: application/json"]) + var err := login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if err != OK: + login_button.disabled = false + login_button.text = "Log In" + _show_error("Failed to send login request.") + + +func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + login_button.disabled = false + login_button.text = "Log In" + + if result != HTTPRequest.RESULT_SUCCESS: + _show_error("Network error. Please try again.") + return + + if response_code < 200 or response_code >= 300: + _show_error("Login failed. Check your credentials.") + return + + var response: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(response) != TYPE_DICTIONARY: + _show_error("Login response was not valid JSON.") + return + + var token := String(response.get("accessToken", "")) + var username := String(response.get("username", username_input.text.strip_edges())) + if token.is_empty(): + _show_error("Login response did not include an access token.") + return + + AuthState.set_session( + username, + token, + String(response.get("refreshToken", "")), + String(response.get("role", "")), + String(response.get("exp", "")) + ) + QuestionBankService.load_questions_from_api() + password_input.text = "" + main.show_screen("menu") + + +func _on_input_submitted(_new_text: String) -> void: + _on_login_pressed() + + +func _show_error(message: String) -> void: + error_label.text = message diff --git a/scripts/ui/login_screen.gd.uid b/scripts/ui/login_screen.gd.uid new file mode 100644 index 0000000..368087f --- /dev/null +++ b/scripts/ui/login_screen.gd.uid @@ -0,0 +1 @@ +uid://cfw3y7g7kn4xj diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd new file mode 100644 index 0000000..cb6c2c7 --- /dev/null +++ b/scripts/ui/main.gd @@ -0,0 +1,157 @@ +extends Control + +const Style := preload("res://scripts/ui/ui_style.gd") + +var screens := {} +var header_stats: Label +var header_panel: PanelContainer +var user_label: Label + +func _ready() -> void: + Style.apply_app_theme(self) + _build_ui() + show_screen("menu" if AuthState.is_logged_in else "login") + + +func show_screen(screen_name: String, payload: Variant = null) -> void: + for child in $Root/Screens.get_children(): + child.visible = false + + var screen = screens.get(screen_name) + if screen == null: + return + + screen.visible = true + if header_panel != null: + header_panel.visible = screen_name != "login" + if screen.has_method("on_show"): + screen.on_show(payload) + + +func _build_ui() -> void: + var background := ColorRect.new() + background.color = Style.BACKGROUND + background.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(background) + + var root := VBoxContainer.new() + root.name = "Root" + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.add_theme_constant_override("separation", 0) + add_child(root) + + header_panel = PanelContainer.new() + header_panel.name = "Header" + header_panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Color("263752"))) + root.add_child(header_panel) + + var header := HBoxContainer.new() + header.custom_minimum_size = Vector2(0, 78) + header.add_theme_constant_override("separation", 14) + header_panel.add_child(header) + + var title := Label.new() + title.text = "STEP 3 Arcade" + Style.set_label(title, 31, Style.TEXT) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(title) + + header_stats = Label.new() + header_stats.name = "HeaderStats" + Style.set_label(header_stats, 15, Style.MUTED) + header_stats.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_stats.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + header.add_child(header_stats) + + user_label = Label.new() + Style.set_label(user_label, 15, Style.CYAN) + user_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + header.add_child(user_label) + + var profile_button := Button.new() + profile_button.text = "Profile" + profile_button.pressed.connect(func(): show_screen("profile")) + header.add_child(profile_button) + + var map_button := Button.new() + map_button.text = "Study Map" + map_button.pressed.connect(func(): show_screen("map")) + header.add_child(map_button) + + var logout_button := Button.new() + logout_button.text = "Log Out" + logout_button.pressed.connect(_logout) + header.add_child(logout_button) + + var screens_container := MarginContainer.new() + screens_container.name = "Screens" + screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + screens_container.add_theme_constant_override("margin_left", 24) + screens_container.add_theme_constant_override("margin_right", 24) + screens_container.add_theme_constant_override("margin_top", 18) + screens_container.add_theme_constant_override("margin_bottom", 22) + root.add_child(screens_container) + + var menu := preload("res://scripts/ui/main_menu.gd").new() + menu.name = "MainMenu" + menu.main = self + screens_container.add_child(menu) + screens["menu"] = menu + + var login := preload("res://scripts/ui/login_screen.gd").new() + login.name = "LoginScreen" + login.main = self + screens_container.add_child(login) + screens["login"] = login + + var map := preload("res://scripts/ui/study_map.gd").new() + map.name = "StudyMap" + map.main = self + screens_container.add_child(map) + screens["map"] = map + + var setup := preload("res://scripts/ui/block_setup.gd").new() + setup.name = "BlockSetup" + setup.main = self + screens_container.add_child(setup) + screens["setup"] = setup + + var player := preload("res://scripts/ui/question_player.gd").new() + player.name = "QuestionPlayer" + player.main = self + screens_container.add_child(player) + screens["player"] = player + + var review := preload("res://scripts/ui/review_results.gd").new() + review.name = "ReviewResults" + review.main = self + screens_container.add_child(review) + screens["review"] = review + + var profile := preload("res://scripts/ui/progress_profile.gd").new() + profile.name = "ProgressProfile" + profile.main = self + screens_container.add_child(profile) + screens["profile"] = profile + + ProfileService.profile_changed.connect(_refresh_header_stats) + AuthState.auth_changed.connect(_refresh_header_stats) + _refresh_header_stats() + + +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), + profile.get("current_streak", 0) + ] + if user_label != null: + user_label.text = AuthState.username if AuthState.is_logged_in else "" + + +func _logout() -> void: + AuthState.clear_session() + show_screen("login") diff --git a/scripts/ui/main.gd.uid b/scripts/ui/main.gd.uid new file mode 100644 index 0000000..730c8c2 --- /dev/null +++ b/scripts/ui/main.gd.uid @@ -0,0 +1 @@ +uid://ctijx6n0pe0x diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd new file mode 100644 index 0000000..4616be6 --- /dev/null +++ b/scripts/ui/main_menu.gd @@ -0,0 +1,80 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var stats_label: Label + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 18) + + var headline := Label.new() + headline.text = "Answer board-style questions. Earn XP. Unlock the map." + headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(headline, 38, Style.TEXT) + add_child(headline) + + var subhead := Label.new() + subhead.text = "A desktop study game for STEP 3-style single-best-answer blocks with tutor and timed modes." + subhead.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(subhead, 18, Style.MUTED) + add_child(subhead) + + var hero := PanelContainer.new() + hero.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("2bdbff"))) + hero.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(hero) + + var hero_content := VBoxContainer.new() + hero_content.add_theme_constant_override("separation", 16) + hero.add_child(hero_content) + + var loop := Label.new() + loop.text = "XP Rush | Streak Heat | System Zones | Timed Blocks" + Style.set_label(loop, 24, Style.CYAN) + hero_content.add_child(loop) + + var details := Label.new() + details.text = "Pick a clinical zone, clear a block, review every miss, and turn weak systems into unlock progress." + details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(details, 18, Style.TEXT) + hero_content.add_child(details) + + var button_row := HBoxContainer.new() + button_row.add_theme_constant_override("separation", 12) + hero_content.add_child(button_row) + + var start := Button.new() + start.text = "Start Block" + start.custom_minimum_size = Vector2(180, 52) + Style.style_primary_button(start) + start.pressed.connect(func(): main.show_screen("setup", "All Systems")) + button_row.add_child(start) + + var map := Button.new() + map.text = "Open Study Map" + map.custom_minimum_size = Vector2(180, 52) + map.pressed.connect(func(): main.show_screen("map")) + button_row.add_child(map) + + var profile := Button.new() + profile.text = "View Profile" + profile.custom_minimum_size = Vector2(180, 52) + profile.pressed.connect(func(): main.show_screen("profile")) + button_row.add_child(profile) + + stats_label = Label.new() + stats_label.name = "Stats" + Style.set_label(stats_label, 20, Style.LIME) + hero_content.add_child(stats_label) + + +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"], + profile["current_streak"], + profile["badges"].size() + ] diff --git a/scripts/ui/main_menu.gd.uid b/scripts/ui/main_menu.gd.uid new file mode 100644 index 0000000..3d7d623 --- /dev/null +++ b/scripts/ui/main_menu.gd.uid @@ -0,0 +1 @@ +uid://bqy7hcss1sd2p diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd new file mode 100644 index 0000000..0dc1248 --- /dev/null +++ b/scripts/ui/progress_profile.gd @@ -0,0 +1,81 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var profile_text: RichTextLabel + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 14) + + var title := Label.new() + title.text = "Progress Profile" + Style.set_label(title, 34, Style.CYAN) + add_child(title) + + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(panel) + + profile_text = RichTextLabel.new() + profile_text.bbcode_enabled = true + profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_child(profile_text) + + var row := HBoxContainer.new() + var back := Button.new() + back.text = "Back to Menu" + back.pressed.connect(func(): main.show_screen("menu")) + row.add_child(back) + + var reset := Button.new() + reset.text = "Reset Progress" + Style.style_chip(reset, Style.RED) + reset.pressed.connect(func(): + ProfileService.reset_profile() + on_show() + ) + row.add_child(reset) + add_child(row) + + +func on_show(_payload: Variant = null) -> void: + 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\n\n[b]Badges[/b]\n" % [ + profile["level"], + profile["xp"], + xp_into, + ProgressionService.XP_PER_LEVEL, + profile["current_streak"], + profile["best_streak"] + ] + if profile["badges"].is_empty(): + text += "No badges yet.\n" + else: + for badge in profile["badges"]: + text += "- %s\n" % badge + + text += "\n[b]System Accuracy[/b]\n" + if profile["system_stats"].is_empty(): + text += "No completed questions yet.\n" + else: + for system_name in profile["system_stats"].keys(): + var stats: Dictionary = profile["system_stats"][system_name] + var seen := int(stats.get("seen", 0)) + var correct := int(stats.get("correct", 0)) + var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0)) + text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy] + + text += "\n[b]Recent Blocks[/b]\n" + for block in profile["block_history"]: + text += "- %s: %s %d/%d, +%d XP\n" % [ + block.get("timestamp", ""), + block.get("system", ""), + block.get("correct", 0), + block.get("total", 0), + block.get("xp_earned", 0) + ] + profile_text.text = text diff --git a/scripts/ui/progress_profile.gd.uid b/scripts/ui/progress_profile.gd.uid new file mode 100644 index 0000000..cf86cc6 --- /dev/null +++ b/scripts/ui/progress_profile.gd.uid @@ -0,0 +1 @@ +uid://dwhuknyk0bh7l diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd new file mode 100644 index 0000000..9cf866c --- /dev/null +++ b/scripts/ui/question_player.gd @@ -0,0 +1,453 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var choice_buttons: Array[Button] = [] +var eliminated: Array = [] +var flagged := false +var selected_choice := "" +var current_answer := {} +var question_highlights := {} +var stem_plain_text := "" +var highlight_mode_enabled := false +var highlight_drag_started_in_stem := false + +var progress_label: Label +var timer_label: Label +var stem_label: RichTextLabel +var lead_label: Label +var explanation_label: RichTextLabel +var next_button: Button +var prev_button: Button +var flag_button: Button +var nav_palette: HBoxContainer +var question_content: VBoxContainer + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 10) + + var top := HBoxContainer.new() + top.add_theme_constant_override("separation", 12) + progress_label = Label.new() + Style.set_label(progress_label, 18, Style.CYAN) + progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(progress_label) + timer_label = Label.new() + Style.set_label(timer_label, 18, Style.AMBER) + top.add_child(timer_label) + flag_button = Button.new() + flag_button.text = "Flag" + flag_button.toggle_mode = true + Style.style_chip(flag_button, Style.AMBER) + flag_button.toggled.connect(func(value: bool): flagged = value) + top.add_child(flag_button) + add_child(top) + + var highlight_row := HBoxContainer.new() + highlight_row.add_theme_constant_override("separation", 8) + var highlight_button := Button.new() + highlight_button.text = "Highlight Mode" + highlight_button.toggle_mode = true + highlight_button.toggled.connect(func(enabled: bool): + highlight_mode_enabled = enabled + highlight_drag_started_in_stem = false + ) + Style.style_chip(highlight_button, Style.AMBER) + highlight_row.add_child(highlight_button) + var clear_highlight_button := Button.new() + clear_highlight_button.text = "Clear Highlights" + clear_highlight_button.pressed.connect(_clear_current_highlights) + highlight_row.add_child(clear_highlight_button) + add_child(highlight_row) + + nav_palette = HBoxContainer.new() + nav_palette.add_theme_constant_override("separation", 6) + add_child(nav_palette) + + var question_panel := PanelContainer.new() + question_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("182235"), Color("344463"))) + question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(question_panel) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + question_panel.add_child(scroll) + + question_content = VBoxContainer.new() + question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_content.add_theme_constant_override("separation", 12) + scroll.add_child(question_content) + + stem_label = RichTextLabel.new() + stem_label.fit_content = true + stem_label.bbcode_enabled = true + stem_label.selection_enabled = true + stem_label.scroll_active = false + stem_label.custom_minimum_size = Vector2(0, 96) + question_content.add_child(stem_label) + + lead_label = Label.new() + lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(lead_label, 21, Style.TEXT) + question_content.add_child(lead_label) + + for index in range(5): + var button := Button.new() + button.custom_minimum_size = Vector2(0, 52) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + Style.style_answer_button(button) + 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) + question_content.add_child(button) + + explanation_label = RichTextLabel.new() + explanation_label.bbcode_enabled = true + explanation_label.fit_content = true + explanation_label.scroll_active = false + explanation_label.custom_minimum_size = Vector2(0, 150) + explanation_label.visible = false + question_content.add_child(explanation_label) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 10) + prev_button = Button.new() + prev_button.text = "Previous" + prev_button.pressed.connect(func(): + if BlockSessionService.move_previous(): + _load_question() + ) + actions.add_child(prev_button) + + var eliminate := Button.new() + eliminate.text = "Eliminate Selected" + eliminate.pressed.connect(_toggle_eliminate_selected) + actions.add_child(eliminate) + + next_button = Button.new() + next_button.text = "Submit" + next_button.custom_minimum_size = Vector2(140, 48) + Style.style_primary_button(next_button) + next_button.pressed.connect(_submit_or_next) + actions.add_child(next_button) + add_child(actions) + + +func on_show(_payload: Variant = null) -> void: + _load_question() + + +func _process(delta: float) -> void: + if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed": + BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) + var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) + var minutes := int(floor(float(shown_seconds) / 60.0)) + timer_label.text = "Timed: %02d:%02d" % [minutes, shown_seconds % 60] + if BlockSessionService.seconds_remaining <= 0: + _finish() + + +func _input(event: InputEvent) -> void: + if not visible or not highlight_mode_enabled or stem_label == null: + return + + var mouse_event := event as InputEventMouseButton + if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT: + return + + if mouse_event.pressed: + highlight_drag_started_in_stem = stem_label.get_global_rect().has_point(mouse_event.global_position) + elif highlight_drag_started_in_stem: + highlight_drag_started_in_stem = false + call_deferred("_commit_current_text_selection") + + +func _load_question() -> void: + eliminated.clear() + flagged = false + selected_choice = "" + current_answer = {} + flag_button.button_pressed = false + explanation_label.visible = false + next_button.text = "Submit" + + var question := BlockSessionService.get_current_question() + progress_label.text = "Question %s" % BlockSessionService.get_progress_text() + timer_label.text = "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" + stem_plain_text = str(question.get("stem", "")) + _render_stem() + lead_label.text = question.get("lead_in", "") + + var choices: Array = question.get("choices", []) + for index in range(choice_buttons.size()): + var button := choice_buttons[index] + button.visible = index < choices.size() + button.disabled = false + if index < choices.size(): + var choice: Dictionary = choices[index] + _set_choice_button_text(index, choice) + button.modulate = Color.WHITE + + var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index) + if saved_answer != null: + current_answer = saved_answer + selected_choice = str(saved_answer.get("selected_choice_id", "")) + eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate() + flagged = bool(saved_answer.get("flagged", false)) + flag_button.button_pressed = flagged + if BlockSessionService.mode == "Tutor": + _show_explanation() + next_button.text = "Next" + else: + _apply_saved_choice_state() + next_button.text = "Next" + + prev_button.disabled = BlockSessionService.current_index == 0 + _refresh_nav_palette() + + +func _select_choice(index: int) -> void: + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("choices", []) + if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()): + return + selected_choice = str(choices[index].get("id", "")) + if BlockSessionService.mode == "Timed": + current_answer = {} + next_button.text = "Submit" + _apply_saved_choice_state() + + +func _choice_gui_input(event: InputEvent, index: int) -> void: + var mouse_event := event as InputEventMouseButton + if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: + return + if not current_answer.is_empty(): + return + + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("choices", []) + if index >= choices.size(): + return + + var choice: Dictionary = choices[index] + var choice_id := str(choice.get("id", "")) + if eliminated.has(choice_id): + eliminated.erase(choice_id) + else: + eliminated.append(choice_id) + _set_choice_button_text(index, choice) + _apply_saved_choice_state() + + +func _toggle_eliminate_selected() -> void: + if selected_choice == "" or not current_answer.is_empty(): + return + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("choices", []) + for index in range(choices.size()): + if str(choices[index].get("id", "")) == selected_choice: + if eliminated.has(selected_choice): + eliminated.erase(selected_choice) + else: + eliminated.append(selected_choice) + _set_choice_button_text(index, choices[index]) + _apply_saved_choice_state() + + +func _submit_or_next() -> void: + if current_answer.is_empty(): + if selected_choice == "": + return + current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) + if BlockSessionService.mode == "Tutor": + _show_explanation() + next_button.text = "Next" + else: + next_button.text = "Next" + _refresh_nav_palette() + else: + _advance_or_finish() + + +func _show_explanation() -> void: + var question := BlockSessionService.get_current_question() + var correct_id := str(question.get("correct_choice_id", "")) + for button in choice_buttons: + button.disabled = true + for index in range(question.get("choices", []).size()): + var choice: Dictionary = question["choices"][index] + var id := str(choice.get("id", "")) + if id == correct_id: + choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) + elif id == selected_choice: + choice_buttons[index].modulate = Color(1.0, 0.48, 0.48) + + 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 + _refresh_nav_palette() + + +func _advance_or_finish() -> void: + if BlockSessionService.move_next(): + _load_question() + else: + _finish() + + +func _finish() -> void: + var results := BlockSessionService.finish_block() + main.show_screen("review", results) + + +func _commit_current_text_selection() -> void: + if not highlight_mode_enabled or stem_label == null: + return + var selected_text := stem_label.get_selected_text().strip_edges() + if selected_text == "": + return + _highlight_text(selected_text) + + +func _highlight_text(selected_text: String) -> void: + if selected_text == "": + return + + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + if not question_highlights.has(question_id): + question_highlights[question_id] = [] + + var highlights: Array = question_highlights[question_id] + var start := _find_available_highlight_start(selected_text, highlights) + if start == -1: + start = stem_plain_text.find(selected_text) + if start == -1: + return + + var end := start + selected_text.length() + if _remove_overlapping_highlights(highlights, start, end): + _render_stem() + if stem_label.has_method("deselect"): + stem_label.deselect() + return + + highlights.append({"start": start, "end": end}) + _render_stem() + if stem_label.has_method("deselect"): + stem_label.deselect() + + +func _clear_current_highlights() -> void: + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + question_highlights[question_id] = [] + _render_stem() + + +func _render_stem() -> void: + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + var ranges: Array = question_highlights.get(question_id, []).duplicate() + ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a["start"]) < int(b["start"])) + + var rendered := "" + var cursor := 0 + for highlight_range in ranges: + var start: int = clamp(int(highlight_range.get("start", 0)), 0, stem_plain_text.length()) + var end: int = clamp(int(highlight_range.get("end", 0)), start, stem_plain_text.length()) + if start < cursor: + continue + rendered += _escape_bbcode(stem_plain_text.substr(cursor, start - cursor)) + rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(stem_plain_text.substr(start, end - start)) + cursor = end + rendered += _escape_bbcode(stem_plain_text.substr(cursor)) + stem_label.text = rendered + + +func _escape_bbcode(value: String) -> String: + return value.replace("[", "[lb]").replace("]", "[rb]") + + +func _find_available_highlight_start(selected_text: String, highlights: Array) -> int: + var search_from := 0 + while search_from < stem_plain_text.length(): + var start := stem_plain_text.find(selected_text, search_from) + if start == -1: + return -1 + var end := start + selected_text.length() + var overlaps := false + for highlight_range in highlights: + if start < int(highlight_range.get("end", 0)) and end > int(highlight_range.get("start", 0)): + overlaps = true + break + if not overlaps: + return start + search_from = start + 1 + return -1 + + +func _remove_overlapping_highlights(highlights: Array, start: int, end: int) -> bool: + var removed := false + for index in range(highlights.size() - 1, -1, -1): + var highlight_range: Dictionary = highlights[index] + if start < int(highlight_range.get("end", 0)) and end > int(highlight_range.get("start", 0)): + highlights.remove_at(index) + removed = true + return removed + + +func _apply_saved_choice_state() -> void: + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("choices", []) + for index in range(choices.size()): + var id := str(choices[index].get("id", "")) + _set_choice_button_text(index, choices[index]) + if eliminated.has(id): + choice_buttons[index].modulate = Color(0.45, 0.45, 0.45) + elif id == selected_choice: + choice_buttons[index].modulate = Color(0.68, 0.92, 1.0) + else: + choice_buttons[index].modulate = Color.WHITE + + +func _set_choice_button_text(index: int, choice: Dictionary) -> void: + var choice_id := str(choice.get("id", "")) + var choice_text := str(choice.get("text", "")) + if 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 _strike_text(value: String) -> String: + var result := "" + for character in value: + result += character + char(0x0336) + return result + + +func _refresh_nav_palette() -> void: + for child in nav_palette.get_children(): + child.queue_free() + + for index in range(BlockSessionService.active_questions.size()): + var button := Button.new() + button.text = str(index + 1) + button.custom_minimum_size = Vector2(38, 34) + var answer = BlockSessionService.get_answer_for_index(index) + if index == BlockSessionService.current_index: + Style.style_chip(button, Style.CYAN) + elif answer != null and bool(answer.get("flagged", false)): + Style.style_chip(button, Style.AMBER) + elif answer != null: + Style.style_chip(button, Style.LIME) + else: + Style.style_chip(button, Color("596982")) + button.pressed.connect(func(target_index: int = index): + if BlockSessionService.mode == "Timed": + BlockSessionService.move_to(target_index) + _load_question() + ) + nav_palette.add_child(button) diff --git a/scripts/ui/question_player.gd.uid b/scripts/ui/question_player.gd.uid new file mode 100644 index 0000000..9a6260e --- /dev/null +++ b/scripts/ui/question_player.gd.uid @@ -0,0 +1 @@ +uid://cfonfdpxr62qf diff --git a/scripts/ui/review_results.gd b/scripts/ui/review_results.gd new file mode 100644 index 0000000..e59d381 --- /dev/null +++ b/scripts/ui/review_results.gd @@ -0,0 +1,77 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var summary_label: Label +var answer_list: VBoxContainer + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 14) + + var title := Label.new() + title.text = "Block Complete" + Style.set_label(title, 34, Style.LIME) + add_child(title) + + summary_label = Label.new() + summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(summary_label, 20, Style.TEXT) + add_child(summary_label) + + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(panel) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + answer_list = VBoxContainer.new() + answer_list.add_theme_constant_override("separation", 10) + scroll.add_child(answer_list) + panel.add_child(scroll) + + var row := HBoxContainer.new() + var map := Button.new() + map.text = "Back to Map" + map.pressed.connect(func(): main.show_screen("map")) + row.add_child(map) + var again := Button.new() + again.text = "New Block" + Style.style_primary_button(again) + again.pressed.connect(func(): main.show_screen("setup", "All Systems")) + row.add_child(again) + add_child(row) + + +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" % [ + 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" + ] + + for child in answer_list.get_children(): + child.queue_free() + + for answer in results.get("answers", []): + var question: Dictionary = answer.get("question", {}) + var item := RichTextLabel.new() + item.bbcode_enabled = true + item.fit_content = true + item.custom_minimum_size = Vector2(0, 90) + var icon := "[color=#a6ff5f]CLEAR[/color]" if answer.get("is_correct", false) else "[color=#ff5f6d]MISS[/color]" + item.text = "[b]%s[/b] %s\n[color=#aeb8ca]Selected: %s | Correct: %s[/color]\n%s" % [ + icon, + question.get("lead_in", ""), + answer.get("selected_choice_id", ""), + question.get("correct_choice_id", ""), + question.get("explanation", "") + ] + answer_list.add_child(item) diff --git a/scripts/ui/review_results.gd.uid b/scripts/ui/review_results.gd.uid new file mode 100644 index 0000000..7219548 --- /dev/null +++ b/scripts/ui/review_results.gd.uid @@ -0,0 +1 @@ +uid://dtouhe6v0wu34 diff --git a/scripts/ui/study_map.gd b/scripts/ui/study_map.gd new file mode 100644 index 0000000..d38d1df --- /dev/null +++ b/scripts/ui/study_map.gd @@ -0,0 +1,54 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var zone_list: GridContainer + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 14) + + var title := Label.new() + title.text = "Study Map" + Style.set_label(title, 34, Style.TEXT) + add_child(title) + + var help := Label.new() + help.text = "Choose a system zone. Strong scores unlock momentum badges and level progress." + help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(help, 16, Style.MUTED) + add_child(help) + + zone_list = GridContainer.new() + zone_list.columns = 3 + zone_list.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(zone_list) + + +func on_show(_payload: Variant = null) -> void: + for child in zone_list.get_children(): + child.queue_free() + + var systems: Array[String] = ["All Systems"] + systems.append_array(QuestionBankService.get_systems()) + var stats: Dictionary = ProfileService.get_profile().get("system_stats", {}) + + for system_name in systems: + var button := Button.new() + button.custom_minimum_size = Vector2(260, 110) + button.text = _zone_text(system_name, stats) + Style.style_chip(button, Style.CYAN if system_name == "All Systems" else Style.MAGENTA) + button.pressed.connect(func(zone_name: String = system_name): main.show_screen("setup", zone_name)) + zone_list.add_child(button) + + +func _zone_text(system_name: String, stats: Dictionary) -> String: + if system_name == "All Systems" or not stats.has(system_name): + return "%s\nStart block" % system_name + var seen := int(stats[system_name].get("seen", 0)) + var correct := int(stats[system_name].get("correct", 0)) + var accuracy := 0 + if seen > 0: + accuracy = int(round(float(correct) / float(seen) * 100.0)) + return "%s\n%d seen | %d%%" % [system_name, seen, accuracy] diff --git a/scripts/ui/study_map.gd.uid b/scripts/ui/study_map.gd.uid new file mode 100644 index 0000000..5ba5d2f --- /dev/null +++ b/scripts/ui/study_map.gd.uid @@ -0,0 +1 @@ +uid://b15rio8jane4y diff --git a/scripts/ui/ui_style.gd b/scripts/ui/ui_style.gd new file mode 100644 index 0000000..a6a9d32 --- /dev/null +++ b/scripts/ui/ui_style.gd @@ -0,0 +1,84 @@ +extends RefCounted + +const BACKGROUND := Color("121827") +const SURFACE := Color("1d2638") +const SURFACE_DARK := Color("151c2b") +const SURFACE_LIGHT := Color("27334a") +const TEXT := Color("f6f8ff") +const MUTED := Color("aeb8ca") +const CYAN := Color("4de1ff") +const LIME := Color("a6ff5f") +const MAGENTA := Color("ff4fd8") +const AMBER := Color("ffc857") +const RED := Color("ff5f6d") + +static func apply_app_theme(root: Control) -> void: + var theme := Theme.new() + theme.set_color("font_color", "Label", TEXT) + theme.set_color("font_color", "Button", TEXT) + theme.set_color("font_color", "RichTextLabel", TEXT) + theme.set_font_size("font_size", "Label", 17) + theme.set_font_size("font_size", "Button", 16) + theme.set_stylebox("normal", "Button", button_box(SURFACE_LIGHT, Color("3b4964"))) + theme.set_stylebox("hover", "Button", button_box(Color("33425d"), CYAN)) + theme.set_stylebox("pressed", "Button", button_box(Color("21344a"), LIME)) + theme.set_stylebox("disabled", "Button", button_box(Color("202637"), Color("2c3445"))) + theme.set_stylebox("focus", "Button", StyleBoxEmpty.new()) + root.theme = theme + + +static func panel_box(color: Color = SURFACE, border: Color = Color("33415c")) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = color + box.border_color = border + box.set_border_width_all(1) + box.corner_radius_top_left = 8 + box.corner_radius_top_right = 8 + box.corner_radius_bottom_left = 8 + box.corner_radius_bottom_right = 8 + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 16 + box.content_margin_bottom = 16 + return box + + +static func button_box(color: Color, border: Color) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = color + box.border_color = border + box.set_border_width_all(1) + box.corner_radius_top_left = 6 + box.corner_radius_top_right = 6 + box.corner_radius_bottom_left = 6 + box.corner_radius_bottom_right = 6 + box.content_margin_left = 14 + box.content_margin_right = 14 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +static func set_label(label: Label, font_size: int, color: Color = TEXT) -> void: + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", color) + + +static func style_primary_button(button: Button) -> void: + button.add_theme_stylebox_override("normal", button_box(Color("18465a"), CYAN)) + button.add_theme_stylebox_override("hover", button_box(Color("20647e"), Color("8df1ff"))) + button.add_theme_stylebox_override("pressed", button_box(Color("153b4d"), LIME)) + button.add_theme_color_override("font_color", TEXT) + + +static func style_answer_button(button: Button) -> void: + button.add_theme_stylebox_override("normal", button_box(SURFACE_DARK, Color("303c55"))) + button.add_theme_stylebox_override("hover", button_box(Color("26354f"), CYAN)) + button.add_theme_stylebox_override("pressed", button_box(Color("20384d"), CYAN)) + button.add_theme_color_override("font_color", TEXT) + + +static func style_chip(button: Button, color: Color) -> void: + button.add_theme_stylebox_override("normal", button_box(Color(color, 0.22), color)) + button.add_theme_stylebox_override("hover", button_box(Color(color, 0.34), color)) + button.add_theme_color_override("font_color", TEXT) diff --git a/scripts/ui/ui_style.gd.uid b/scripts/ui/ui_style.gd.uid new file mode 100644 index 0000000..1dd07ec --- /dev/null +++ b/scripts/ui/ui_style.gd.uid @@ -0,0 +1 @@ +uid://c0t63ls30huo1 diff --git a/step3.sln b/step3.sln new file mode 100644 index 0000000..4550b08 --- /dev/null +++ b/step3.sln @@ -0,0 +1,39 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api", "api", "{D82C56D6-B2CD-F611-9E31-04638F7EC30E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi", "api\QuestionsApi\QuestionsApi.csproj", "{0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|x64.Build.0 = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|x86.ActiveCfg = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Debug|x86.Build.0 = Debug|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|Any CPU.Build.0 = Release|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|x64.ActiveCfg = Release|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|x64.Build.0 = Release|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|x86.ActiveCfg = Release|Any CPU + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E} + EndGlobalSection +EndGlobal