diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..646165f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# Build outputs
+bin/
+obj/
+out/
+artifacts/
+
+# IDE
+.vs/
+.vscode/
+node_modules/
+
+# User secrets / configs
+appsettings.Development.json
+scripts/deploy-ftp.profile.psd1
+*.user
+*.suo
+
+# Logs
+*.log
+
+# Test results / coverage artifacts
+TestResults/
+coverage.cobertura.xml
+
+# SQLite data
+App_Data/
+*.db
+
+# OS cruft
+Thumbs.db
+Desktop.ini
+Properties/launchSettings.json
diff --git a/FAQ.md b/FAQ.md
new file mode 100644
index 0000000..14cf09d
--- /dev/null
+++ b/FAQ.md
@@ -0,0 +1,11 @@
+# FAQ
+
+## Why does this starter use custom frontend lint/format scripts instead of heavy npm dependencies?
+
+The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
+
+- API client generation from the OpenAPI contract
+- basic frontend syntax/contract checks
+- deterministic formatting checks used by `scripts/ci-local.ps1`
+
+This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1cf043d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# RpgRoller
+
+Fresh full-stack starter scaffold:
+
+- `RpgRoller/`: ASP.NET Core backend + static frontend (`wwwroot`)
+- `RpgRoller.Tests/`: xUnit integration-heavy test project
+- `RpgRoller.sln`: solution used by local CI script
+
+## Prerequisites
+
+- .NET SDK 10.0+
+- Node.js 22+ and npm
+- PowerShell 7+
+
+## Local Development
+
+1. Run the local CI parity script:
+ ```powershell
+ pwsh ./scripts/ci-local.ps1
+ ```
+2. Start the backend:
+ ```powershell
+ dotnet run --project RpgRoller/RpgRoller.csproj
+ ```
+3. Open `http://localhost:5000` (or the port shown in the console).
+
+## Frontend Tooling
+
+- OpenAPI contract: `openapi/RpgRoller.json`
+- API client generation: `npm run generate:api-client`
+- Frontend lint checks: `npm run lint`
+- Frontend format checks: `npm run format:check`
+
+## Test and Coverage
+
+- Tests:
+ ```powershell
+ dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
+ ```
+- Coverage gate:
+ ```powershell
+ pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
+ ```
diff --git a/RpgRoller.Tests/RpgRoller.Tests.csproj b/RpgRoller.Tests/RpgRoller.Tests.csproj
new file mode 100644
index 0000000..9ad4433
--- /dev/null
+++ b/RpgRoller.Tests/RpgRoller.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs
new file mode 100644
index 0000000..e08cf5a
--- /dev/null
+++ b/RpgRoller.Tests/UnitTest1.cs
@@ -0,0 +1,89 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using RpgRoller.Contracts;
+using RpgRoller.Services;
+
+namespace RpgRoller.Tests;
+
+public sealed class UnitTest1 : IClassFixture>
+{
+ private readonly HttpClient m_Client;
+
+ public UnitTest1(WebApplicationFactory factory)
+ {
+ m_Client = factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(new FixedDiceRoller(7));
+ })).CreateClient();
+ }
+
+ [Fact]
+ public async Task GetHealth_ReturnsOkPayload()
+ {
+ var response = await m_Client.GetAsync("/api/health");
+ var payload = await response.Content.ReadFromJsonAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(payload);
+ Assert.Equal("ok", payload.Status);
+ }
+
+ [Theory]
+ [InlineData(1, "Dice must have at least 2 sides.")]
+ [InlineData(1001, "Dice must have at most 1000 sides.")]
+ public async Task Roll_WithInvalidSides_ReturnsBadRequest(int sides, string expectedError)
+ {
+ var response = await m_Client.GetAsync($"/api/roll/{sides}");
+ var payload = await response.Content.ReadFromJsonAsync();
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.NotNull(payload);
+ Assert.Equal(expectedError, payload.Error);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(1000)]
+ public async Task Roll_WithValidSides_ReturnsExpectedResult(int sides)
+ {
+ var response = await m_Client.GetAsync($"/api/roll/{sides}");
+ var payload = await response.Content.ReadFromJsonAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(payload);
+ Assert.Equal(sides, payload.Sides);
+ Assert.Equal(Math.Min(7, sides), payload.Value);
+ }
+
+ [Fact]
+ public void RandomDiceRoller_ProducesValueWithinRange()
+ {
+ var roller = new RandomDiceRoller();
+
+ for (var i = 0; i < 200; i += 1)
+ {
+ var value = roller.Roll(6);
+ Assert.InRange(value, 1, 6);
+ }
+ }
+
+ private sealed class FixedDiceRoller : IDiceRoller
+ {
+ private readonly int m_Result;
+
+ public FixedDiceRoller(int result)
+ {
+ m_Result = result;
+ }
+
+ public int Roll(int sides)
+ {
+ return Math.Min(m_Result, sides);
+ }
+ }
+}
diff --git a/RpgRoller.Tests/coverlet.runsettings b/RpgRoller.Tests/coverlet.runsettings
new file mode 100644
index 0000000..21564f5
--- /dev/null
+++ b/RpgRoller.Tests/coverlet.runsettings
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ cobertura
+ GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
+
+
+
+
+
diff --git a/RpgRoller.sln b/RpgRoller.sln
new file mode 100644
index 0000000..ea6d7e9
--- /dev/null
+++ b/RpgRoller.sln
@@ -0,0 +1,48 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RpgRoller", "RpgRoller\RpgRoller.csproj", "{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RpgRoller.Tests", "RpgRoller.Tests\RpgRoller.Tests.csproj", "{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}"
+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
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x64.Build.0 = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x86.Build.0 = Debug|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|Any CPU.Build.0 = Release|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x64.ActiveCfg = Release|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x64.Build.0 = Release|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x86.ActiveCfg = Release|Any CPU
+ {67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x86.Build.0 = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x64.Build.0 = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x86.Build.0 = Debug|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x64.ActiveCfg = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x64.Build.0 = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x86.ActiveCfg = Release|Any CPU
+ {D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/RpgRoller.slnx b/RpgRoller.slnx
new file mode 100644
index 0000000..ba788ff
--- /dev/null
+++ b/RpgRoller.slnx
@@ -0,0 +1,2 @@
+
+
diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs
new file mode 100644
index 0000000..f883b71
--- /dev/null
+++ b/RpgRoller/Contracts/ApiContracts.cs
@@ -0,0 +1,5 @@
+namespace RpgRoller.Contracts;
+
+public sealed record HealthResponse(string Status);
+public sealed record RollResponse(int Sides, int Value);
+public sealed record ApiError(string Error);
diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs
new file mode 100644
index 0000000..0b6d1f2
--- /dev/null
+++ b/RpgRoller/Program.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Http.HttpResults;
+using RpgRoller.Contracts;
+using RpgRoller.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+
+app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok")));
+
+app.MapGet(
+ "/api/roll/{sides:int}",
+ Results, BadRequest> (int sides, IDiceRoller diceRoller) =>
+ {
+ var validationError = DiceRules.ValidateSides(sides);
+ if (validationError is not null)
+ {
+ return TypedResults.BadRequest(new ApiError(validationError));
+ }
+
+ var value = diceRoller.Roll(sides);
+ return TypedResults.Ok(new RollResponse(sides, value));
+ });
+
+app.Run();
+
+public partial class Program;
diff --git a/RpgRoller/Properties/launchSettings.json b/RpgRoller/Properties/launchSettings.json
new file mode 100644
index 0000000..aadb6f8
--- /dev/null
+++ b/RpgRoller/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5175",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7271;http://localhost:5175",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/RpgRoller/RpgRoller.csproj b/RpgRoller/RpgRoller.csproj
new file mode 100644
index 0000000..a3a34b6
--- /dev/null
+++ b/RpgRoller/RpgRoller.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/RpgRoller/Services/DiceRules.cs b/RpgRoller/Services/DiceRules.cs
new file mode 100644
index 0000000..fdb6812
--- /dev/null
+++ b/RpgRoller/Services/DiceRules.cs
@@ -0,0 +1,19 @@
+namespace RpgRoller.Services;
+
+public static class DiceRules
+{
+ public static string? ValidateSides(int sides)
+ {
+ if (sides < 2)
+ {
+ return "Dice must have at least 2 sides.";
+ }
+
+ if (sides > 1000)
+ {
+ return "Dice must have at most 1000 sides.";
+ }
+
+ return null;
+ }
+}
diff --git a/RpgRoller/Services/IDiceRoller.cs b/RpgRoller/Services/IDiceRoller.cs
new file mode 100644
index 0000000..9d9f298
--- /dev/null
+++ b/RpgRoller/Services/IDiceRoller.cs
@@ -0,0 +1,6 @@
+namespace RpgRoller.Services;
+
+public interface IDiceRoller
+{
+ int Roll(int sides);
+}
diff --git a/RpgRoller/Services/RandomDiceRoller.cs b/RpgRoller/Services/RandomDiceRoller.cs
new file mode 100644
index 0000000..e366047
--- /dev/null
+++ b/RpgRoller/Services/RandomDiceRoller.cs
@@ -0,0 +1,9 @@
+namespace RpgRoller.Services;
+
+public sealed class RandomDiceRoller : IDiceRoller
+{
+ public int Roll(int sides)
+ {
+ return Random.Shared.Next(1, sides + 1);
+ }
+}
diff --git a/RpgRoller/appsettings.json b/RpgRoller/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/RpgRoller/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js
new file mode 100644
index 0000000..cc4a76f
--- /dev/null
+++ b/RpgRoller/wwwroot/app.js
@@ -0,0 +1,31 @@
+import { getHealth, rollDice } from "./generated/api-client.js";
+
+const healthElement = document.getElementById("health");
+const resultElement = document.getElementById("result");
+const formElement = document.getElementById("roll-form");
+const sidesInput = document.getElementById("sides");
+
+async function refreshHealth() {
+ try {
+ const health = await getHealth();
+ healthElement.textContent = `API status: ${health.status}`;
+ }
+ catch (error) {
+ healthElement.textContent = `API status check failed: ${error.message}`;
+ }
+}
+
+formElement.addEventListener("submit", async (event) => {
+ event.preventDefault();
+
+ const sides = Number.parseInt(sidesInput.value, 10);
+ try {
+ const roll = await rollDice(sides);
+ resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
+ }
+ catch (error) {
+ resultElement.textContent = `Roll failed: ${error.message}`;
+ }
+});
+
+await refreshHealth();
diff --git a/RpgRoller/wwwroot/generated/api-client.js b/RpgRoller/wwwroot/generated/api-client.js
new file mode 100644
index 0000000..69c6ebd
--- /dev/null
+++ b/RpgRoller/wwwroot/generated/api-client.js
@@ -0,0 +1,37 @@
+/* This file is generated by scripts/generate-api-client.mjs. */
+
+export { apiOperations };
+
+const apiOperations = {
+ getHealth: { method: "GET", path: "/api/health" },
+ rollDice: { method: "GET", path: "/api/roll/{sides}" }
+};
+
+async function send(operation, pathParams = {}) {
+ let resolvedPath = operation.path;
+ for (const [key, value] of Object.entries(pathParams)) {
+ resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
+ }
+
+ const response = await fetch(resolvedPath, {
+ method: operation.method,
+ headers: {
+ "Accept": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
+ throw new Error(errorPayload.error ?? `Request failed with status ${response.status}`);
+ }
+
+ return response.json();
+}
+
+export async function getHealth() {
+ return send(apiOperations.getHealth, {});
+}
+
+export async function rollDice(sides) {
+ return send(apiOperations.rollDice, { sides: sides });
+}
diff --git a/RpgRoller/wwwroot/index.html b/RpgRoller/wwwroot/index.html
new file mode 100644
index 0000000..dc6dccb
--- /dev/null
+++ b/RpgRoller/wwwroot/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ RpgRoller
+
+
+
+
+ RpgRoller
+ Checking API status...
+
+
+
+ No roll yet.
+
+
+
+
+
diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css
new file mode 100644
index 0000000..709db32
--- /dev/null
+++ b/RpgRoller/wwwroot/styles.css
@@ -0,0 +1,42 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ background: linear-gradient(165deg, #f2f4f8 0%, #e6ebf5 100%);
+ color: #1f2937;
+}
+
+.layout {
+ max-width: 32rem;
+ margin: 0 auto;
+ padding: 2.5rem 1.25rem;
+}
+
+.panel {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+input,
+button {
+ font: inherit;
+ padding: 0.6rem 0.75rem;
+}
+
+button {
+ border: 0;
+ border-radius: 0.4rem;
+ background: #2563eb;
+ color: #ffffff;
+ cursor: pointer;
+}
+
+.status,
+.result {
+ font-weight: 600;
+}
diff --git a/TECH.md b/TECH.md
index 3a25986..b132741 100644
--- a/TECH.md
+++ b/TECH.md
@@ -1,5 +1,14 @@
# TECH - Kickoff Blueprint
+## 0) Current scaffold status
+
+- Root solution: `RpgRoller.sln`
+- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
+- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
+- OpenAPI source: `openapi/RpgRoller.json`
+- Generated client target: `RpgRoller/wwwroot/generated/api-client.js`
+- Local CI parity entrypoint: `scripts/ci-local.ps1`
+
## 1) Stack and baseline choices
- ASP.NET Core Minimal API on .NET 10.
@@ -170,4 +179,3 @@ Avoid:
- Unbounded in-memory caches.
- Synchronous external network checks on hot write paths.
- Manual API contract duplication between docs/frontend/backend.
-
diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json
new file mode 100644
index 0000000..74d078b
--- /dev/null
+++ b/openapi/RpgRoller.json
@@ -0,0 +1,109 @@
+{
+ "openapi": "3.0.1",
+ "info": {
+ "title": "RpgRoller API",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/api/health": {
+ "get": {
+ "operationId": "getHealth",
+ "responses": {
+ "200": {
+ "description": "API is reachable.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/roll/{sides}": {
+ "get": {
+ "operationId": "rollDice",
+ "parameters": [
+ {
+ "name": "sides",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 2,
+ "maximum": 1000
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Roll succeeded.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RollResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation error.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "HealthResponse": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "status"
+ ]
+ },
+ "RollResponse": {
+ "type": "object",
+ "properties": {
+ "sides": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "value": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "required": [
+ "sides",
+ "value"
+ ]
+ },
+ "ApiError": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "error"
+ ]
+ }
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1efbcaf
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,12 @@
+{
+ "name": "rpgroller",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rpgroller",
+ "version": "0.1.0"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..572eef5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "rpgroller",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "generate:api-client": "node ./scripts/generate-api-client.mjs",
+ "lint": "node ./scripts/lint-frontend.mjs",
+ "format:check": "node ./scripts/format-check.mjs"
+ }
+}
diff --git a/scripts/format-check.mjs b/scripts/format-check.mjs
new file mode 100644
index 0000000..66e0601
--- /dev/null
+++ b/scripts/format-check.mjs
@@ -0,0 +1,67 @@
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDirectory, "..");
+
+const directoriesToScan = [
+ path.join(repoRoot, "RpgRoller", "wwwroot"),
+ path.join(repoRoot, "openapi")
+];
+
+const filesToScan = [
+ path.join(repoRoot, "scripts", "generate-api-client.mjs"),
+ path.join(repoRoot, "scripts", "lint-frontend.mjs"),
+ path.join(repoRoot, "scripts", "format-check.mjs")
+];
+
+async function collectFiles(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const results = [];
+ for (const entry of entries) {
+ const fullPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ const children = await collectFiles(fullPath);
+ results.push(...children);
+ }
+ else {
+ results.push(fullPath);
+ }
+ }
+
+ return results;
+}
+
+const allFiles = [...filesToScan];
+for (const directory of directoriesToScan) {
+ const directoryFiles = await collectFiles(directory);
+ allFiles.push(...directoryFiles);
+}
+
+const failures = [];
+for (const filePath of allFiles) {
+ const text = await readFile(filePath, "utf8");
+ const relativePath = path.relative(repoRoot, filePath);
+ const lines = text.split(/\r?\n/);
+
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
+ if (/[ \t]+$/.test(lines[lineNumber])) {
+ failures.push(`${relativePath}:${lineNumber + 1} has trailing whitespace.`);
+ }
+ }
+
+ if (text.includes("\t")) {
+ failures.push(`${relativePath} contains tab characters.`);
+ }
+
+ if (text.length > 0 && !text.endsWith("\n") && !text.endsWith("\r\n")) {
+ failures.push(`${relativePath} is missing a trailing newline.`);
+ }
+}
+
+if (failures.length > 0) {
+ throw new Error(failures.join("\n"));
+}
+
+console.log("Frontend format checks passed.");
diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs
new file mode 100644
index 0000000..086abd0
--- /dev/null
+++ b/scripts/generate-api-client.mjs
@@ -0,0 +1,93 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDirectory, "..");
+const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
+const outputPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js");
+
+function escapePathSegment(segment) {
+ return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
+}
+
+function collectOperations(document) {
+ const operations = [];
+ for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
+ for (const [method, operation] of Object.entries(pathItem ?? {})) {
+ if (operation === null || typeof operation !== "object") {
+ continue;
+ }
+
+ if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
+ throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
+ }
+
+ operations.push({
+ operationId: operation.operationId,
+ method: method.toUpperCase(),
+ path: pathKey
+ });
+ }
+ }
+
+ if (operations.length === 0) {
+ throw new Error("OpenAPI document does not define any operations.");
+ }
+
+ return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
+}
+
+function buildClientSource(operations) {
+ const operationEntries = operations
+ .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
+ .join(",\n");
+
+ const helper = `const apiOperations = {\n${operationEntries}\n};\n`;
+
+ const sendFunction = `
+async function send(operation, pathParams = {}) {
+ let resolvedPath = operation.path;
+ for (const [key, value] of Object.entries(pathParams)) {
+ resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
+ }
+
+ const response = await fetch(resolvedPath, {
+ method: operation.method,
+ headers: {
+ "Accept": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
+ throw new Error(errorPayload.error ?? \`Request failed with status \${response.status}\`);
+ }
+
+ return response.json();
+}
+`.trim();
+
+ const exports = operations
+ .map((operation) => {
+ const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
+ const signature = params.length === 0 ? "" : params.join(", ");
+ const pathParams = params.length === 0
+ ? "{}"
+ : `{ ${params.map((name) => `${name}: ${name}`).join(", ")} }`;
+
+ return `export async function ${operation.operationId}(${signature}) {\n return send(apiOperations.${operation.operationId}, ${pathParams});\n}`;
+ })
+ .join("\n\n");
+
+ return `/* This file is generated by scripts/generate-api-client.mjs. */\n\nexport { apiOperations };\n\n${helper}\n${sendFunction}\n\n${exports}\n`;
+}
+
+const openApiText = await readFile(openApiPath, "utf8");
+const document = JSON.parse(openApiText);
+const operations = collectOperations(document);
+const clientSource = buildClientSource(operations);
+
+await mkdir(path.dirname(outputPath), { recursive: true });
+await writeFile(outputPath, clientSource, "utf8");
+console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);
diff --git a/scripts/lint-frontend.mjs b/scripts/lint-frontend.mjs
new file mode 100644
index 0000000..d4d0bb5
--- /dev/null
+++ b/scripts/lint-frontend.mjs
@@ -0,0 +1,42 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDirectory, "..");
+const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
+const appJsPath = path.join(repoRoot, "RpgRoller", "wwwroot", "app.js");
+const generatedClientPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js");
+
+const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
+const generatedClient = await readFile(generatedClientPath, "utf8");
+const errors = [];
+
+const appSyntaxCheck = spawnSync(process.execPath, ["--check", appJsPath], { encoding: "utf8" });
+if (appSyntaxCheck.status !== 0) {
+ errors.push(`Syntax error in ${path.relative(repoRoot, appJsPath)}:\n${appSyntaxCheck.stderr}`);
+}
+
+for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
+ for (const [method, operation] of Object.entries(pathItem ?? {})) {
+ if (operation === null || typeof operation !== "object") {
+ continue;
+ }
+
+ if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
+ errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
+ continue;
+ }
+
+ if (!generatedClient.includes(`apiOperations.${operation.operationId}`)) {
+ errors.push(`Generated client is missing operation export for ${operation.operationId}`);
+ }
+ }
+}
+
+if (errors.length > 0) {
+ throw new Error(errors.join("\n"));
+}
+
+console.log("Frontend lint checks passed.");