Remove legacy TypeScript frontend and npm pipeline

This commit is contained in:
2026-02-25 12:31:39 +01:00
parent 35c60c4ea2
commit 0f44cc466b
31 changed files with 20 additions and 2526 deletions

14
FAQ.md
View File

@@ -1,15 +1,9 @@
# FAQ
## Why does this starter use custom frontend lint/format scripts instead of heavy npm dependencies?
## Does this project still require npm/frontend TypeScript tooling?
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
- API client generation from the OpenAPI contract
- TypeScript compilation for frontend source files
- basic frontend 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.
No. The legacy TypeScript frontend pipeline was removed after the Blazor rewrite.
`scripts/ci-local.ps1` is now a .NET-only flow (restore, build, tests, coverage checks).
## Is frontend JavaScript handwritten?
@@ -21,7 +15,7 @@ There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-a
- SSE connection/reconnect handling
- per-tab session storage helpers used by the Blazor UI
The TypeScript frontend folders remain in the repo for tooling and generated API client contract checks used by CI.
There is no TypeScript runtime frontend in the current codebase.
## Where is backend state stored locally?

View File

@@ -6,6 +6,7 @@ Tracking against `UX.md` tasks and decisions.
- Branch: `feature/blazor-frontend-rebuild-ux`
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
- Legacy TypeScript frontend/runtime artifacts: removed
## UX Checklist

View File

