Initial STEP 3 study game and questions API
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -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
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
/android/
|
||||
|
||||
# .NET build output
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Local editor/user files
|
||||
*.user
|
||||
*.suo
|
||||
@@ -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/<owner>/<repo>/questions-api:latest
|
||||
```
|
||||
@@ -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<IActionResult> Systems()
|
||||
{
|
||||
var systems = await _questions.GetSystemsAsync(User.IsInRole("SUPER"));
|
||||
return Ok(systems);
|
||||
}
|
||||
|
||||
[HttpPost("search")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public class BulkUpsertQuestionsRequest
|
||||
{
|
||||
public List<UpsertQuestionRequest> Questions { get; set; } = [];
|
||||
}
|
||||
@@ -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<QuestionChoice> Choices { get; set; } = [];
|
||||
public string CorrectChoiceId { get; set; } = "";
|
||||
public string Explanation { get; set; } = "";
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public int Difficulty { get; set; } = 1;
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> 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; } = "";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public class QuestionSearchRequest
|
||||
{
|
||||
public string? System { get; set; }
|
||||
public string? Discipline { get; set; }
|
||||
public List<string> 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<QuestionChoice> Choices { get; set; } = [];
|
||||
public string CorrectChoiceId { get; set; } = "";
|
||||
public string Explanation { get; set; } = "";
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public int Difficulty { get; set; } = 1;
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool IsPublished { get; set; } = true;
|
||||
}
|
||||
@@ -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<QuestionChoice> Choices { get; set; } = [];
|
||||
public string CorrectChoiceId { get; set; } = "";
|
||||
public string Explanation { get; set; } = "";
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public int Difficulty { get; set; }
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> 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
|
||||
};
|
||||
}
|
||||
@@ -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<QuestionStore>();
|
||||
|
||||
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<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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<IExceptionHandlerFeature>();
|
||||
var exception = feature?.Error;
|
||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().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();
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,144 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public class QuestionStore
|
||||
{
|
||||
private readonly IMongoCollection<Question> _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<Question>("Questions");
|
||||
|
||||
var externalIdIndex = Builders<Question>.IndexKeys.Ascending(q => q.ExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(externalIdIndex, new CreateIndexOptions { Unique = true }));
|
||||
var systemIndex = Builders<Question>.IndexKeys.Ascending(q => q.System).Ascending(q => q.IsPublished);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex));
|
||||
var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(tagsIndex));
|
||||
}
|
||||
|
||||
public async Task<List<Question>> SearchAsync(QuestionSearchRequest req, bool includeUnpublished)
|
||||
{
|
||||
var builder = Builders<Question>.Filter;
|
||||
var filters = new List<FilterDefinition<Question>>();
|
||||
|
||||
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<Question?> GetByIdAsync(string id) =>
|
||||
await _col.Find(q => q.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Question?> 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<Question?> 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<int> UpsertManyByExternalIdAsync(IEnumerable<UpsertQuestionRequest> requests)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var req in requests)
|
||||
{
|
||||
var question = FromRequest(req);
|
||||
question.UpdatedUtc = DateTime.UtcNow;
|
||||
|
||||
var filter = Builders<Question>.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<bool> DeleteAsync(string id)
|
||||
{
|
||||
var result = await _col.DeleteOneAsync(q => q.Id == id);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetSystemsAsync(bool includeUnpublished)
|
||||
{
|
||||
var filter = includeUnpublished
|
||||
? Builders<Question>.Filter.Empty
|
||||
: Builders<Question>.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
|
||||
};
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cf03tijcjcp1j
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgbbs67a8wdl4
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://epx6yqvy5nf6
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2w2nbhsf6uv6
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb16xajj1m36q
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckykfr70d812h
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfw3y7g7kn4xj
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctijx6n0pe0x
|
||||
@@ -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()
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqy7hcss1sd2p
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwhuknyk0bh7l
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfonfdpxr62qf
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtouhe6v0wu34
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://b15rio8jane4y
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0t63ls30huo1
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user