diff --git a/docs/kb/README.md b/docs/kb/README.md index b33d094..e971ada 100644 --- a/docs/kb/README.md +++ b/docs/kb/README.md @@ -16,8 +16,12 @@ The goal is to give request-processing tools stable system summaries instead of - Machine-readable system index. - `request-analysis-schema.json` - JSON schema for structured request parsing output. +- `tags.json` + - Standardized request tags for queue analysis and admin review. - `queue-workflow.md` - First-pass automation and review flow. +- `editor-capabilities.md` + - Shorthand capability matrix for common request-analysis questions. - `systems/*.md` - Human-readable system notes, organized by editor subsystem. @@ -44,10 +48,11 @@ Suggested queue flow: 1. Pull a raw request submission from the launcher request store. 2. Retrieve likely systems from `systems.json` using tags, aliases, and file names. 3. Provide the matching `systems/*.md` files to the local model as context. -4. Ask the model to split the submission into atomic requests. -5. Require output that matches `request-analysis-schema.json`. -6. Validate the JSON before storing anything back into the app. -7. Auto-promote only high-confidence items. Keep low-confidence items in review. +4. Require the model to use only tags from `tags.json`. +5. Ask the model to split the submission into atomic requests. +6. Require output that matches `request-analysis-schema.json`. +7. Validate the JSON before storing anything back into the app. +8. Auto-promote only high-confidence items. Keep low-confidence items in review. ## Recommended Next Additions @@ -98,6 +103,7 @@ $env:REQUEST_ANALYZER_MODEL="deepseek-v4-pro" - If the model produces multiple atomic requests from one submission, keep the original submission id on every derived item. - If a request touches multiple systems, prefer one primary category and many tags. +- Review rationale should be short, structured, and safe to show in admin UI. Do not rely on hidden model chain-of-thought. - If the request is really a bug report, preserve the problem statement and write the implementation note as a likely fix path, not a promise. - If the model cannot confidently map a request, store it as `needs_review` instead of forcing a category. diff --git a/docs/kb/editor-capabilities.md b/docs/kb/editor-capabilities.md new file mode 100644 index 0000000..e7e3f97 --- /dev/null +++ b/docs/kb/editor-capabilities.md @@ -0,0 +1,69 @@ +# Editor Capabilities Map + +This file is a shorthand capability matrix for request analysis. It is intentionally less detailed than the source code, but more concrete than the original scaffold summaries. + +## Tile Canvas Editing + +- Owned primarily by `Layers And Tile Editing` +- Supports: + - freehand tile painting + - rectangle and circle outline strokes + - line strokes with axis lock + - erasing on the active layer + - sparse height-layer paint and erase workflows + - background-cell modes for explicit tile, transparent hole, and inherit +- Depends on: + - `Chunk Storage And Streaming` + - `Content Catalog And Records` + - `Persistence And Save Pipeline` + - `Rendering And Viewport` + +## Graphics Painter + +- Owned primarily by `Graphics Painter` +- Supports: + - rectangle, circle, triangle, and line tools + - outline, fill, and outline + fill variants + - shape erasers + - animation timeline editing and preview + - transform-oriented tool menus +- Depends on: + - `Content Catalog And Records` + - `Floating Window Shell` + - `Rendering And Viewport` + +## Chunk Operations + +- Shared by `Chunk Storage And Streaming` and `World Overview` +- Supports: + - neighborhood chunk loading + - chunk composition into the current world edit surface + - chunk move, duplicate, rotate, flip, and delete + - chunk background tile paint and restore + - bookmark and POI workflows +- Depends on: + - `Persistence And Save Pipeline` + - `Worlds` + - `Windows` + +## Request Review + +- Owned primarily by `Request Board` +- Supports: + - public submission intake + - standardized tag assignment + - structured review rationale + - possible options for manual triage + - admin-side edits and approval + - queue worker invocation +- Depends on: + - `Launcher` + - `Wiki` + - request-analysis worker and KB files + +## Relationship Rules For Triage + +- If a request asks for how a brush or rectangle tool behaves on the map canvas, start with `Layers And Tile Editing`. +- If a request asks for how a rectangle or shape tool behaves inside the art editor, start with `Graphics Painter`. +- If a request asks about chunk movement, chunk duplication, or world-scale operations, start with `World Overview`, then confirm any storage implications in `Chunk Storage And Streaming`. +- If a request is mostly about presentation, launch flow, repo links, or public site layout, start with `Launcher Home`. diff --git a/docs/kb/queue-workflow.md b/docs/kb/queue-workflow.md index c04f224..2f1424f 100644 --- a/docs/kb/queue-workflow.md +++ b/docs/kb/queue-workflow.md @@ -18,9 +18,10 @@ Turn raw launcher submissions into structured request items that are: 2. Split it into candidate atomic requests. 3. Retrieve likely systems from `docs/kb/systems.json`. 4. Load the matching `docs/kb/systems/*.md` files. -5. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`. -6. Validate the JSON. -7. Promote high-confidence items and hold low-confidence items for review. +5. Load the standardized request tags from `docs/kb/tags.json`. +6. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`. +7. Validate the JSON. +8. Promote high-confidence items and hold low-confidence items for review. ## Suggested Confidence Rules @@ -52,10 +53,11 @@ You are processing Worldshaper editor requests. You must: - split the submission into one or more atomic requests - assign one primary category per item -- assign tags grounded in the provided KB +- assign tags grounded in the provided KB and limited to `docs/kb/tags.json` - write a short descriptive title - explain the user intent in plain language - propose a likely implementation path +- provide a short review rationale and possible options when confidence is low - avoid inventing systems that are not in the KB - return only valid JSON matching the provided schema diff --git a/docs/kb/request-analysis-schema.json b/docs/kb/request-analysis-schema.json index fda19f7..a47541d 100644 --- a/docs/kb/request-analysis-schema.json +++ b/docs/kb/request-analysis-schema.json @@ -105,6 +105,17 @@ "minimum": 0, "maximum": 1 }, + "reviewRationale": { + "type": "string" + }, + "reviewOptions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "notes": { "type": "string" } diff --git a/docs/kb/systems.json b/docs/kb/systems.json index 11183f5..dfb37b0 100644 --- a/docs/kb/systems.json +++ b/docs/kb/systems.json @@ -7,7 +7,7 @@ "name": "Launcher Home", "docPath": "docs/kb/systems/launcher-home.md", "aliases": ["launcher", "main page", "home page", "landing page"], - "tags": ["Launcher", "News", "Requests", "Presentation"], + "tags": ["Launcher", "Request Board", "Website", "Polish"], "uiSurfaces": ["Launcher hero", "News tab", "Requests tab"], "keyFiles": ["src/WorldshaperLauncher.tsx", "src/index.css", "src/worldshaperStudio/windowing.ts"], "apiEndpoints": ["/api/world-default", "/api/launcher-requests"], @@ -19,7 +19,7 @@ "name": "Request Board", "docPath": "docs/kb/systems/request-board.md", "aliases": ["requests", "request queue", "request intake", "request board"], - "tags": ["Requests", "Launcher", "Workflow", "Moderation"], + "tags": ["Request Board", "Launcher", "UI / Workflow", "Website"], "uiSurfaces": ["Launcher Requests tab"], "keyFiles": ["src/WorldshaperLauncher.tsx", "server.js", "data/launcher_requests.json"], "apiEndpoints": ["/api/launcher-requests", "/api/launcher-requests/:requestId"], @@ -31,7 +31,7 @@ "name": "Studio Bootstrap", "docPath": "docs/kb/systems/world-bootstrap.md", "aliases": ["bootstrap", "startup", "world load", "initial load"], - "tags": ["Bootstrap", "Worlds", "Chunks", "Startup"], + "tags": ["Worlds", "Chunks", "Launcher", "Persistence"], "uiSurfaces": ["Studio window"], "keyFiles": ["src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/main.ts", "src/worldshaperStudio/runtime.ts"], "apiEndpoints": ["/api/world-default", "/api/world/:worldId", "/api/world/:worldId/bookmarks", "/api/world/:worldId/chunks"], @@ -43,7 +43,7 @@ "name": "Chunk Storage And Streaming", "docPath": "docs/kb/systems/chunk-storage-streaming.md", "aliases": ["chunks", "chunk loading", "world streaming", "chunk cache", "world neighborhood"], - "tags": ["Chunks", "Streaming", "Worlds", "Performance"], + "tags": ["Chunks", "Worlds", "Performance", "Persistence"], "uiSurfaces": ["World editor viewport", "World mode"], "keyFiles": ["server.js", "src/worldChunking.ts", "src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/runtime.ts"], "apiEndpoints": ["/api/world/:worldId/chunk/:chunkX/:chunkY", "/api/world/:worldId/chunks", "/api/world/:worldId/chunks/batch-save"], @@ -55,7 +55,7 @@ "name": "Layers And Tile Editing", "docPath": "docs/kb/systems/layers-tile-editing.md", "aliases": ["layers", "tile painting", "height layers", "brushes", "eraser"], - "tags": ["Layers", "Tiling", "Editing", "Height Layers"], + "tags": ["Layers", "Tiling", "UI / Workflow", "Worlds"], "uiSurfaces": ["Layers sidebar", "Canvas tools", "Viewport"], "keyFiles": ["src/worldshaperStudio/interactionController.ts", "src/worldshaperStudio/runtime.ts", "src/worldshaperStudio/sidebarController.ts", "src/worldshaperStudio/mapDocumentController.ts", "src/worldshaperStudio/popupSessionStore.ts"], "apiEndpoints": ["/api/world/:worldId/chunks/batch-save"], @@ -67,7 +67,7 @@ "name": "Graphics Painter", "docPath": "docs/kb/systems/graphics-painter.md", "aliases": ["graphic painter", "graphics painter", "tile art", "sprite painter", "animation painter"], - "tags": ["Graphics Painter", "Animation", "Tiles", "Sprites"], + "tags": ["Graphics Painter", "Animation", "Content", "Windows"], "uiSurfaces": ["Graphic Painter window", "Animation preview"], "keyFiles": ["src/worldshaperStudio/tileArtEditorWindowController.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts", "src/worldshaperStudio/dom.ts"], "apiEndpoints": ["/api/content/images", "/api/content/sprites", "/api/content/tiles", "/api/images", "/api/images/:filename"], @@ -79,7 +79,7 @@ "name": "Rendering And Viewport", "docPath": "docs/kb/systems/rendering-viewport.md", "aliases": ["renderer", "pixi", "viewport", "camera", "draw", "chunk culling"], - "tags": ["Rendering", "Viewport", "Pixi", "Performance"], + "tags": ["Rendering", "Performance", "Worlds", "Chunks"], "uiSurfaces": ["World canvas", "Debug overlay"], "keyFiles": ["src/worldshaperStudio/renderController.ts", "src/worldshaperStudio/pixiTileStageController.ts", "src/worldshaperStudio/pixiSceneRebuildHelpers.ts", "src/worldshaperStudio/pixiChunkSurfaceHelpers.ts", "src/worldshaperStudio/overlayRenderer.ts"], "apiEndpoints": [], @@ -91,7 +91,7 @@ "name": "World Overview", "docPath": "docs/kb/systems/world-overview.md", "aliases": ["overview", "world overview", "chunk overview", "overview map"], - "tags": ["World Overview", "Chunks", "Bookmarks", "Navigation"], + "tags": ["World Overview", "Chunks", "Windows", "Worlds"], "uiSurfaces": ["World Overview window"], "keyFiles": ["src/worldshaperStudio/worldOverviewWindowController.ts", "src/worldshaperStudio/dom.ts", "server.js"], "apiEndpoints": ["/api/world/:worldId/overview", "/api/world/:worldId/bookmarks"], @@ -103,7 +103,7 @@ "name": "Persistence And Save Pipeline", "docPath": "docs/kb/systems/persistence-save-pipeline.md", "aliases": ["save", "saving", "persist", "world save", "content save"], - "tags": ["Persistence", "Saving", "APIs", "Worlds", "Content"], + "tags": ["Persistence", "Content", "Chunks", "Worlds"], "uiSurfaces": ["Toolbar save flow", "Status messages"], "keyFiles": ["src/worldshaperStudio/persistenceController.ts", "src/worldshaperStudio/historyController.ts", "src/worldshaperStudio/historyStateStore.ts", "server.js"], "apiEndpoints": ["/api/world/:worldId/chunks/batch-save", "/api/content/:type", "/api/editor-settings", "/api/catalog-meta"], @@ -115,7 +115,7 @@ "name": "Floating Window Shell", "docPath": "docs/kb/systems/floating-window-shell.md", "aliases": ["floating window", "popout", "windowing", "tool windows"], - "tags": ["Windows", "UI", "Launcher", "Tooling"], + "tags": ["Windows", "Launcher", "UI / Workflow", "Graphics Painter"], "uiSurfaces": ["Studio popout", "Tool windows", "Overlay layer"], "keyFiles": ["src/worldshaperStudio/windowing.ts", "src/worldshaperStudio/floatingWindowUtils.ts", "src/worldshaperStudio/toolWindowController.ts", "src/worldshaperStudio/changelogSplashWindowController.ts", "src/worldshaperStudio/engineOverrideWindowController.ts"], "apiEndpoints": [], @@ -127,7 +127,7 @@ "name": "Content Catalog And Records", "docPath": "docs/kb/systems/content-catalog-records.md", "aliases": ["content", "tiles", "sprites", "images", "catalog", "records"], - "tags": ["Content", "Tiles", "Sprites", "Images", "Schemas"], + "tags": ["Content", "Graphics Painter", "Tiling", "Persistence"], "uiSurfaces": ["Content records", "Graphic Painter inputs", "Tile palette"], "keyFiles": ["server.js", "src/App.tsx", "src/components/worldshaperShared.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts"], "apiEndpoints": ["/api/content/:type", "/api/content/tiles/:tileId/delete", "/api/content/sprites/:spriteId/delete", "/api/catalog-meta", "/api/images", "/api/images/:filename"], diff --git a/docs/kb/systems/chunk-storage-streaming.md b/docs/kb/systems/chunk-storage-streaming.md index 59c01cf..4d756b9 100644 --- a/docs/kb/systems/chunk-storage-streaming.md +++ b/docs/kb/systems/chunk-storage-streaming.md @@ -1,48 +1,47 @@ # Chunk Storage And Streaming -## What It Does +## What It Owns -World data is stored as per-chunk JSON files and loaded into the editor as chunk neighborhoods around a center chunk. The runtime caches chunk payloads, shifts neighborhoods as the viewport moves, and saves dirty chunks in batches. +This system owns the world-mode chunk model: loading chunk neighborhoods, composing chunk data into the current editor session, and persisting chunk updates back to disk. -## Key Files +## Core Behaviors + +- Load a neighborhood of chunks around a world position. +- Compose room layers from chunk-local rows into a temporary larger editing surface. +- Compose sparse height layers from chunk data. +- Compose placed instances and overlays from chunk payloads. +- Batch-save chunk changes back to the API. + +## Important Data + +Chunk payloads can contribute: + +- `roomLayers` +- `heightLayers` +- `instances` +- `backgroundTileId` +- chunk coordinates and dimensions + +## Important Files - `server.js` - `src/worldChunking.ts` - `src/worldshaperStudio/bootstrap.ts` - `src/worldshaperStudio/runtime.ts` -- `src/worldshaperStudio/persistenceController.ts` -## Endpoints It Uses +## Known Behavior Notes -- `GET /api/world/:worldId/chunk/:chunkX/:chunkY` -- `POST /api/world/:worldId/chunk/:chunkX/:chunkY` -- `GET /api/world/:worldId/chunks` -- `POST /api/world/:worldId/chunks/batch-save` +- The bootstrap path currently loads a neighborhood around an initial bookmark or world center instead of streaming every chunk in the world. +- Exact chunk access is grid-based, so many performance requests should start with profiling the current neighborhood/cache path before proposing spatial trees. +- World mode is not editing one isolated chunk in a vacuum; it edits a composed surface backed by many chunk records. -## Important Data And Rules +## Relationships -- chunks are stored as `content/worlds//chunks/_.json` -- world neighborhoods are requested as a square around a center chunk -- runtime keeps a chunk cache and tracks dirty chunk keys -- chunk payloads include `roomLayers`, `heightLayers`, `instances`, and optional `backgroundTileId` +- Feeds `Layers And Tile Editing`, which paints onto the composed chunk neighborhood. +- Feeds `World Overview`, which visualizes and transforms chunk-level records. +- Depends on `Persistence And Save Pipeline` to write chunk mutations. +- Feeds `Rendering And Viewport`, which only draws what the currently loaded chunk neighborhood exposes. -## Invariants And Constraints +## Triage Hints -- Exact chunk lookup is grid-based and direct. -- The editor does not load the entire world for normal editing. -- Small tile edits must sync back into the correct cached chunk. -- Save flow should preserve chunk-local edits without rebuilding unrelated chunks. - -## Common Request Themes - -- chunk loading speed -- chunk streaming behavior -- per-chunk backgrounds -- chunk transforms, duplication, deletion -- performance questions around indexing and caching - -## Triage Questions - -- Is the request about storage, fetch patterns, cache behavior, or save behavior? -- Does it affect only visible chunks or the full world? -- Is the user asking for chunk-local data, or freeform world overlays that break chunk assumptions? +Requests about chunk loading, chunk transforms, chunk duplication, world streaming, chunk persistence, spatial indexing, and neighborhood performance should start here. diff --git a/docs/kb/systems/graphics-painter.md b/docs/kb/systems/graphics-painter.md index f2011ad..096d0de 100644 --- a/docs/kb/systems/graphics-painter.md +++ b/docs/kb/systems/graphics-painter.md @@ -1,48 +1,55 @@ # Graphics Painter -## What It Does +## What It Owns -The Graphics Painter is the tile and sprite art editing window. It handles pixel editing, frame management, animation timeline editing, preview playback, and import-oriented art workflows. +This is the dedicated art-editing environment for tile and sprite graphics. It covers shape tools, erasers, transforms, previewing, and animation timeline editing for the currently opened asset. -## Key Files +## Core Behaviors + +- Shape drawing modes: + - rectangle + - circle + - triangle + - line +- Shape variants: + - outline + - fill + - outline + fill +- Shape erasers: + - rectangle + - circle + - triangle +- Transform workflows available through the painter menu. +- Animation timeline controls, frame order, playback speed, and preview. + +## Important UI Surfaces + +- Graphic Painter window +- Tool menu and tooltip menus +- Animation timeline row +- Preview card and frame preview + +## Important Files - `src/worldshaperStudio/tileArtEditorWindowController.ts` - `src/worldshaperStudio/graphicsDocumentHelpers.ts` - `src/worldshaperStudio/importController.ts` - `src/worldshaperStudio/dom.ts` -## Endpoints It Uses +## Known Tool Details -- `GET /api/images` -- `GET /api/images/:filename` -- `POST /api/content/images` -- `POST /api/content/sprites` -- `POST /api/content/tiles` +- Shape menus are nested by shape and variant, rather than being a single flat tool list. +- The active draw tool and active eraser shape are tracked separately. +- The painter already knows about animation preview, frame creation, and per-frame editing; many animation requests should be phrased as extensions to an existing timeline rather than brand-new systems. +- The preview and animation controls are editor-side only; runtime rendering requests often cross into `Rendering And Viewport`. -## Important Data And Rules +## Relationships -- graphics records may include animation frame data and playback metadata -- preview behavior is maintained in painter-local UI state -- art changes eventually flow into tile and sprite content records -- imports and previews are tightly tied to painter usability +- Depends on `Content Catalog And Records` for source records and saved asset definitions. +- Depends on `Floating Window Shell` because the painter lives inside a movable editor window. +- Feeds `Rendering And Viewport` when edited assets are later shown in the live map renderer. +- Intersects with `Animation`-tagged requests when frame behavior changes. -## Invariants And Constraints +## Triage Hints -- the painter is a floating tool window, not a docked pane -- animation preview should reflect current frame ordering and speed settings -- save behavior must preserve frame structure and timing metadata -- painter workflows often depend on tile and sprite schema conventions - -## Common Request Themes - -- move or selection tools -- frame duplication and frame management -- animation controls -- import workflow improvements -- better preview behavior - -## Triage Questions - -- Is this request about art authoring, record storage, or runtime playback? -- Does it affect tiles, sprites, or both? -- Is the requested behavior inside the painter window only? +Requests about sprite drawing, tile art tools, frame duplication, painter transforms, selection or move workflows, and editor-side animation controls should usually start here. diff --git a/docs/kb/systems/launcher-home.md b/docs/kb/systems/launcher-home.md index 25ba92e..0ef2a0f 100644 --- a/docs/kb/systems/launcher-home.md +++ b/docs/kb/systems/launcher-home.md @@ -1,42 +1,36 @@ # Launcher Home -## What It Does +## What It Owns -The launcher is the public-facing entry point for Worldshaper. It presents the hero card, opens the editor in its own floating window, links to the repo, and hosts the News and Requests tabs. +The launcher is the public-facing web shell for Worldshaper. It handles editor launch, launch presentation, release notes, repo navigation, and the visible request board entry point. -## Key Files +## Core Behaviors + +- Open the editor in a separate floating window. +- Show the release/news presentation. +- Show the Requests board. +- Swap between public request browsing and protected admin review mode. +- Present launcher artwork and page-level theming. + +## Important Files - `src/WorldshaperLauncher.tsx` - `src/index.css` - `src/worldshaperStudio/windowing.ts` -## Endpoints It Uses +## Important UI Surfaces -- `GET /api/world-default` -- `GET /api/launcher-requests` +- Hero launch card +- News tab +- Requests tab +- Protected admin unlock flow -## Important State +## Relationships -- launch state: ready, opening, opened, blocked, error -- active board tab: news or requests -- request list, filters, and expanded request rows -- resolved default world id +- Depends on `Floating Window Shell` for popup launch behavior. +- Depends on `Request Board` for request intake and moderation UI. +- Intersects with `Website` and `Polish` requests more than the editor-runtime systems do. -## Invariants And Constraints +## Triage Hints -- The editor is intended to open in a separate popup-style window. -- The launcher should still work if the default world lookup fails by falling back to `overworld`. -- Browser popup blocking is a real failure mode and must be surfaced to the user. - -## Common Request Themes - -- page styling and presentation -- launch flow and popup behavior -- repo button or external links -- tab layout and launcher information architecture - -## Triage Questions - -- Is this about the launcher page itself, or the studio window after launch? -- Does it affect only presentation, or also request API behavior? -- Does it depend on popup/window behavior? +Requests about the landing page, launch flow, background presentation, release content, public request visibility, and repo-linking should start here. diff --git a/docs/kb/systems/layers-tile-editing.md b/docs/kb/systems/layers-tile-editing.md index badc823..6d7af3b 100644 --- a/docs/kb/systems/layers-tile-editing.md +++ b/docs/kb/systems/layers-tile-editing.md @@ -1,47 +1,55 @@ # Layers And Tile Editing -## What It Does +## What It Owns -This subsystem handles normal tile painting, erasing, selection, visible layer management, sparse height layers, and the mapping between visible document edits and stored chunk data. +This system owns the main world-canvas tile workflow: selecting a room layer, choosing a tile brush, painting, erasing, and editing sparse height layers in world mode. -## Key Files +## Core Behaviors + +- Freehand tile painting on the active layer. +- Shape strokes for the world canvas: + - freehand + - rectangle outline + - circle outline + - line with axis lock behavior +- Height-layer painting modes: + - single or freehand paint + - rectangle fill + - circle fill + - erase variants for the same shapes +- Background-cell editing modes: + - explicit tile stamp + - transparent hole + - inherit world background + +## Important UI Surfaces + +- Layers sidebar +- Height layer list +- Canvas brush interactions +- Background mode button and preview + +## Important Files - `src/worldshaperStudio/interactionController.ts` -- `src/worldshaperStudio/runtime.ts` - `src/worldshaperStudio/sidebarController.ts` +- `src/worldshaperStudio/runtime.ts` - `src/worldshaperStudio/mapDocumentController.ts` -- `src/worldshaperStudio/popupSessionStore.ts` -- `src/worldshaperStudio/dom.ts` -## Endpoints It Uses +## Known Tool Details -- primarily saved through `POST /api/world/:worldId/chunks/batch-save` +- Tile strokes register history entries with shape-specific labels such as rectangle stroke, circle stroke, brush stroke, and erase stroke. +- Height strokes register their own history labels and track the target Z layer. +- Layer editing is tightly coupled to the currently loaded chunk neighborhood in world mode. +- Brush behavior depends on catalog tile metadata resolved through runtime helpers. -## Important Data And Rules +## Relationships -- room layer `0` is the base layer -- non-base layers use sparse or transparent-style fill semantics -- height layers are separate structures from room layers -- visibility is tracked in popup session state -- painting in world mode must mark affected chunks dirty +- Depends on `Content Catalog And Records` for tile definitions and brush metadata. +- Depends on `Chunk Storage And Streaming` for the currently editable chunk neighborhood. +- Depends on `Persistence And Save Pipeline` for actually writing edits back to storage. +- Feeds `Rendering And Viewport`, which visualizes the edited layers and sparse height patches. -## Invariants And Constraints +## Triage Hints -- layer ordering matters to rendering and selection behavior -- layer visibility must not destroy layer data -- tile edits in world mode must map local viewport coordinates back to world chunk coordinates -- height layers are not the same thing as standard room layers - -## Common Request Themes - -- better layer reordering -- more precise tiling tools -- height/elevation features -- snapping or unsnapped placement -- erase or selection behavior - -## Triage Questions - -- Is this about normal room layers or height layers? -- Does the request affect paint semantics, visibility, ordering, or storage? -- Does it apply to map mode, world mode, or both? +Requests about rectangle tools, brush behavior, height painting, active layer confusion, background stamping, and tile-placement modes should usually start here, even when they also touch rendering or chunk persistence. diff --git a/docs/kb/systems/request-board.md b/docs/kb/systems/request-board.md index 38d5730..f18406b 100644 --- a/docs/kb/systems/request-board.md +++ b/docs/kb/systems/request-board.md @@ -1,54 +1,47 @@ # Request Board -## What It Does +## What It Owns -The request board lives on the launcher and stores lightweight user submissions. It currently supports pending and active request states, filtering, expansion of active items, and deletion with confirmation. +The request board is the launcher-side intake, queue, moderation, and review surface for product requests. It stores raw submissions, structured analysis, active request rows, and admin-only review actions. -## Key Files +## Core Behaviors -- `src/WorldshaperLauncher.tsx` -- `server.js` -- `data/launcher_requests.json` +- Save public request submissions. +- Keep raw source text alongside normalized active requests. +- Store structured analysis metadata for pending requests. +- Filter by status and standardized tags. +- Protect admin review actions behind server-side password checks. +- Promote reviewed request items into active request rows. -## Endpoints It Uses +## Important Endpoints - `GET /api/launcher-requests` - `POST /api/launcher-requests` - `PATCH /api/launcher-requests/:requestId` - `DELETE /api/launcher-requests/:requestId` +- `POST /api/launcher-requests/:requestId/process-analysis` +- `POST /api/launcher-requests/process-pending` +- `GET /api/launcher-request-meta` +- `POST /api/admin/auth-check` ## Important Data Shape -Current request records include: +Request records can now contain: -- `id` -- `sourceSubmissionId` -- `title` -- `status` -- `category` -- `tags` -- `sourceText` -- `summary` -- `implementationNotes` -- `createdAt` -- `updatedAt` +- public fields such as title, category, tags, summary, and implementation notes +- raw `sourceText` +- `analysis.state` +- `analysis.items[]` +- per-item review rationale +- per-item possible options +- structured affected-system and affected-file hints -## Invariants And Constraints +## Relationships -- New submissions enter as pending unless promoted. -- One raw submission may contain multiple real feature requests. -- Active requests should be normalized, titled, and tagged. -- Deletion is destructive and should remain explicit. +- Depends on `Launcher Home` for the public site presentation. +- Depends on the KB under `docs/kb/` for model-grounded request parsing. +- Depends on the request-analysis worker for automated triage. -## Common Request Themes +## Triage Hints -- parse and split user submissions -- request tags and filtering -- moderation and cleanup -- queue automation and confidence review - -## Triage Questions - -- Is the user asking to change request storage, presentation, or parsing behavior? -- Should one submission become many active requests? -- Is the request really a bug report or a feature idea? +Requests about queue automation, moderation, review UI, request splitting, approval workflows, structured tags, and admin tooling should start here. diff --git a/docs/kb/systems/world-overview.md b/docs/kb/systems/world-overview.md index 90306bc..44c1bec 100644 --- a/docs/kb/systems/world-overview.md +++ b/docs/kb/systems/world-overview.md @@ -1,45 +1,44 @@ # World Overview -## What It Does +## What It Owns -World Overview is a separate window that displays a coarse map of all known chunks, supports bookmark navigation, and exposes chunk operations such as move, duplicate, rotate, flip, delete, and background adjustments. +The World Overview is the large-scale chunk map used for navigation, chunk inspection, bookmark placement, and chunk-level operations that would be awkward from the tile canvas alone. -## Key Files +## Core Behaviors + +- Navigate directly to a chunk. +- Move chunk content. +- Duplicate chunk tiles. +- Flip and rotate chunk content. +- Delete a chunk and clear its tiles, height data, and placed entities. +- Paint or restore the chunk background tile. +- Create POIs and bookmarks from chunk positions. + +## Important UI Surfaces + +- World Overview floating window +- Chunk context menu +- Pending chunk-action flow +- Bookmark and POI display + +## Important Files - `src/worldshaperStudio/worldOverviewWindowController.ts` - `src/worldshaperStudio/dom.ts` - `server.js` -## Endpoints It Uses +## Known Behavior Notes -- `GET /api/world/:worldId/overview` -- `GET /api/world/:worldId/bookmarks` -- bookmark writes flow through the world save path +- The chunk context menu is already a rich operations hub, so many requests in this area are extensions to an existing menu rather than requests for a brand-new system. +- Pending move and duplicate actions are stateful; the overview can wait for a second chunk selection before executing. +- The overview also exposes quick background-painting workflows at the chunk level. -## Important Data And Rules +## Relationships -- overview currently requests the full set of world chunks from the server -- it draws its own chunk preview surfaces -- it merges server chunks with cached client chunks to reflect unsaved local state -- chunk context menus expose world-level operations +- Depends on `Chunk Storage And Streaming` for chunk records and chunk dimensions. +- Depends on `Windows` because the overview lives in its own floating tool window. +- Often intersects with `Layers` and `Tiling` when users want higher-level terrain or chunk painting workflows. -## Invariants And Constraints +## Triage Hints -- overview is only available in world mode -- overview actions should not silently discard unsaved chunk edits -- chunk transform actions must preserve chunk addressing rules -- the full overview payload can become a performance hotspot on larger worlds - -## Common Request Themes - -- chunk operations -- world navigation -- bookmark workflows -- overview performance -- better high-level visualization - -## Triage Questions - -- Is the issue specific to the overview window, or all world editing? -- Does it affect overview drawing only, or also chunk save/load behavior? -- Is the request about overview scale, navigation, or chunk manipulation? +Requests about chunk move, duplicate, rotate, delete, POIs, overview navigation, and world-scale map management should begin here. diff --git a/docs/kb/tags.json b/docs/kb/tags.json new file mode 100644 index 0000000..73ad7e8 --- /dev/null +++ b/docs/kb/tags.json @@ -0,0 +1,132 @@ +{ + "schemaVersion": 1, + "generatedFromRepoDate": "2026-06-27", + "tags": [ + { + "id": "launcher", + "label": "Launcher", + "description": "The public landing page and launch flow for Worldshaper Studio.", + "aliases": ["launcher home", "home page", "landing page", "main page"] + }, + { + "id": "request-board", + "label": "Request Board", + "description": "The launcher request queue, review tooling, and moderation workflows.", + "aliases": ["requests", "request queue", "moderation", "request intake"] + }, + { + "id": "chunks", + "label": "Chunks", + "description": "Chunk storage, chunk transforms, chunk loading, and chunk-level map operations.", + "aliases": ["chunk", "chunk loading", "chunk storage", "chunk overview"] + }, + { + "id": "layers", + "label": "Layers", + "description": "Base room layers, height layers, and layer management workflows.", + "aliases": ["layer", "height layers", "z layers"] + }, + { + "id": "tiling", + "label": "Tiling", + "description": "Tile painting, shape strokes, grid editing, and tile placement workflows.", + "aliases": ["tile", "tile editing", "tile painting", "brush"] + }, + { + "id": "graphics-painter", + "label": "Graphics Painter", + "description": "The tile and sprite art editor, including transforms and frame editing.", + "aliases": ["graphic painter", "tile art", "sprite painter", "art editor"] + }, + { + "id": "rendering", + "label": "Rendering", + "description": "Viewport drawing, visual composition, and renderer-facing behavior.", + "aliases": ["renderer", "viewport", "pixi", "draw"] + }, + { + "id": "animation", + "label": "Animation", + "description": "Frame timelines, animation playback, and animated graphics behavior.", + "aliases": ["animated", "timeline", "frame playback"] + }, + { + "id": "content", + "label": "Content", + "description": "Content records, tile and sprite catalogs, imported images, and asset metadata.", + "aliases": ["assets", "catalog", "tiles", "sprites", "images"] + }, + { + "id": "world-overview", + "label": "World Overview", + "description": "The large-scale overview map, navigation, bookmarks, and chunk context actions.", + "aliases": ["overview", "world map", "bookmarks", "poi"] + }, + { + "id": "windows", + "label": "Windows", + "description": "Floating windows, popouts, and dockable or movable tool shells.", + "aliases": ["windowing", "popout", "tool windows", "floating window"] + }, + { + "id": "persistence", + "label": "Persistence", + "description": "Saving, history, API persistence, and data write pipelines.", + "aliases": ["saving", "save pipeline", "history", "undo", "redo"] + }, + { + "id": "performance", + "label": "Performance", + "description": "Profiling, loading speed, rendering cost, and optimization work.", + "aliases": ["optimization", "slow", "lag", "streaming"] + }, + { + "id": "ui-workflow", + "label": "UI / Workflow", + "description": "Interaction design, prompts, confirmations, and cross-tool user flow improvements.", + "aliases": ["workflow", "ux", "ui", "quality of life", "qol"] + }, + { + "id": "worlds", + "label": "Worlds", + "description": "World-level bootstrap, defaults, map metadata, and multi-world concerns.", + "aliases": ["world", "world bootstrap", "startup"] + }, + { + "id": "website", + "label": "Website", + "description": "The public site, launch presentation, and web-facing pages outside the editor runtime.", + "aliases": ["site", "web", "homepage"] + }, + { + "id": "repo", + "label": "Repo", + "description": "Repository, source control, and Forgejo-facing workflows.", + "aliases": ["repository", "forgejo", "git"] + }, + { + "id": "wiki", + "label": "Wiki", + "description": "Internal docs, guides, and knowledge-base style content.", + "aliases": ["kb", "knowledge base", "docs", "documentation"] + }, + { + "id": "polish", + "label": "Polish", + "description": "Small fit-and-finish improvements that improve feel, clarity, or consistency.", + "aliases": ["cleanup", "fit and finish", "presentation"] + }, + { + "id": "general", + "label": "General", + "description": "Broad requests that span multiple systems but still sound actionable.", + "aliases": ["broad", "misc", "cross-system"] + }, + { + "id": "other", + "label": "Other", + "description": "Fallback tag for requests that do not match the current standardized catalog.", + "aliases": ["unknown", "unclear", "unsorted"] + } + ] +} diff --git a/scripts/request-analysis-worker.mjs b/scripts/request-analysis-worker.mjs index c2148ed..c98b8b2 100644 --- a/scripts/request-analysis-worker.mjs +++ b/scripts/request-analysis-worker.mjs @@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const kbRoot = path.join(repoRoot, "docs", "kb"); +const requestTagCatalogPath = path.join(kbRoot, "tags.json"); const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash"; const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER @@ -231,6 +232,48 @@ function uniqueStrings(values) { return next; } +function normalizeRequestTagLookupValue(value) { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); +} + +async function loadRequestTagCatalog() { + const payload = JSON.parse(await fs.readFile(requestTagCatalogPath, "utf8")); + const tags = Array.isArray(payload?.tags) + ? payload.tags + .map((entry) => ({ + id: String(entry?.id || "").trim(), + label: String(entry?.label || "").trim(), + aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [], + })) + .filter((entry) => entry.id && entry.label) + : []; + if (tags.length === 0) { + throw new Error("Request tag catalog did not contain any valid tags."); + } + return tags; +} + +function buildRequestTagLookup(tagDefinitions) { + return new Map( + (Array.isArray(tagDefinitions) ? tagDefinitions : []).flatMap((entry) => [ + [normalizeRequestTagLookupValue(entry.label), entry.label], + ...(Array.isArray(entry.aliases) ? entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]) : []), + ]), + ); +} + +function normalizeRequestTags(values, tagLookup, fallback = []) { + const normalized = uniqueStrings(Array.isArray(values) ? values : []) + .map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "") + .filter(Boolean); + if (normalized.length > 0) { + return uniqueStrings(normalized); + } + return uniqueStrings(Array.isArray(fallback) ? fallback : []) + .map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "") + .filter(Boolean); +} + function clampConfidence(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { @@ -264,7 +307,7 @@ function buildFallbackTitle(text, fallback = "Pending request") { return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence; } -function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = []) { +function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = [], kb = null) { const source = rawItem && typeof rawItem === "object" && !Array.isArray(rawItem) ? rawItem : null; @@ -281,15 +324,21 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste const primaryCategory = String(source.primaryCategory || source.category || "").trim() || String(relevantSystems[0]?.name || "Unsorted"); const affectedSystems = uniqueStrings(source.affectedSystems); - const defaultTags = uniqueStrings([ + const defaultTags = [ primaryCategory, ...affectedSystems, - ]); - const tags = uniqueStrings(Array.isArray(source.tags) ? source.tags : defaultTags); + ]; + const tags = normalizeRequestTags( + Array.isArray(source.tags) ? source.tags : defaultTags, + kb?.requestTagLookup || new Map(), + defaultTags, + ); + const reviewRationale = String(source.reviewRationale || source.reviewReason || source.notes || "").trim(); + const reviewOptions = uniqueStrings(source.reviewOptions); return { title, primaryCategory, - tags: tags.length > 0 ? tags : ["Unsorted"], + tags: tags.length > 0 ? tags : ["General"], statusRecommendation: normalizeItemStatusRecommendation(source.statusRecommendation || source.status), parsedInterpretation, implementationApproach, @@ -298,11 +347,13 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste problemType: normalizeProblemType(source.problemType), rawExcerpt: String(source.rawExcerpt || "").trim(), confidence: clampConfidence(source.confidence), + reviewRationale, + reviewOptions, notes: String(source.notes || "").trim(), }; } -function normalizeAnalysisResult(rawResult, request, relevantSystems) { +function normalizeAnalysisResult(rawResult, request, relevantSystems, kb = null) { const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult) ? rawResult : (Array.isArray(rawResult) ? { items: rawResult } : null); @@ -310,7 +361,7 @@ function normalizeAnalysisResult(rawResult, request, relevantSystems) { throw new Error("Model response was not a JSON object."); } const items = (Array.isArray(source.items) ? source.items : []) - .map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems)) + .map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems, kb)) .filter(Boolean); if (items.length === 0) { throw new Error("Model response did not contain any valid request items."); @@ -336,6 +387,7 @@ async function loadKnowledgeBase() { const requestSchemaPath = path.join(kbRoot, "request-analysis-schema.json"); const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8")); const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8")); + const requestTagDefinitions = await loadRequestTagCatalog(); const docsById = new Map(); for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) { const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep)); @@ -345,6 +397,8 @@ async function loadKnowledgeBase() { return { systemsIndex, requestSchema, + requestTagDefinitions, + requestTagLookup: buildRequestTagLookup(requestTagDefinitions), docsById, }; } @@ -400,8 +454,11 @@ function pickRelevantSystems(kb, requestText, limit = 4) { return ranked.slice(0, limit); } -function buildPrompt(request, relevantSystems, schema) { +function buildPrompt(request, relevantSystems, schema, kb) { const schemaSummary = JSON.stringify(schema, null, 2); + const tagCatalogSummary = Array.isArray(kb?.requestTagDefinitions) + ? kb.requestTagDefinitions.map((entry) => `- ${entry.label}: ${entry.description || ""}`.trim()).join("\n") + : ""; const systemDocs = relevantSystems.map(({ system, docText }) => { return [ `System: ${system.name}`, @@ -419,6 +476,8 @@ function buildPrompt(request, relevantSystems, schema) { "You are processing Worldshaper editor requests.", "Split a submission into one or more atomic requests.", "Ground your decisions in the provided KB systems only.", + "Use only the standardized tags listed in the provided tag catalog.", + "Do not expose or simulate hidden chain-of-thought. Provide short structured review rationale instead.", "Return only valid JSON.", "Do not wrap the JSON in markdown fences.", "If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".", @@ -434,6 +493,9 @@ function buildPrompt(request, relevantSystems, schema) { "Return JSON matching this schema:", schemaSummary, "", + "Standardized tags you may use:", + tagCatalogSummary, + "", "Relevant KB systems:", systemDocs, ].join("\n"), @@ -599,9 +661,9 @@ async function analyzeRequest(config, kb, request) { if (!config.dryRun) { await markRequestProcessing(config, request); } - const prompt = buildPrompt(request, relevantSystems, kb.requestSchema); + const prompt = buildPrompt(request, relevantSystems, kb.requestSchema, kb); const modelResult = await callModelApi(config, prompt); - const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system)); + const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system), kb); const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review"; console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`); if (config.dryRun) { diff --git a/server.js b/server.js index e44af27..bf8d222 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,33 @@ const app = express(); const port = Number(process.env.PORT) || 5180; const host = process.env.HOST || "0.0.0.0"; const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim(); +const DEFAULT_REQUEST_TAG_DEFINITIONS = [ + { id: "launcher", label: "Launcher", aliases: ["launcher home", "home page", "landing page", "main page"] }, + { id: "request-board", label: "Request Board", aliases: ["requests", "request queue", "moderation", "request intake"] }, + { id: "chunks", label: "Chunks", aliases: ["chunk", "chunk loading", "chunk storage", "chunk overview"] }, + { id: "layers", label: "Layers", aliases: ["layer", "height layers", "z layers"] }, + { id: "tiling", label: "Tiling", aliases: ["tile", "tile editing", "tile painting", "brush"] }, + { id: "graphics-painter", label: "Graphics Painter", aliases: ["graphic painter", "tile art", "sprite painter", "art editor"] }, + { id: "rendering", label: "Rendering", aliases: ["renderer", "viewport", "pixi", "draw"] }, + { id: "animation", label: "Animation", aliases: ["animated", "timeline", "frame playback"] }, + { id: "content", label: "Content", aliases: ["assets", "catalog", "tiles", "sprites", "images"] }, + { id: "world-overview", label: "World Overview", aliases: ["overview", "world map", "bookmarks", "poi"] }, + { id: "windows", label: "Windows", aliases: ["windowing", "popout", "tool windows", "floating window"] }, + { id: "persistence", label: "Persistence", aliases: ["saving", "save pipeline", "history", "undo", "redo"] }, + { id: "performance", label: "Performance", aliases: ["optimization", "slow", "lag", "streaming"] }, + { id: "ui-workflow", label: "UI / Workflow", aliases: ["workflow", "ux", "ui", "quality of life", "qol", "tools", "tooling"] }, + { id: "worlds", label: "Worlds", aliases: ["world", "world bootstrap", "startup", "world systems"] }, + { id: "website", label: "Website", aliases: ["site", "web", "homepage"] }, + { id: "repo", label: "Repo", aliases: ["repository", "forgejo", "git"] }, + { id: "wiki", label: "Wiki", aliases: ["kb", "knowledge base", "docs", "documentation"] }, + { id: "polish", label: "Polish", aliases: ["cleanup", "fit and finish", "presentation"] }, + { id: "general", label: "General", aliases: ["broad", "misc", "cross-system"] }, + { id: "other", label: "Other", aliases: ["unknown", "unclear", "unsorted"] }, +]; + +function normalizeRequestTagLookupValue(value) { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); +} function isLauncherAdminProtectionEnabled() { return Boolean(launcherAdminPassword); @@ -73,6 +100,7 @@ const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json"); const editorSettingsPath = path.join(dataRoot, "editor_settings.json"); const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json"); const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs"); +const requestTagCatalogPath = path.join(__dirname, "docs", "kb", "tags.json"); const requestAnalysisRunState = { child: null, restartTimer: null, @@ -81,6 +109,17 @@ const imagesCatalogPath = path.join(contentRoot, "images.json"); const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json"); const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json"); const recentSaveEvents = []; +const REQUEST_TAG_DEFINITIONS = loadRequestTagCatalog(); +const REQUEST_TAG_LABELS = REQUEST_TAG_DEFINITIONS.map((entry) => entry.label); +const REQUEST_TAG_LOOKUP = new Map( + REQUEST_TAG_DEFINITIONS.flatMap((entry) => { + const normalizedLabel = normalizeRequestTagLookupValue(entry.label); + return [ + [normalizedLabel, entry.label], + ...entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]), + ]; + }), +); const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure"; const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]); const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = { @@ -417,8 +456,8 @@ function normalizeLauncherRequestStatus(value) { function normalizeLauncherRequestTags(value) { return normalizeUniqueStringList(value, { - normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(), - dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(), + normalizeValue: (entry) => normalizeRequestTag(entry), + dedupeKey: (entry) => normalizeRequestTag(entry).toLowerCase(), }); } @@ -445,6 +484,14 @@ function normalizeLauncherRequestAnalysisStringList(value) { }); } +function normalizeLauncherRequestAnalysisTags(value, fallback = []) { + const normalized = normalizeLauncherRequestTags(Array.isArray(value) ? value : []); + if (normalized.length > 0) { + return normalized; + } + return normalizeLauncherRequestTags(Array.isArray(fallback) ? fallback : []); +} + function normalizeLauncherRequestAnalysisItem(item, index = 0) { const source = item && typeof item === "object" && !Array.isArray(item) ? item @@ -455,9 +502,11 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) { const fallbackTitle = `Analyzed request ${index + 1}`; const title = String(source.title || "").trim() || fallbackTitle; const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted"; - const tags = normalizeLauncherRequestAnalysisStringList(source.tags); + const tags = normalizeLauncherRequestAnalysisTags(source.tags, [primaryCategory]); const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim(); const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim(); + const reviewRationale = String(source.reviewRationale || source.reviewReason || "").trim(); + const reviewOptions = normalizeLauncherRequestAnalysisStringList(source.reviewOptions); const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase(); const statusRecommendation = statusRecommendationRaw === "active" || statusRecommendationRaw === "duplicate" @@ -480,7 +529,7 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) { return { title, primaryCategory, - tags, + tags: tags.length > 0 ? tags : ["General"], statusRecommendation, parsedInterpretation, implementationApproach, @@ -489,6 +538,8 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) { problemType, rawExcerpt: String(source.rawExcerpt || "").trim(), confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence), + reviewRationale, + reviewOptions, notes: String(source.notes || "").trim(), }; } @@ -2166,6 +2217,37 @@ function normalizeUniqueStringList(value, options = {}) { return normalized; } +function loadRequestTagCatalog() { + try { + const payload = JSON.parse(fs.readFileSync(requestTagCatalogPath, "utf8")); + const tags = Array.isArray(payload?.tags) + ? payload.tags + .map((entry) => ({ + id: String(entry?.id || "").trim(), + label: String(entry?.label || "").trim(), + aliases: Array.isArray(entry?.aliases) + ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) + : [], + })) + .filter((entry) => entry.id && entry.label) + : []; + if (tags.length > 0) { + return tags; + } + } catch { + // Fall back to built-in definitions when the KB file is unavailable. + } + return DEFAULT_REQUEST_TAG_DEFINITIONS; +} + +function normalizeRequestTag(value) { + const normalizedValue = normalizeRequestTagLookupValue(value); + if (!normalizedValue) { + return ""; + } + return REQUEST_TAG_LOOKUP.get(normalizedValue) || ""; +} + function normalizeTagList(value) { if (!Array.isArray(value)) { return []; @@ -2710,6 +2792,13 @@ app.get("/api/debug/recent-saves", (req, res) => { }); }); +app.get("/api/launcher-request-meta", (_req, res) => { + res.json({ + ok: true, + allowedTags: REQUEST_TAG_LABELS, + }); +}); + app.get("/api/launcher-requests", (_req, res) => { try { res.json(readLauncherRequestsPayload()); diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index ad9ff12..ed65ba6 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -53,6 +53,8 @@ type LauncherRequest = { problemType?: string; rawExcerpt?: string; confidence?: number | null; + reviewRationale?: string; + reviewOptions?: string[]; notes?: string; }>; }; @@ -60,6 +62,8 @@ type LauncherRequest = { updatedAt: string; }; +type LauncherRequestAnalysisItem = NonNullable["items"]>[number]; + type LauncherRequestsPayload = { requests?: LauncherRequest[]; }; @@ -95,6 +99,10 @@ type ProcessPendingPayload = { pid?: number; }; +type LauncherRequestMetaPayload = { + allowedTags?: string[]; +}; + type AdminAuthPayload = { ok?: boolean; accessGranted?: boolean; @@ -148,6 +156,108 @@ function isAdminAccessError(error: unknown): boolean { || text.includes("admin access is not configured"); } +function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest { + return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest; +} + +function getPrimaryAnalysisItem(requestEntry: LauncherRequest): LauncherRequestAnalysisItem | null { + const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis?.items : []; + return items.length > 0 ? items[0] : null; +} + +function formatConfidence(value: number | null | undefined): string { + if (!Number.isFinite(Number(value))) { + return "Unscored"; + } + return `${Math.round(Number(value) * 100)}%`; +} + +function escapePopupHtml(value: string): string { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function openReviewDetailsPopup(requestEntry: LauncherRequest): void { + const popup = window.open("", `worldshaper-review-${requestEntry.id}`, "popup=yes,width=840,height=760,resizable=yes,scrollbars=yes"); + if (!popup) { + return; + } + const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : []; + const renderedItems = items.map((item, index) => { + const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : []; + return ` +
+
Review Item ${index + 1}
+