@@ -3,7 +3,6 @@
Fresh full-stack starter scaffold:
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
- `RpgRoller/frontend/`: TypeScript frontend source
- `RpgRoller.Tests/`: xUnit integration-heavy test project
- `RpgRoller.sln`: solution used by local CI script
- `UX.md`: frontend UX and interaction design specification (pre-implementation baseline)
@@ -30,7 +29,6 @@ Frontend:
- `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
- `RpgRoller/frontend/generated/`: generated TypeScript API client source retained for contract/tooling parity
Backend state persistence:
@@ -43,7 +41,6 @@ Backend state persistence:
## Prerequisites
- .NET SDK 10.0+
- Node.js 22+ and npm
- PowerShell 7+
## Local Development
@@ -60,13 +57,11 @@ Backend state persistence:
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
## Frontend Tooling
## Frontend Runtime
- OpenAPI contract: `openapi/RpgRoller.json`
- TypeScript build output config: `tsconfig.frontend.json`
- API client generation + frontend compile: `npm run generate:api-client`
- Frontend lint checks: `npm run lint`
- Frontend format checks: `npm run format:check`
- Runtime frontend is Blazor Server with interactive components.
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage

View File

@@ -1,252 +0,0 @@
import {
activateCharacter,
createCampaign,
createCharacter,
createSkill,
loginUser,
logoutUser,
registerUser,
rollSkill,
updateSkill
} from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import {
ensureRulesets,
refreshHealth,
reloadCampaignLog,
reloadCampaigns,
reloadSelectedCampaign,
reloadSession
} from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const elements = getAppElements();
const state = createInitialState();
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await registerUser({
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await loginUser({
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
elements.loginPassword.value = "";
await reloadAll();
setStatus("Logged in.", false);
});
});
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setStatus("Active character updated.", false);
});
});
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setStatus("Ready.", false);
});
async function reloadAll(): Promise<void> {
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll(state, elements);
}
function syncEventStream(): void {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
}
async function runAppAction(action: () => Promise<void>): Promise<void> {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function setStatus(message: string, isError: boolean): void {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -1,24 +0,0 @@
export async function runAction(
action: () => Promise<void>,
onError: (message: string) => void
): Promise<void> {
try {
await action();
}
catch (error: unknown) {
onError(formatError(error));
}
}
export function setMessage(messageElement: HTMLElement, message: string, isError: boolean): void {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -1,112 +0,0 @@
export type AppElements = {
healthElement: HTMLElement;
messageElement: HTMLElement;
campaignMetaElement: HTMLElement;
campaignDetailsElement: HTMLElement;
rollResultElement: HTMLElement;
campaignLogElement: HTMLElement;
registerForm: HTMLFormElement;
loginForm: HTMLFormElement;
logoutButton: HTMLButtonElement;
registerUsername: HTMLInputElement;
registerDisplayName: HTMLInputElement;
registerPassword: HTMLInputElement;
loginUsername: HTMLInputElement;
loginPassword: HTMLInputElement;
campaignForm: HTMLFormElement;
campaignNameInput: HTMLInputElement;
campaignRulesetSelect: HTMLSelectElement;
campaignSelect: HTMLSelectElement;
refreshCampaignButton: HTMLButtonElement;
characterForm: HTMLFormElement;
characterNameInput: HTMLInputElement;
characterSelect: HTMLSelectElement;
activateCharacterButton: HTMLButtonElement;
skillForm: HTMLFormElement;
skillNameInput: HTMLInputElement;
skillExpressionInput: HTMLInputElement;
updateSkillButton: HTMLButtonElement;
skillSelect: HTMLSelectElement;
rollForm: HTMLFormElement;
rollVisibilitySelect: HTMLSelectElement;
};
export function getAppElements(): AppElements {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id: string): HTMLInputElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id: string): HTMLSelectElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id: string): HTMLFormElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id: string): HTMLButtonElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -1,30 +0,0 @@
import type { AppState } from "./types.js";
export function connectStateEvents(state: AppState, onStateChanged: () => void): void {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state: AppState): void {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -1,74 +0,0 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import type { AppElements } from "./dom.js";
import { syncSelectedCharacter } from "./state.js";
import type { AppState } from "./types.js";
export async function refreshHealth(elements: AppElements): Promise<void> {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state: AppState, elements: AppElements): Promise<void> {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state: AppState): Promise<void> {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state: AppState, preferredCampaignId: string | null): Promise<void> {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
export async function reloadSelectedCampaign(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -1,117 +0,0 @@
import type { AppElements } from "./dom.js";
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
import type { AppState } from "./types.js";
export function renderAll(state: AppState, elements: AppElements): void {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state: AppState, elements: AppElements): void {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
elements.campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
export function renderCharacterSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state: AppState, elements: AppElements): void {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
function renderCampaignSelect(state: AppState, elements: AppElements): void {
elements.campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}

View File

@@ -1,71 +0,0 @@
import type { SkillSummary } from "../generated/api-client.js";
import type { AppState } from "./types.js";
export function createInitialState(): AppState {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state: AppState): void {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state: AppState): void {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state: AppState): void {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state: AppState): string | null {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state: AppState, selectedSkillId: string): SkillSummary | null {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -1,19 +0,0 @@
import type {
CampaignDetails,
CampaignLogEntry,
CampaignSummary,
RulesetDefinition,
UserSummary
} from "../generated/api-client.js";
export type AppState = {
user: UserSummary | null;
activeCharacterId: string | null;
selectedCharacterId: string | null;
campaigns: CampaignSummary[];
selectedCampaignId: string | null;
selectedCampaign: CampaignDetails | null;
campaignLog: CampaignLogEntry[];
rulesets: RulesetDefinition[];
eventSource: EventSource | null;
};

View File

@@ -1,343 +0,0 @@
/* This file is generated by scripts/generate-api-client.mjs. */
export interface ApiError {
error: string;
}
export interface CampaignDetails {
id: string;
name: string;
rulesetId: string;
gm: UserSummary;
characters: Array<CharacterSummary>;
skills: Array<SkillSummary>;
}
export interface CampaignLogEntry {
rollId: string;
campaignId: string;
characterId: string;
skillId: string;
rollerUserId: string;
visibility: string;
result: number;
breakdown: string;
timestampUtc: string;
}
export interface CampaignSummary {
id: string;
name: string;
rulesetId: string;
gmUserId: string;
}
export interface CharacterSummary {
id: string;
name: string;
ownerUserId: string;
campaignId: string;
}
export interface CreateCampaignRequest {
name: string;
rulesetId: string;
}
export interface CreateCharacterRequest {
name: string;
campaignId: string;
}
export interface CreateSkillRequest {
name: string;
diceRollDefinition: string;
}
export interface HealthResponse {
status: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface MeResponse {
user: UserSummary;
activeCharacterId?: string;
currentCampaignId?: string;
}
export interface RegisterRequest {
username: string;
password: string;
displayName: string;
}
export interface RollResult {
rollId: string;
campaignId: string;
characterId: string;
skillId: string;
rollerUserId: string;
visibility: string;
result: number;
breakdown: string;
timestampUtc: string;
}
export interface RollSkillRequest {
visibility: string;
}
export interface RulesetDefinition {
id: string;
name: string;
diceSyntax: string;
}
export interface SkillSummary {
id: string;
characterId: string;
name: string;
diceRollDefinition: string;
}
export interface UpdateCharacterRequest {
name: string;
campaignId: string;
}
export interface UpdateSkillRequest {
name: string;
diceRollDefinition: string;
}
export interface UserSummary {
id: string;
username: string;
displayName: string;
}
type ApiOperation = {
method: string;
path: string;
expectsJson: boolean;
};
type RequestOptions = {
pathParams?: Record<string, string | number | boolean>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
export const apiOperations = {
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
getMe: { method: "GET", path: "/api/me", expectsJson: true },
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
} as const satisfies Record<string, ApiOperation>;
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
}
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers: Record<string, string> = {
"Accept": "application/json"
};
let body: string | undefined;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers,
body
});
if (!response.ok) {
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
throw new Error(`Request failed with status ${response.status}`);
}
if (!operation.expectsJson) {
return undefined as TResult;
}
return response.json() as Promise<TResult>;
}
export async function activateCharacter(characterId: string): Promise<boolean> {
return send<boolean>(apiOperations.activateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: undefined
});
}
export async function createCampaign(body: CreateCampaignRequest): Promise<CampaignSummary> {
return send<CampaignSummary>(apiOperations.createCampaign, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createCharacter(body: CreateCharacterRequest): Promise<CharacterSummary> {
return send<CharacterSummary>(apiOperations.createCharacter, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createSkill(characterId: string, body: CreateSkillRequest): Promise<SkillSummary> {
return send<SkillSummary>(apiOperations.createSkill, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function getCampaign(campaignId: string): Promise<CampaignDetails> {
return send<CampaignDetails>(apiOperations.getCampaign, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaignLog(campaignId: string): Promise<Array<CampaignLogEntry>> {
return send<Array<CampaignLogEntry>>(apiOperations.getCampaignLog, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaigns(): Promise<Array<CampaignSummary>> {
return send<Array<CampaignSummary>>(apiOperations.getCampaigns, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getCurrentCampaignCharacters(): Promise<Array<CharacterSummary>> {
return send<Array<CharacterSummary>>(apiOperations.getCurrentCampaignCharacters, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getHealth(): Promise<HealthResponse> {
return send<HealthResponse>(apiOperations.getHealth, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getMe(): Promise<MeResponse> {
return send<MeResponse>(apiOperations.getMe, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getRulesets(): Promise<Array<RulesetDefinition>> {
return send<Array<RulesetDefinition>>(apiOperations.getRulesets, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function loginUser(body: LoginRequest): Promise<UserSummary> {
return send<UserSummary>(apiOperations.loginUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function logoutUser(): Promise<void> {
return send<void>(apiOperations.logoutUser, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function registerUser(body: RegisterRequest): Promise<UserSummary> {
return send<UserSummary>(apiOperations.registerUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function rollSkill(skillId: string, body: RollSkillRequest): Promise<RollResult> {
return send<RollResult>(apiOperations.rollSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}
export async function updateCharacter(characterId: string, body: UpdateCharacterRequest): Promise<CharacterSummary> {
return send<CharacterSummary>(apiOperations.updateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function updateSkill(skillId: string, body: UpdateSkillRequest): Promise<SkillSummary> {
return send<SkillSummary>(apiOperations.updateSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}

View File

@@ -1,198 +0,0 @@
import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const elements = getAppElements();
const state = createInitialState();
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await registerUser({
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await loginUser({
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
elements.loginPassword.value = "";
await reloadAll();
setStatus("Logged in.", false);
});
});
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setStatus("Active character updated.", false);
});
});
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setStatus("Ready.", false);
});
async function reloadAll() {
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll(state, elements);
}
function syncEventStream() {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
}
async function runAppAction(action) {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function setStatus(message, isError) {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -1,18 +0,0 @@
export async function runAction(action, onError) {
try {
await action();
}
catch (error) {
onError(formatError(error));
}
}
export function setMessage(messageElement, message, isError) {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -1,69 +0,0 @@
export function getAppElements() {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -1,23 +0,0 @@
export function connectStateEvents(state, onStateChanged) {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state) {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -1,58 +0,0 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import { syncSelectedCharacter } from "./state.js";
export async function refreshHealth(elements) {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state, elements) {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state) {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state, preferredCampaignId) {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
export async function reloadSelectedCampaign(state) {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state) {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -1,98 +0,0 @@
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
export function renderAll(state, elements) {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state, elements) {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state, elements) {
if (!state.selectedCampaign) {
elements.campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
elements.campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
export function renderCharacterSelect(state, elements) {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state, elements) {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state, elements) {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
function renderCampaignSelect(state, elements) {
elements.campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}

View File

@@ -1,58 +0,0 @@
export function createInitialState() {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state) {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state) {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state) {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state) {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state, selectedSkillId) {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,181 +0,0 @@
/* This file is generated by scripts/generate-api-client.mjs. */
export const apiOperations = {
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
getMe: { method: "GET", path: "/api/me", expectsJson: true },
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
};
function withPathParams(pathTemplate, pathParams) {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue, query) {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
}
async function send(operation, options) {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers = {
"Accept": "application/json"
};
let body;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers,
body
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
throw new Error(`Request failed with status ${response.status}`);
}
if (!operation.expectsJson) {
return undefined;
}
return response.json();
}
export async function activateCharacter(characterId) {
return send(apiOperations.activateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: undefined
});
}
export async function createCampaign(body) {
return send(apiOperations.createCampaign, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createCharacter(body) {
return send(apiOperations.createCharacter, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createSkill(characterId, body) {
return send(apiOperations.createSkill, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function getCampaign(campaignId) {
return send(apiOperations.getCampaign, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaignLog(campaignId) {
return send(apiOperations.getCampaignLog, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaigns() {
return send(apiOperations.getCampaigns, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getCurrentCampaignCharacters() {
return send(apiOperations.getCurrentCampaignCharacters, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getHealth() {
return send(apiOperations.getHealth, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getMe() {
return send(apiOperations.getMe, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getRulesets() {
return send(apiOperations.getRulesets, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function loginUser(body) {
return send(apiOperations.loginUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function logoutUser() {
return send(apiOperations.logoutUser, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function registerUser(body) {
return send(apiOperations.registerUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function rollSkill(skillId, body) {
return send(apiOperations.rollSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}
export async function updateCharacter(characterId, body) {
return send(apiOperations.updateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function updateSkill(skillId, body) {
return send(apiOperations.updateSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}

View File

@@ -1,91 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RpgRoller</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="layout">
<h1>RpgRoller</h1>
<p id="health" class="status">Checking API status...</p>
<p id="message" class="message"></p>
<section class="panel">
<h2>Authentication</h2>
<form id="register-form" class="grid-form">
<input id="register-username" type="text" placeholder="Username" required>
<input id="register-display-name" type="text" placeholder="Display Name" required>
<input id="register-password" type="password" placeholder="Password (min 8 chars)" required>
<button type="submit">Register</button>
</form>
<form id="login-form" class="grid-form">
<input id="login-username" type="text" placeholder="Username" required>
<input id="login-password" type="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<button id="logout-button" type="button">Logout</button>
</section>
<section class="panel">
<h2>Campaigns</h2>
<form id="campaign-form" class="grid-form">
<input id="campaign-name" type="text" placeholder="Campaign name" required>
<select id="campaign-ruleset" required></select>
<button type="submit">Create Campaign</button>
</form>
<div class="inline-controls">
<select id="campaign-select"></select>
<button id="refresh-campaign-button" type="button">Refresh</button>
</div>
<p id="campaign-meta"></p>
</section>
<section class="panel">
<h2>Characters</h2>
<form id="character-form" class="grid-form">
<input id="character-name" type="text" placeholder="Character name" required>
<button type="submit">Create Character</button>
</form>
<div class="inline-controls">
<select id="character-select"></select>
<button id="activate-character-button" type="button">Activate Character</button>
</div>
</section>
<section class="panel">
<h2>Skills</h2>
<form id="skill-form" class="grid-form">
<input id="skill-name" type="text" placeholder="Skill name" required>
<input id="skill-expression" type="text" placeholder="Dice expression" required>
<div class="inline-controls">
<button id="create-skill-button" type="submit">Create Skill</button>
<button id="update-skill-button" type="button">Update Selected Skill</button>
</div>
</form>
<form id="roll-form" class="grid-form">
<select id="skill-select"></select>
<select id="roll-visibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit">Roll Skill</button>
</form>
<p id="roll-result" class="result">No roll yet.</p>
</section>
<section class="panel">
<h2>Campaign Details</h2>
<div id="campaign-details"></div>
</section>
<section class="panel">
<h2>Campaign Log</h2>
<ul id="campaign-log" class="log-list"></ul>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>

38
TECH.md
View File

@@ -3,20 +3,17 @@
## 0) Current scaffold status
- Root solution: `RpgRoller.sln`
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
- Frontend source: `RpgRoller/frontend` (TypeScript)
- Frontend module split: `RpgRoller/frontend/app/*` (dom/state/loaders/render/events/actions)
- Backend/full-stack project: `RpgRoller` (Minimal API + Blazor frontend host)
- Frontend source: `RpgRoller/Components/*` + `RpgRoller/wwwroot/*`
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
- Test file split: concern-based API tests (`RpgRoller.Tests/Api/*`), service tests (`RpgRoller.Tests/Services/*`), and shared helpers (`RpgRoller.Tests/Support/*`)
- Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService`
- OpenAPI source: `openapi/RpgRoller.json`
- Generated client source: `RpgRoller/frontend/generated/api-client.ts`
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
- Local CI parity entrypoint: `scripts/ci-local.ps1`
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
- Service boundary model: API request DTOs are mapped to explicit service method parameters before workflow execution
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
- Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.
- Current frontend features: Blazor-based authenticated campaign workspace with live log updates and full roll workflow controls.
## 1) Stack and baseline choices
@@ -24,8 +21,8 @@
- EF Core with SQLite file persistence in current project (single-node deployment).
- Game state is hydrated once on startup and then served from in-memory state; writes are persisted back to SQLite after successful mutations.
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
- A minimal frontend framework supporting mixing 3D graphics with 2D elements, or a modern framework-less alternative (HTMl/CSS/TypeScript).
- OpenAPI generated from backend and consumed by generated client.
- Blazor frontend host with Razor components and minimal JS interop for browser APIs.
- OpenAPI generated from backend as contract documentation.
- xUnit integration-heavy test suite with isolated SQLite test databases and coverage gates.
## 2) Architecture patterns to keep
@@ -94,22 +91,10 @@ This pattern is a strong baseline for low to medium scale and should be the defa
### 2.6 Frontend architecture
- modules split by concern:
- API wrapper
- Data loaders
- UI composition
- Feature-specific renderers/handlers
- Shared utils and runtime dependency injection
- Single runtime state object with deliberate clear/reset logic.
- Refresh scheduler:
- Serialized refreshes (no overlap)
- Adaptive polling backoff
- SSE-triggered immediate refresh for state mutations
- Visibility-aware refresh suppression
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
- Internationalization:
- translation file validation at startup
- language-specific FAQ markdown loading with fallback to default language
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor(.cs)`.
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
### 2.7 Testing strategy patterns
@@ -127,8 +112,7 @@ This pattern is a strong baseline for low to medium scale and should be the defa
### 2.8 Tooling and contract discipline
- OpenAPI generated during build (`openapi/RpgRoller.json`).
- Client generated from OpenAPI with required operation-id checks.
- Separate lint + format + tests + coverage threshold checks.
- Separate build + tests + coverage threshold checks.
- Build configured with warnings as errors in CI/local script.
## 3) Concrete feature set
@@ -158,7 +142,6 @@ Use this as a reusable "starter scope menu" for the new app:
- trusted proxy/host settings explicit
- Contract:
- OpenAPI generation enabled in build
- generated client wired into frontend
- operation-id stability tested
- Data integrity:
- enforce critical invariants both app-side and DB-side
@@ -179,7 +162,6 @@ Keep:
- Shared service result abstraction.
- Explicit middleware order.
- SSE + ETag state sync.
- Generated API client from OpenAPI.
- DB-enforced invariants.
- Regression tests for security-sensitive UI rendering.

29
package-lock.json generated
View File

@@ -1,29 +0,0 @@
{
"name": "rpgroller",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rpgroller",
"version": "0.1.0",
"devDependencies": {
"typescript": "5.9.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -1,15 +0,0 @@
{
"name": "rpgroller",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build:frontend": "tsc -p ./tsconfig.frontend.json",
"generate:api-client": "node ./scripts/generate-api-client.mjs && npm run build:frontend",
"lint": "tsc -p ./tsconfig.frontend.json --noEmit && node ./scripts/lint-frontend.mjs",
"format:check": "node ./scripts/format-check.mjs"
},
"devDependencies": {
"typescript": "5.9.3"
}
}

View File

@@ -1,5 +1,4 @@
param(
[switch]$SkipNpmInstall,
[switch]$SkipDotnetRestore,
[switch]$SkipBuild
)
@@ -25,12 +24,6 @@ $repoRoot = Split-Path -Parent $scriptDir
Push-Location $repoRoot
try {
if (-not $SkipNpmInstall) {
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
npm install
}
}
if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore RpgRoller.sln
@@ -43,18 +36,6 @@ try {
}
}
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
npm run generate:api-client
}
Invoke-Step -Name "Lint frontend" -Action {
npm run lint
}
Invoke-Step -Name "Check frontend formatting" -Action {
npm run format:check
}
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings

View File

@@ -54,91 +54,6 @@ function Resolve-ProfilePath {
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Normalize-BasePath {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ""
}
$normalized = $Value.Trim()
if (-not $normalized.StartsWith("/")) {
$normalized = "/$normalized"
}
if ($normalized.Length -gt 1) {
$normalized = $normalized.TrimEnd("/")
}
return $normalized
}
function Infer-BasePathFromRemoteDir {
param([string]$RemoteDir)
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
return ""
}
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($segments.Count -eq 0) {
return ""
}
$candidate = $segments[$segments.Count - 1]
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
return ""
}
return Normalize-BasePath $candidate
}
function Resolve-AppBasePath {
param([Parameter(Mandatory = $true)][hashtable]$Config)
if ($Config.ContainsKey("BasePath")) {
$configured = Normalize-BasePath ([string]$Config.BasePath)
if (-not [string]::IsNullOrWhiteSpace($configured)) {
return $configured
}
}
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
}
function Set-FrontendAppBaseMeta {
param(
[Parameter(Mandatory = $true)][string]$PublishDir,
[Parameter(Mandatory = $true)][string]$BasePath
)
$candidatePaths = @(
(Join-Path $PublishDir "wwwroot\index.html"),
(Join-Path $PublishDir "index.html")
)
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($indexPath)) {
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
}
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
$content = Get-Content -Path $indexPath -Raw
if ($content -notmatch $pattern) {
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
}
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
$updated = [System.Text.RegularExpressions.Regex]::Replace(
$content,
$pattern,
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
1
)
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
}
function Read-PlainOrPrompt {
param(
[string]$Value,
@@ -259,9 +174,7 @@ if (-not $selfContained) {
}
dotnet @publishArgs
$appBasePath = Resolve-AppBasePath -Config $config
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"

View File

@@ -1,70 +0,0 @@
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", "frontend"),
path.join(repoRoot, "RpgRoller", "wwwroot"),
path.join(repoRoot, "openapi")
];
const filesToScan = [
path.join(repoRoot, "package.json"),
path.join(repoRoot, "tsconfig.frontend.json"),
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.");

View File

@@ -1,360 +0,0 @@
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", "frontend", "generated", "api-client.ts");
function pascalCase(value) {
return value
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
function escapePathSegment(segment) {
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function mapSimpleType(schema) {
switch (schema?.type) {
case "integer":
case "number":
return "number";
case "boolean":
return "boolean";
case "string":
return "string";
default:
return null;
}
}
function schemaRefName(schema) {
if (typeof schema?.$ref !== "string") {
return null;
}
const refPrefix = "#/components/schemas/";
if (!schema.$ref.startsWith(refPrefix)) {
throw new Error(`Unsupported schema ref: ${schema.$ref}`);
}
return schema.$ref.substring(refPrefix.length);
}
function toTypeScriptType(schema, forProperty = false) {
if (!schema || typeof schema !== "object") {
return "unknown";
}
const refName = schemaRefName(schema);
if (refName !== null) {
return refName;
}
const simpleType = mapSimpleType(schema);
if (simpleType !== null) {
return simpleType;
}
if (schema.type === "array") {
const itemType = toTypeScriptType(schema.items, true);
return `Array<${itemType}>`;
}
if (schema.type === "object") {
const propertyEntries = Object.entries(schema.properties ?? {});
if (propertyEntries.length === 0) {
return "Record<string, unknown>";
}
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = propertyEntries.map(([name, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(name) ? "" : "?";
return `${name}${optional}: ${typeName};`;
});
if (forProperty) {
return `{ ${fields.join(" ")} }`;
}
return `{\n${fields.map((field) => ` ${field}`).join("\n")}\n}`;
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
return "unknown";
}
function schemaToInterface(name, schema) {
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(propertyName) ? "" : "?";
return ` ${propertyName}${optional}: ${typeName};`;
});
if (fields.length === 0) {
return `export type ${name} = Record<string, unknown>;`;
}
return `export interface ${name} {\n${fields.join("\n")}\n}`;
}
function collectParameters(pathItem, operation) {
const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
const deduped = [];
const keys = new Set();
for (const parameter of rawParameters) {
if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") {
continue;
}
const key = `${parameter.in}:${parameter.name}`;
if (keys.has(key)) {
continue;
}
keys.add(key);
const inferredType = mapSimpleType(parameter.schema) ?? "string";
deduped.push({
name: parameter.name,
location: parameter.in,
required: parameter.required === true,
type: inferredType
});
}
return deduped;
}
function resolveRequestBodyType(operation) {
const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
if (!bodySchema) {
return null;
}
return {
type: toTypeScriptType(bodySchema, true),
required: operation.requestBody.required === true
};
}
function resolveResponse(operation) {
const responses = operation.responses ?? {};
const successCodes = Object.keys(responses)
.filter((code) => code.startsWith("2"))
.sort();
if (successCodes.length === 0) {
return { type: "void", expectsJson: false };
}
for (const code of successCodes) {
const jsonSchema = responses[code]?.content?.["application/json"]?.schema;
if (jsonSchema) {
return { type: toTypeScriptType(jsonSchema), expectsJson: true };
}
if (code === "204") {
return { type: "void", expectsJson: false };
}
}
return { type: "void", expectsJson: false };
}
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" || typeof operation.operationId !== "string") {
continue;
}
if (operation.operationId.length === 0) {
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
}
const parameters = collectParameters(pathItem, operation);
const pathParameters = parameters.filter((parameter) => parameter.location === "path");
const queryParameters = parameters.filter((parameter) => parameter.location === "query");
const requestBody = resolveRequestBodyType(operation);
const response = resolveResponse(operation);
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path: pathKey,
pathParameters,
queryParameters,
requestBody,
response
});
}
}
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 buildSchemaTypes(document) {
const schemas = document.components?.schemas ?? {};
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
const declarations = schemaEntries.map(([name, schema]) => {
if (!schema || typeof schema !== "object") {
return `export type ${name} = unknown;`;
}
return schemaToInterface(name, schema);
});
return declarations.join("\n\n");
}
function buildFunctionSource(operation) {
const pathParameters = operation.pathParameters;
const queryParameters = operation.queryParameters;
const body = operation.requestBody;
const parts = [];
if (pathParameters.length > 0) {
parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`));
}
if (body) {
const optional = body.required ? "" : "?";
parts.push(`body${optional}: ${body.type}`);
}
if (queryParameters.length > 0) {
const queryType = queryParameters
.map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`)
.join(" ");
parts.push(`query: { ${queryType} }`);
}
const signature = parts.join(", ");
const pathParameterAssignment = pathParameters.length === 0
? "{}"
: `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
const bodyArgument = body ? "body" : "undefined";
const queryArgument = queryParameters.length > 0 ? "query" : "undefined";
return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`;
}
function buildClientSource(operations, schemaTypes) {
const operationEntries = operations
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`)
.join(",\n");
const helper = `
type ApiOperation = {
method: string;
path: string;
expectsJson: boolean;
};
type RequestOptions = {
pathParams?: Record<string, string | number | boolean>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
export const apiOperations = {
${operationEntries}
} as const satisfies Record<string, ApiOperation>;
`.trim();
const sendFunction = `
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`;
}
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers: Record<string, string> = {
"Accept": "application/json"
};
let body: string | undefined;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers,
body
});
if (!response.ok) {
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
throw new Error(\`Request failed with status \${response.status}\`);
}
if (!operation.expectsJson) {
return undefined as TResult;
}
return response.json() as Promise<TResult>;
}
`.trim();
const exports = operations.map(buildFunctionSource).join("\n\n");
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
}
const openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText);
const operations = collectOperations(document);
const schemaTypes = buildSchemaTypes(document);
const clientSource = buildClientSource(operations, schemaTypes);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, clientSource, "utf8");
console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);

View File

@@ -1,41 +0,0 @@
import { 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 openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts");
const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
const generatedClient = await readFile(generatedClientPath, "utf8");
const appSource = await readFile(appTsPath, "utf8");
const errors = [];
if (!appSource.includes("from \"./generated/api-client.js\"")) {
errors.push("Frontend app.ts must import the generated api-client module.");
}
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.");

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"strict": true,
"noEmitOnError": true,
"rootDir": "./RpgRoller/frontend",
"outDir": "./RpgRoller/wwwroot",
"newLine": "lf"
},
"include": [
"RpgRoller/frontend/**/*.ts"
]
}