${escapePopupHtml(String(item?.title || `Request ${index + 1}`))}

+
+ ${escapePopupHtml(String(item?.primaryCategory || "Unsorted"))} + ${escapePopupHtml(String(item?.statusRecommendation || "needs_review"))} + ${escapePopupHtml(formatConfidence(item?.confidence))} +
+
+

Review Rationale

+

${escapePopupHtml(String(item?.reviewRationale || "No structured review rationale was returned."))}

+
+
+

Parsed Interpretation

+

${escapePopupHtml(String(item?.parsedInterpretation || ""))}

+
+
+

Implementation Approach

+

${escapePopupHtml(String(item?.implementationApproach || ""))}

+
+
+

Possible Options

+ ${reviewOptions.length > 0 + ? `
    ${reviewOptions.map((option) => `
  • ${escapePopupHtml(String(option || ""))}
  • `).join("")}
` + : "

No structured options were returned.

"} +
+
+

Notes

+

${escapePopupHtml(String(item?.notes || "No extra notes."))}

+
+
+ `; + }).join(""); + popup.document.open(); + popup.document.write(` + + + + Worldshaper Review Details + + + +
+
+

${escapePopupHtml(requestEntry.title)}

+

${escapePopupHtml(requestEntry.sourceText)}

+
+ ${renderedItems || '

No Review Items

This request does not have structured review details yet.

'} +
+ + `); + popup.document.close(); +} + function formatRequestTimestamp(value: string): string { const parsed = Date.parse(String(value || "")); if (!Number.isFinite(parsed)) { @@ -305,6 +415,7 @@ function WorldshaperLauncher() { const [requestSubmitting, setRequestSubmitting] = useState(false); const [requestMutatingId, setRequestMutatingId] = useState(""); const [requestFilter, setRequestFilter] = useState("all"); + const [allowedRequestTags, setAllowedRequestTags] = useState([]); const [expandedRequestIds, setExpandedRequestIds] = useState([]); const [adminPanelOpen, setAdminPanelOpen] = useState(false); const [adminAccessGranted, setAdminAccessGranted] = useState(false); @@ -312,6 +423,9 @@ function WorldshaperLauncher() { const [adminPasswordDraft, setAdminPasswordDraft] = useState(""); const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false); const [adminPasswordError, setAdminPasswordError] = useState(""); + const [selectedAdminRequestId, setSelectedAdminRequestId] = useState(""); + const [adminEditorDraft, setAdminEditorDraft] = useState(null); + const [adminSaving, setAdminSaving] = useState(false); const [recentSaveEvents, setRecentSaveEvents] = useState([]); const [logsLoading, setLogsLoading] = useState(false); const [logsError, setLogsError] = useState(""); @@ -357,6 +471,15 @@ function WorldshaperLauncher() { } } + async function loadRequestMeta(): Promise { + try { + const payload = await fetchJsonOrThrow("/api/launcher-request-meta"); + setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []); + } catch { + setAllowedRequestTags([]); + } + } + async function loadRecentSaveEvents(): Promise { setLogsLoading(true); try { @@ -397,6 +520,7 @@ function WorldshaperLauncher() { useEffect(() => { void loadRequests(); + void loadRequestMeta(); }, []); useEffect(() => { @@ -412,13 +536,15 @@ function WorldshaperLauncher() { } let cancelled = false; const refreshBoard = async (): Promise => { - try { - const payload = await fetchJsonOrThrow("/api/launcher-requests"); - if (!cancelled) { - setRequests(Array.isArray(payload.requests) ? payload.requests : []); + if (!adminPanelOpen) { + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests"); + if (!cancelled) { + setRequests(Array.isArray(payload.requests) ? payload.requests : []); + } + } catch { + // Keep the current list visible during background refresh failures. } - } catch { - // Keep the current list visible during background refresh failures. } if (!adminPanelOpen || !adminAccessGranted || !adminPassword) { return; @@ -443,6 +569,26 @@ function WorldshaperLauncher() { }; }, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]); + useEffect(() => { + if (!adminPanelOpen || !adminAccessGranted) { + return; + } + if (requests.length === 0) { + setSelectedAdminRequestId(""); + setAdminEditorDraft(null); + return; + } + const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId); + if (selectedRequest) { + if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) { + setAdminEditorDraft(cloneLauncherRequest(selectedRequest)); + } + return; + } + setSelectedAdminRequestId(requests[0].id); + setAdminEditorDraft(cloneLauncherRequest(requests[0])); + }, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]); + async function handleLaunch(): Promise { setError(""); const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; @@ -539,6 +685,152 @@ function WorldshaperLauncher() { } } + function handleSelectAdminRequest(requestId: string): void { + const nextRequest = requests.find((entry) => entry.id === requestId); + setSelectedAdminRequestId(requestId); + setAdminEditorDraft(nextRequest ? cloneLauncherRequest(nextRequest) : null); + setAdminNotice(""); + setAdminPasswordError(""); + } + + function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void { + setAdminEditorDraft((current) => (current ? updater(current) : current)); + } + + function updateAdminDraftItem( + itemIndex: number, + updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem, + ): void { + updateAdminDraft((current) => { + const next = cloneLauncherRequest(current); + if (!next.analysis) { + next.analysis = { + state: "needs_review", + items: [], + }; + } + const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : []; + const existingItem = items[itemIndex] || {}; + items[itemIndex] = updater({ + ...existingItem, + tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [], + affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [], + affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [], + reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [], + }); + next.analysis.items = items; + next.analysis.itemCount = items.length; + next.analysis.updatedAt = new Date().toISOString(); + return next; + }); + } + + function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit { + return { + method: "PATCH", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + title: requestEntry.title, + status: requestEntry.status, + category: requestEntry.category, + tags: requestEntry.tags, + sourceText: requestEntry.sourceText, + summary: requestEntry.summary, + implementationNotes: requestEntry.implementationNotes, + analysis: requestEntry.analysis, + }), + }; + } + + async function handleSaveAdminRequest(): Promise { + if (!adminEditorDraft) { + return; + } + setAdminSaving(true); + try { + const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>( + `/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`, + buildAdminSavePayload(adminEditorDraft), + ); + const nextRequests = Array.isArray(payload.requests) ? payload.requests : requests; + setRequests(nextRequests); + const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || payload.request || adminEditorDraft; + setAdminEditorDraft(cloneLauncherRequest(refreshed)); + setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to save admin changes.")); + } finally { + setAdminSaving(false); + } + } + + async function handleApproveAdminRequest(): Promise { + if (!adminEditorDraft) { + return; + } + const nextDraft = cloneLauncherRequest(adminEditorDraft); + if (!nextDraft.analysis) { + setLogsError("This request has no analysis to approve yet."); + return; + } + const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : []; + nextDraft.analysis.items = items.map((item) => ({ + ...item, + statusRecommendation: "active", + })); + nextDraft.analysis.state = "processed"; + nextDraft.analysis.updatedAt = new Date().toISOString(); + setAdminEditorDraft(nextDraft); + setAdminSaving(true); + try { + await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>( + `/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`, + buildAdminSavePayload(nextDraft), + ); + const promotePayload = await fetchJsonOrThrow( + `/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`, + { + method: "POST", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + action: "promote", + analysis: nextDraft.analysis, + }), + }, + ); + const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests : []; + setRequests(nextRequests); + const fallbackSelection = nextRequests[0] || null; + setSelectedAdminRequestId(fallbackSelection?.id || ""); + setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(fallbackSelection) : null); + setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to approve this request.")); + } finally { + setAdminSaving(false); + } + } + function handleToggleExpandedRequest(requestId: string): void { setExpandedRequestIds((current) => ( current.includes(requestId) @@ -622,12 +914,14 @@ function WorldshaperLauncher() { const activeRequestCount = requests.filter((entry) => entry.status === "active").length; const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length; const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length; - const requestTags = Array.from(new Set( - requests - .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) - .map((entry) => String(entry || "").trim()) - .filter(Boolean), - )).sort((a, b) => a.localeCompare(b)); + const requestTags = (allowedRequestTags.length > 0 + ? allowedRequestTags + : Array.from(new Set( + requests + .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) + .map((entry) => String(entry || "").trim()) + .filter(Boolean), + ))).sort((a, b) => a.localeCompare(b)); const filteredRequests = requests.filter((entry) => { if (requestFilter === "status:pending") { return entry.status === "pending"; @@ -881,27 +1175,40 @@ function WorldshaperLauncher() {

Request Management

-
Delete actions live here now.
+
Select a request to review or edit it.
{requests.map((requestEntry) => { const isMutating = requestMutatingId === requestEntry.id; const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state); + const reviewItem = getPrimaryAnalysisItem(requestEntry); + const isSelected = requestEntry.id === selectedAdminRequestId; return ( -
+
handleSelectAdminRequest(requestEntry.id)} + >
{requestEntry.title}
{formatRequestStatusLabel(requestEntry.status)} {requestEntry.category} {analysisState} + {formatConfidence(reviewItem?.confidence ?? requestEntry.analysis?.confidence)} {formatRequestTimestamp(requestEntry.updatedAt)}
+ {reviewItem?.reviewRationale ? ( +
{reviewItem.reviewRationale}
+ ) : null}
+
+
+

Review Editor

+
Edit request fields, review details, and approval state.
+
+ {!adminEditorDraft ? ( +
Select a request from the list to review it.
+ ) : ( +
+
+ + +
+