Refactor launcher and studio modules

This commit is contained in:
Andraxion 2026-06-27 04:36:26 -04:00
parent a20d298be2
commit ec3e0f5138
34 changed files with 10300 additions and 8600 deletions

View file

@ -4,7 +4,7 @@ import ConfigSection from "./components/ConfigSection";
import EditorToolbar from "./components/EditorToolbar";
import StatusFooter from "./components/StatusFooter";
import TopNavTabs from "./components/TopNavTabs";
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
import { openWorldshaperStudioWindow } from "./shared/windowing";
import {
CONFIG_TAB_TO_KEY,
DIALOGUE_NODE_FIELD_ORDER,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,974 @@
// @ts-nocheck
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import { openWorldshaperStudioWindow } from "../shared/windowing";
import launcherBackground from "../../background.png";
import { LauncherAdminPanel } from "./components/LauncherAdminPanel";
import { LauncherLogsModal } from "./components/LauncherLogsModal";
import { LauncherNewsPanel } from "./components/LauncherNewsPanel";
import { LauncherPublicRequestBoard } from "./components/LauncherPublicRequestBoard";
import { useLauncherRequestBoard } from "./useLauncherRequestBoard";
declare const __APP_BUILD__: string;
type WorldDefaultPayload = {
worldId?: string;
world?: {
id?: string;
};
};
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
type BoardTab = "news" | "requests";
type LauncherWindowMode = "public" | "admin";
type LauncherRequestStatus = "pending" | "active" | "implemented";
type AdminDetailTab = "routing" | "analysis";
type LauncherRequestAnalysisRouting = {
summary?: string;
ambiguity?: "low" | "medium" | "high";
matchedTerms?: string[];
suggestedTags?: string[];
suggestedSystems?: string[];
suggestedModules?: string[];
rationale?: string;
possibleDirections?: string[];
kbSections?: string[];
};
type LauncherRequest = {
id: string;
sourceSubmissionId?: string;
title: string;
status: LauncherRequestStatus;
category: string;
tags: string[];
sourceText: string;
summary: string;
implementationNotes: string;
analysis?: {
state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error";
confidence?: number | null;
model?: string;
createdAt?: string;
updatedAt?: string;
error?: string;
submissionId?: string;
sourceTextSnapshot?: string;
routing?: LauncherRequestAnalysisRouting;
itemCount?: number;
items?: Array<{
title?: string;
primaryCategory?: string;
tags?: string[];
statusRecommendation?: string;
parsedInterpretation?: string;
implementationApproach?: string;
affectedSystems?: string[];
affectedFiles?: string[];
problemType?: string;
rawExcerpt?: string;
confidence?: number | null;
reviewRationale?: string;
reviewOptions?: string[];
notes?: string;
}>;
};
createdAt: string;
updatedAt: string;
};
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
type LauncherRequestsPayload = {
requests?: LauncherRequest[];
};
type RecentSaveEvent = {
at?: string;
type?: string;
requestId?: string;
textPreview?: string;
status?: string;
category?: string;
itemCount?: number;
model?: string;
reason?: string;
provider?: string;
pid?: number;
code?: number | null;
signal?: string;
error?: string;
};
type RecentSaveEventsPayload = {
saves?: RecentSaveEvent[];
};
type ProcessPendingPayload = {
ok?: boolean;
launched?: boolean;
reason?: string;
autorunEnabled?: boolean;
configured?: boolean;
queuedPendingCount?: number;
pid?: number;
};
type RequeueAnalysisPayload = {
ok?: boolean;
launched?: boolean;
reason?: string;
request?: LauncherRequest;
requests?: LauncherRequest[];
requestId?: string;
queuedPendingCount?: number;
pid?: number;
};
type LauncherRequestMetaPayload = {
allowedTags?: string[];
};
type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
adminConfigured?: boolean;
error?: string;
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
function readLauncherWindowMode(): LauncherWindowMode {
if (typeof window === "undefined") {
return "public";
}
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get("admin") === "requests" ? "admin" : "public";
}
function normalizeStringList(values: string[]): string[] {
return Array.from(new Set(
values
.map((entry) => String(entry || "").trim())
.filter(Boolean),
)).sort((left, right) => left.localeCompare(right));
}
function appendUniqueString(values: string[], value: string): string[] {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) {
return normalizeStringList(values);
}
return normalizeStringList([...values, normalizedValue]);
}
function removeStringValue(values: string[], value: string): string[] {
const normalizedValue = String(value || "").trim().toLowerCase();
return normalizeStringList(values.filter((entry) => entry.trim().toLowerCase() !== normalizedValue));
}
function toggleStringSelection(current: string[], value: string): string[] {
return current.includes(value)
? current.filter((entry) => entry !== value)
: [...current, value].sort((left, right) => left.localeCompare(right));
}
function normalizeSearchText(value: string): string {
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function extractRoutingTerms(requestEntry: LauncherRequest): string[] {
const tagTerms = requestEntry.tags
.map((entry) => String(entry || "").trim())
.filter(Boolean);
if (tagTerms.length > 0) {
return tagTerms.slice(0, 6);
}
const seen = new Set<string>();
const stopWords = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "have", "need",
"want", "make", "more", "just", "like", "does", "dont", "cannot", "should",
"would", "could", "about", "because", "there", "their", "they", "them", "then",
"than", "over", "under", "your", "while", "where",
]);
const matches = `${requestEntry.title} ${requestEntry.sourceText}`.match(/[A-Za-z][A-Za-z0-9/-]{2,}/g) || [];
return matches
.map((entry) => entry.trim())
.filter((entry) => {
const normalized = entry.toLowerCase();
if (stopWords.has(normalized) || seen.has(normalized)) {
return false;
}
seen.add(normalized);
return true;
})
.slice(0, 6);
}
function buildRoutingSummaryFallback(requestEntry: LauncherRequest, firstItem: LauncherRequestAnalysisItem | null): string {
const normalizedSummary = String(requestEntry.summary || "").trim();
if (normalizedSummary && normalizedSummary !== "Awaiting parsing and categorization.") {
return normalizedSummary;
}
if (firstItem?.parsedInterpretation) {
return String(firstItem.parsedInterpretation).trim();
}
const normalizedSource = String(requestEntry.sourceText || "").replace(/\s+/g, " ").trim();
if (normalizedSource) {
return normalizedSource.length > 220
? `${normalizedSource.slice(0, 217).trim()}...`
: normalizedSource;
}
return "No routing summary has been stored yet.";
}
function buildFallbackRouting(requestEntry: LauncherRequest): LauncherRequestAnalysisRouting {
const firstItem = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items[0] : null;
const routedTags = Array.isArray(firstItem?.tags) && firstItem.tags.length > 0
? firstItem.tags
: requestEntry.tags.length > 0
? requestEntry.tags
: (requestEntry.category && requestEntry.category !== "Unsorted" ? [requestEntry.category] : []);
const likelySystems = Array.isArray(firstItem?.affectedSystems) && firstItem.affectedSystems.length > 0
? firstItem.affectedSystems
: (routedTags.length > 0 ? routedTags : []);
const possibleDirections = Array.isArray(firstItem?.reviewOptions) && firstItem.reviewOptions.length > 0
? firstItem.reviewOptions
: (requestEntry.implementationNotes.trim() ? [requestEntry.implementationNotes.trim()] : []);
return {
summary: buildRoutingSummaryFallback(requestEntry, firstItem),
ambiguity: requestEntry.status === "pending" ? "medium" : "low",
matchedTerms: extractRoutingTerms(requestEntry),
suggestedTags: Array.isArray(routedTags) ? routedTags : [],
suggestedSystems: likelySystems,
suggestedModules: [],
rationale: String(
firstItem?.reviewRationale
|| requestEntry.implementationNotes
|| `Routing was reconstructed from the saved request title, tags, and submission text for "${requestEntry.title}".`
).trim(),
possibleDirections,
kbSections: [],
};
}
function mergeRoutingWithFallback(
requestEntry: LauncherRequest,
existingRouting: LauncherRequestAnalysisRouting | undefined,
): LauncherRequestAnalysisRouting {
const fallbackRouting = buildFallbackRouting(requestEntry);
const normalizedRouting = existingRouting || {};
return {
summary: String(normalizedRouting.summary || "").trim() || fallbackRouting.summary,
ambiguity: normalizedRouting.ambiguity || fallbackRouting.ambiguity,
matchedTerms: Array.isArray(normalizedRouting.matchedTerms) && normalizedRouting.matchedTerms.length > 0
? normalizedRouting.matchedTerms
: fallbackRouting.matchedTerms,
suggestedTags: Array.isArray(normalizedRouting.suggestedTags) && normalizedRouting.suggestedTags.length > 0
? normalizedRouting.suggestedTags
: fallbackRouting.suggestedTags,
suggestedSystems: Array.isArray(normalizedRouting.suggestedSystems) && normalizedRouting.suggestedSystems.length > 0
? normalizedRouting.suggestedSystems
: fallbackRouting.suggestedSystems,
suggestedModules: Array.isArray(normalizedRouting.suggestedModules) && normalizedRouting.suggestedModules.length > 0
? normalizedRouting.suggestedModules
: fallbackRouting.suggestedModules,
rationale: String(normalizedRouting.rationale || "").trim() || fallbackRouting.rationale,
possibleDirections: Array.isArray(normalizedRouting.possibleDirections) && normalizedRouting.possibleDirections.length > 0
? normalizedRouting.possibleDirections
: fallbackRouting.possibleDirections,
kbSections: Array.isArray(normalizedRouting.kbSections) && normalizedRouting.kbSections.length > 0
? normalizedRouting.kbSections
: fallbackRouting.kbSections,
};
}
function hydrateLauncherRequestForUi(requestEntry: LauncherRequest): LauncherRequest {
const nextRequest = cloneLauncherRequest(requestEntry);
nextRequest.analysis = {
...(nextRequest.analysis || {}),
createdAt: nextRequest.analysis?.createdAt || nextRequest.createdAt,
updatedAt: nextRequest.analysis?.updatedAt || nextRequest.updatedAt,
itemCount: nextRequest.analysis?.itemCount ?? (Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis?.items.length : 0),
items: Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis.items : [],
routing: mergeRoutingWithFallback(nextRequest, nextRequest.analysis?.routing),
};
return nextRequest;
}
function buildRequestSearchCorpus(requestEntry: LauncherRequest): string {
return normalizeSearchText([
requestEntry.title,
requestEntry.category,
requestEntry.tags.join(" "),
requestEntry.sourceText,
requestEntry.summary,
requestEntry.implementationNotes,
requestEntry.analysis?.routing?.summary,
requestEntry.analysis?.routing?.rationale,
...(Array.isArray(requestEntry.analysis?.routing?.matchedTerms) ? requestEntry.analysis?.routing?.matchedTerms : []),
...(Array.isArray(requestEntry.analysis?.items)
? requestEntry.analysis.items.flatMap((item) => [
item.title,
item.primaryCategory,
item.parsedInterpretation,
item.implementationApproach,
...(Array.isArray(item.tags) ? item.tags : []),
])
: []),
].filter(Boolean).join(" "));
}
function matchesRequestFilterToken(requestEntry: LauncherRequest, token: string): boolean {
if (token === "pending") {
return requestEntry.status === "pending";
}
if (token === "queued") {
return isQueuedPendingRequest(requestEntry);
}
if (token === "review") {
return isNeedsReviewRequest(requestEntry);
}
if (token === "active") {
return requestEntry.status === "active";
}
if (token === "implemented") {
return requestEntry.status === "implemented";
}
return false;
}
function requestMatchesFilters(
requestEntry: LauncherRequest,
searchText: string,
statusSelections: string[],
tagSelections: string[],
): boolean {
const normalizedSearchText = normalizeSearchText(searchText);
if (normalizedSearchText && !buildRequestSearchCorpus(requestEntry).includes(normalizedSearchText)) {
return false;
}
if (statusSelections.length > 0 && !statusSelections.some((token) => matchesRequestFilterToken(requestEntry, token))) {
return false;
}
if (tagSelections.length > 0 && !tagSelections.some((tag) => requestEntry.tags.includes(tag))) {
return false;
}
return true;
}
function FilterIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function LogsIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 9h8M8 12h8M8 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function SaveIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M8 4h7v5H8zM8 14h8v5H8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12.5l4.2 4.2L19 7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 6l10 6-10 6z" fill="currentColor" />
</svg>
);
}
type LauncherChipSelectorProps = {
label: string;
values: string[];
options: string[];
placeholder: string;
emptyLabel?: string;
onAdd: (value: string) => void;
onRemove: (value: string) => void;
};
function LauncherChipSelector({
label,
values,
options,
placeholder,
emptyLabel = "No tags selected yet.",
onAdd,
onRemove,
}: LauncherChipSelectorProps) {
const availableOptions = options.filter((option) => !values.includes(option));
return (
<div className="launcher-chip-field">
<div className="launcher-chip-field-head">
<span className="launcher-request-filter-label">{label}</span>
<select
className="launcher-request-filter-select"
value=""
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) {
return;
}
onAdd(nextValue);
}}
disabled={availableOptions.length === 0}
>
<option value="">{availableOptions.length > 0 ? placeholder : "Everything added"}</option>
{availableOptions.map((option) => (
<option key={`${label}-${option}`} value={option}>{option}</option>
))}
</select>
</div>
<div className="launcher-chip-list">
{values.length > 0 ? values.map((value) => (
<span key={`${label}-${value}`} className="launcher-chip">
<span>{value}</span>
<button
type="button"
className="launcher-chip-remove"
onClick={() => onRemove(value)}
aria-label={`Remove ${value}`}
title={`Remove ${value}`}
>
x
</button>
</span>
)) : (
<span className="launcher-chip-empty">{emptyLabel}</span>
)}
</div>
</div>
);
}
async function resolveDefaultWorldId(): Promise<string> {
const response = await fetch("/api/world-default");
if (!response.ok) {
throw new Error(`Failed to load default world (${response.status}).`);
}
const payload = await response.json() as WorldDefaultPayload;
const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim();
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
}
async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
let detail = `Request failed (${response.status}).`;
try {
const payload = await response.json() as { error?: string };
detail = String(payload?.error || detail);
} catch {
// Ignore JSON parse failures and fall back to status text.
}
throw new Error(detail);
}
return response.json() as Promise<T>;
}
function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
const normalizedPassword = String(password || "").trim();
if (!normalizedPassword) {
return {
...(headers || {}),
};
}
return {
...(headers || {}),
"x-worldshaper-admin-password": normalizedPassword,
};
}
function isAdminAccessError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("admin access denied")
|| text.includes("admin access is not configured");
}
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
}
function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
return "Saved recently";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(parsed);
}
function formatRequestSubmittedDate(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
return "Recently";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(parsed);
}
function normalizeAnalysisState(value: string | undefined): string {
return String(value || "").trim().toLowerCase();
}
function formatAnalysisStateLabel(value: string | undefined): string {
const normalized = normalizeAnalysisState(value);
if (normalized === "processing") {
return "Processing";
}
if (normalized === "processed") {
return "Processed";
}
if (normalized === "needs_review") {
return "Needs Review";
}
if (normalized === "error") {
return "Error";
}
return "Unprocessed";
}
function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
if (requestEntry.status === "implemented") {
return "Implemented";
}
if (requestEntry.status === "active") {
return "Active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "Queued";
}
if (analysisState === "needs_review") {
return "Needs Review";
}
if (analysisState === "error") {
return "Analysis Error";
}
if (analysisState === "processed") {
return "Reviewed";
}
return formatAnalysisStateLabel(analysisState);
}
function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string {
if (requestEntry.status === "implemented") {
return "implemented";
}
if (requestEntry.status === "active") {
return "active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "queued";
}
if (analysisState === "needs_review") {
return "needs-review";
}
if (analysisState === "error") {
return "error";
}
if (analysisState === "processed") {
return "processed";
}
if (analysisState === "processing") {
return "processing";
}
return "pending";
}
function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing");
}
function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error");
}
function formatEventLabel(event: RecentSaveEvent): string {
switch (String(event.type || "").trim()) {
case "launcher-request-add":
return "Request submitted";
case "launcher-request-delete":
return "Request deleted";
case "launcher-request-update":
return "Request updated";
case "launcher-request-review":
return "Analysis saved for review";
case "launcher-request-promote":
return "Pending request promoted";
case "launcher-request-analysis-error":
return "Analysis failed";
case "launcher-request-analysis-launch":
return "Queue worker launched";
case "launcher-request-analysis-finish":
return "Queue worker finished";
case "launcher-request-analysis-launch-error":
return "Queue worker launch error";
case "launcher-request-analysis-requeue":
return "Request requeued for review";
default:
return String(event.type || "Event");
}
}
function formatEventDetail(event: RecentSaveEvent): string {
const parts = [
event.requestId ? `Request ${event.requestId}` : "",
event.category ? `Category ${event.category}` : "",
event.status ? `Status ${event.status}` : "",
event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "",
event.provider ? `Provider ${event.provider}` : "",
event.model ? `Model ${event.model}` : "",
event.reason ? `Reason ${event.reason}` : "",
event.pid ? `PID ${event.pid}` : "",
Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "",
event.signal ? `Signal ${event.signal}` : "",
event.error ? String(event.error) : "",
event.textPreview ? `Preview: ${event.textPreview}` : "",
].filter(Boolean);
return parts.join(" • ");
}
function openStudioPopup(worldId: string): boolean {
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
return Boolean(popup);
}
function openRepo(): void {
window.location.assign("https://repo.andraxion.net/");
}
function openAdminPanelWindow(): boolean {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set("admin", "requests");
nextUrl.searchParams.set("tab", "requests");
const popup = window.open(nextUrl.toString(), "worldshaper-admin-panel", "popup=yes,width=1620,height=980,resizable=yes,scrollbars=yes");
if (popup) {
popup.focus();
}
return Boolean(popup);
}
function WorldshaperLauncher() {
const launcherWindowMode = readLauncherWindowMode();
const adminWindowMode = launcherWindowMode === "admin";
const [launchState, setLaunchState] = useState<LaunchState>("ready");
const [error, setError] = useState("");
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
const {
adminPanelOpen,
activeBoardTab,
setActiveBoardTab,
requests,
requestsLoading,
requestsError,
requestDraftOpen,
setRequestDraftOpen,
requestDraft,
setRequestDraft,
requestSubmitting,
requestMutatingId,
requestSearchText,
setRequestSearchText,
requestFilterMenuOpen,
setRequestFilterMenuOpen,
requestStatusFilters,
setRequestStatusFilters,
requestTagFilters,
setRequestTagFilters,
expandedRequestIds,
adminAccessGranted,
adminPasswordDraft,
setAdminPasswordDraft,
adminAuthSubmitting,
adminPasswordError,
selectedAdminRequestId,
selectedAdminAnalysisIndex,
setSelectedAdminAnalysisIndex,
adminSearchText,
setAdminSearchText,
adminFilterMenuOpen,
setAdminFilterMenuOpen,
adminStatusFilters,
setAdminStatusFilters,
adminTagFilters,
setAdminTagFilters,
adminEditorDraft,
adminDetailTab,
setAdminDetailTab,
adminSaving,
recentSaveEvents,
logsLoading,
logsModalOpen,
setLogsModalOpen,
logsError,
queueTriggering,
requeueingMode,
adminNotice,
refreshAdminData,
loadRecentSaveEvents,
handleAddRequest,
handleAdminPanelToggle,
handleAdminUnlock,
handleSelectAdminRequest,
updateAdminDraft,
updateAdminDraftItem,
handleSaveAdminRequest,
handleApproveAdminRequest,
handleRequeueAnalysis,
handleToggleExpandedRequest,
handleDeleteRequest,
handleProcessPendingQueue,
requestCount,
pendingRequestCount,
activeRequestCount,
implementedRequestCount,
queuedPendingRequestCount,
needsReviewRequestCount,
requestTagFilterOptions,
requestStatusFilterOptions,
filteredRequests,
adminFilteredRequests,
selectedAnalysisItem,
standardizedTagOptions,
categoryOptions,
boardTitle,
boardHint,
} = useLauncherRequestBoard({ adminWindowMode });
useEffect(() => {
let cancelled = false;
void resolveDefaultWorldId()
.then((resolvedWorldId) => {
if (cancelled) {
return;
}
setWorldId(resolvedWorldId);
})
.catch(() => {
if (cancelled) {
return;
}
setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
});
return () => {
cancelled = true;
};
}, []);
async function handleLaunch(): Promise<void> {
setError("");
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
setLaunchState("opening");
try {
const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
setWorldId(resolvedWorldId);
if (openStudioPopup(resolvedWorldId)) {
setLaunchState("opened");
return;
}
setLaunchState("blocked");
} catch (nextError: unknown) {
const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio.");
setLaunchState("error");
setError(nextErrorText);
}
}
const isBusy = launchState === "opening";
return (
<main
className={`launcher-shell ${adminWindowMode ? "launcher-shell-admin" : ""}`}
style={{ "--launcher-background-image": `url(${launcherBackground})` } as CSSProperties}
>
<div className={`launcher-stack ${adminWindowMode ? "launcher-stack-admin" : ""}`}>
{!adminWindowMode ? (
<section className="launcher-hero-window" aria-labelledby="launcher-studio-title">
<div className="launcher-hero-body">
<div className="launcher-hero-stack">
<div className="launcher-title-bubble">
<h1 className="launcher-title" id="launcher-studio-title">Worldshaper Studio</h1>
</div>
<div className="launcher-actions launcher-actions-floating">
<button type="button" className="launcher-primary-btn" onClick={() => void handleLaunch()} disabled={isBusy}>
Launch
</button>
<button type="button" className="launcher-secondary-btn" onClick={openRepo} disabled={isBusy}>
Open Repo
</button>
</div>
{launchState === "blocked" ? <p className="launcher-error">Popup blocked. Allow popups, then press Launch again.</p> : null}
{error ? <p className="launcher-error">{error}</p> : null}
</div>
</div>
</section>
) : null}
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
<div className="launcher-changelog-titlebar">
<div className="launcher-changelog-title" id="launcher-board-title">{boardTitle}</div>
<div className="launcher-changelog-hint">{boardHint}</div>
</div>
<div className="launcher-changelog-body">
<div className="launcher-board-content">
{!adminWindowMode ? (
<div className="launcher-board-tabs">
<button
type="button"
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "news" ? "is-active" : ""}`}
onClick={() => setActiveBoardTab("news")}
>
News
</button>
<button
type="button"
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "requests" ? "is-active" : ""}`}
onClick={() => setActiveBoardTab("requests")}
>
Requests
</button>
</div>
) : null}
{activeBoardTab === "news" ? (
<LauncherNewsPanel />
) : (
adminPanelOpen ? (
<LauncherAdminPanel
adminAccessGranted={adminAccessGranted}
adminPasswordDraft={adminPasswordDraft}
setAdminPasswordDraft={setAdminPasswordDraft}
handleAdminUnlock={handleAdminUnlock}
adminAuthSubmitting={adminAuthSubmitting}
adminPasswordError={adminPasswordError}
handleProcessPendingQueue={handleProcessPendingQueue}
queueTriggering={queueTriggering}
refreshAdminData={refreshAdminData}
logsLoading={logsLoading}
setLogsModalOpen={setLogsModalOpen}
loadRecentSaveEvents={loadRecentSaveEvents}
queuedPendingRequestCount={queuedPendingRequestCount}
needsReviewRequestCount={needsReviewRequestCount}
pendingRequestCount={pendingRequestCount}
activeRequestCount={activeRequestCount}
implementedRequestCount={implementedRequestCount}
adminNotice={adminNotice}
logsError={logsError}
adminSearchText={adminSearchText}
setAdminSearchText={setAdminSearchText}
adminFilterMenuOpen={adminFilterMenuOpen}
setAdminFilterMenuOpen={setAdminFilterMenuOpen}
requestStatusFilterOptions={requestStatusFilterOptions}
adminStatusFilters={adminStatusFilters}
setAdminStatusFilters={setAdminStatusFilters}
requestTagFilterOptions={requestTagFilterOptions}
adminTagFilters={adminTagFilters}
setAdminTagFilters={setAdminTagFilters}
toggleStringSelection={toggleStringSelection}
requestsLoading={requestsLoading}
adminFilteredRequests={adminFilteredRequests}
requestMutatingId={requestMutatingId}
selectedAdminRequestId={selectedAdminRequestId}
handleSelectAdminRequest={handleSelectAdminRequest}
handleDeleteRequest={handleDeleteRequest}
adminEditorDraft={adminEditorDraft}
adminDetailTab={adminDetailTab}
setAdminDetailTab={setAdminDetailTab}
selectedAnalysisItem={selectedAnalysisItem}
selectedAdminAnalysisIndex={selectedAdminAnalysisIndex}
setSelectedAdminAnalysisIndex={setSelectedAdminAnalysisIndex}
updateAdminDraft={updateAdminDraft}
updateAdminDraftItem={updateAdminDraftItem}
standardizedTagOptions={standardizedTagOptions}
categoryOptions={categoryOptions}
handleSaveAdminRequest={handleSaveAdminRequest}
adminSaving={adminSaving}
requeueingMode={requeueingMode}
handleApproveAdminRequest={handleApproveAdminRequest}
handleRequeueAnalysis={handleRequeueAnalysis}
/>
) : (
<LauncherPublicRequestBoard
requestCount={requestCount}
queuedPendingRequestCount={queuedPendingRequestCount}
needsReviewRequestCount={needsReviewRequestCount}
activeRequestCount={activeRequestCount}
implementedRequestCount={implementedRequestCount}
requestDraftOpen={requestDraftOpen}
requestSubmitting={requestSubmitting}
requestSearchText={requestSearchText}
requestFilterMenuOpen={requestFilterMenuOpen}
requestStatusFilterOptions={requestStatusFilterOptions}
requestTagFilterOptions={requestTagFilterOptions}
requestStatusFilters={requestStatusFilters}
requestTagFilters={requestTagFilters}
requestsLoading={requestsLoading}
requests={requests}
filteredRequests={filteredRequests}
expandedRequestIds={expandedRequestIds}
setRequestDraftOpen={setRequestDraftOpen}
handleAdminPanelToggle={handleAdminPanelToggle}
setRequestSearchText={setRequestSearchText}
setRequestFilterMenuOpen={setRequestFilterMenuOpen}
setRequestStatusFilters={setRequestStatusFilters}
setRequestTagFilters={setRequestTagFilters}
toggleStringSelection={toggleStringSelection}
handleToggleExpandedRequest={handleToggleExpandedRequest}
requestDraft={requestDraft}
setRequestDraft={setRequestDraft}
handleAddRequest={handleAddRequest}
/>
)
)}
{requestsError ? (
<p className="launcher-request-error">{requestsError}</p>
) : null}
</div>
</div>
</section>
<div className="launcher-build-stamp">Build {__APP_BUILD__}</div>
</div>
{adminPanelOpen && logsModalOpen ? (
<LauncherLogsModal
logsLoading={logsLoading}
recentSaveEvents={recentSaveEvents}
setLogsModalOpen={setLogsModalOpen}
/>
) : null}
</main>
);
}
export default WorldshaperLauncher;

View file

@ -0,0 +1,39 @@
export type ChangelogItem = string | {
text: string;
note?: string;
};
export type ChangelogSection = {
title: string;
items: ReadonlyArray<ChangelogItem>;
};
export const CHANGELOG_SPLASH_VERSION = "2026-06-26-launcher-presentation-update";
export const CHANGELOG_SPLASH_KICKER = "Launch Experience Update";
export const CHANGELOG_SPLASH_TITLE = "What's New";
export const CHANGELOG_SPLASH_FOOTNOTE = "This release focuses on presentation, access, and a cleaner studio handoff.";
export const CHANGELOG_SECTIONS: ReadonlyArray<ChangelogSection> = [
{
title: "Studio Launch Experience",
items: [
"Worldshaper now opens from a dedicated launch page built to frame the studio instead of burying it behind a utility screen.",
"The editor now launches only in its slim floating window, keeping the first impression focused on the intended workspace.",
"The launch page now opens with an editor showcase backdrop that sets the tone before you step inside.",
],
},
{
title: "Project Access",
items: [
"Added a direct Repo destination from the launcher, making project browsing and source access part of the front door.",
"Release highlights now live on the main page, so returning creators can catch up before jumping back into the world.",
],
},
{
title: "Presentation & Structure",
items: [
"The launcher now gives the studio controls and release notes their own stage, mirroring the feel of the in-editor update window.",
"The entry flow has been tightened into a cleaner, more cinematic handoff from main page to creation space.",
],
},
];

View file

@ -0,0 +1,687 @@
// @ts-nocheck
import { LauncherChipSelector, CheckIcon, FilterIcon, LogsIcon, PlayIcon, SaveIcon, appendUniqueString, formatRequestTimestamp, getRequestDisplayStateClassName, getRequestDisplayStateLabel, removeStringValue } from "../utils";
export function LauncherAdminPanel({
adminAccessGranted,
adminPasswordDraft,
setAdminPasswordDraft,
handleAdminUnlock,
adminAuthSubmitting,
adminPasswordError,
handleProcessPendingQueue,
queueTriggering,
refreshAdminData,
logsLoading,
setLogsModalOpen,
loadRecentSaveEvents,
queuedPendingRequestCount,
needsReviewRequestCount,
pendingRequestCount,
activeRequestCount,
implementedRequestCount,
adminNotice,
logsError,
adminSearchText,
setAdminSearchText,
adminFilterMenuOpen,
setAdminFilterMenuOpen,
requestStatusFilterOptions,
adminStatusFilters,
setAdminStatusFilters,
requestTagFilterOptions,
adminTagFilters,
setAdminTagFilters,
toggleStringSelection,
requestsLoading,
adminFilteredRequests,
requestMutatingId,
selectedAdminRequestId,
handleSelectAdminRequest,
handleDeleteRequest,
adminEditorDraft,
adminDetailTab,
setAdminDetailTab,
selectedAnalysisItem,
selectedAdminAnalysisIndex,
setSelectedAdminAnalysisIndex,
updateAdminDraft,
updateAdminDraftItem,
standardizedTagOptions,
categoryOptions,
handleSaveAdminRequest,
adminSaving,
requeueingMode,
handleApproveAdminRequest,
handleRequeueAnalysis,
}) {
return (
<section className="launcher-request-admin-panel">
{!adminAccessGranted ? (
<div className="launcher-request-admin-unlock">
<div className="launcher-request-admin-kicker">Protected Tools</div>
<h3 className="launcher-request-admin-title">Admin Access Required</h3>
<p className="launcher-request-admin-copy">
Enter the admin password to manage deletions, run the queue worker, and read request logs.
</p>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Password</span>
<input
type="password"
className="launcher-request-filter-select"
value={adminPasswordDraft}
onChange={(event) => setAdminPasswordDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAdminUnlock();
}
}}
placeholder="Enter admin password"
/>
</label>
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleAdminUnlock()}
disabled={adminAuthSubmitting}
>
{adminAuthSubmitting ? "Unlocking..." : "Unlock Admin Panel"}
</button>
</div>
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
</div>
) : (
<>
<div className="launcher-request-admin-toolbar">
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleProcessPendingQueue()}
disabled={queueTriggering}
>
{queueTriggering ? "Starting Queue..." : "Run Pending Queue"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => void refreshAdminData({ includeLogs: true, silentRequests: true })}
disabled={logsLoading}
>
{logsLoading ? "Refreshing..." : "Refresh Admin Data"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => {
setLogsModalOpen(true);
void loadRecentSaveEvents();
}}
disabled={logsLoading}
>
<LogsIcon /> View Logs
</button>
</div>
<div className="launcher-request-admin-stats">
<span>{queuedPendingRequestCount} queued</span>
<span>{needsReviewRequestCount} review</span>
<span>{pendingRequestCount} pending</span>
<span>{activeRequestCount} active</span>
<span>{implementedRequestCount} implemented</span>
</div>
</div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid">
<div className="launcher-request-admin-sidebar">
<section className="launcher-request-admin-card launcher-request-admin-list-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Request Management</h4>
<div className="launcher-request-admin-card-hint">Select a request to load it on the right.</div>
</div>
<div className="launcher-request-filter-bar launcher-request-filter-bar-admin">
<input
type="text"
className="launcher-request-search-input"
value={adminSearchText}
onChange={(event) => setAdminSearchText(event.target.value)}
placeholder="Search requests..."
/>
<div className="launcher-request-filter-menu-wrap">
<button
type="button"
className={`launcher-secondary-btn launcher-request-filter-icon-btn ${adminFilterMenuOpen ? "is-active" : ""}`}
onClick={() => setAdminFilterMenuOpen((current) => !current)}
title="Open request filters"
aria-label="Open request filters"
>
<FilterIcon />
</button>
{adminFilterMenuOpen ? (
<div className="launcher-request-filter-menu launcher-request-filter-menu-admin">
{requestStatusFilterOptions.length > 0 ? (
<div className="launcher-request-filter-group">
<div className="launcher-request-filter-group-title">Status</div>
{requestStatusFilterOptions.map((option) => (
<label key={`admin-status-${option.id}`} className="launcher-request-filter-option">
<input
type="checkbox"
checked={adminStatusFilters.includes(option.id)}
onChange={() => setAdminStatusFilters((current) => toggleStringSelection(current, option.id))}
/>
<span>({option.count}) {option.label}</span>
</label>
))}
</div>
) : null}
{requestTagFilterOptions.length > 0 ? (
<div className="launcher-request-filter-group">
<div className="launcher-request-filter-group-title">Tags</div>
{requestTagFilterOptions.map(({ tag, count }) => (
<label key={`admin-tag-${tag}`} className="launcher-request-filter-option">
<input
type="checkbox"
checked={adminTagFilters.includes(tag)}
onChange={() => setAdminTagFilters((current) => toggleStringSelection(current, tag))}
/>
<span>({count}) {tag}</span>
</label>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
<div className="launcher-request-admin-request-list">
{!requestsLoading && adminFilteredRequests.length === 0 ? (
<div className="launcher-request-empty">No requests match the current search or filters.</div>
) : null}
{adminFilteredRequests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const isSelected = requestEntry.id === selectedAdminRequestId;
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
return (
<article
key={`admin-${requestEntry.id}`}
className={`launcher-request-admin-request-row ${isSelected ? "is-selected" : ""}`}
onClick={() => handleSelectAdminRequest(requestEntry.id)}
>
<div className="launcher-request-admin-request-copy">
<div className="launcher-request-admin-request-headline">
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
{requestDisplayState}
</div>
</div>
<div className="launcher-request-admin-request-meta">
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
</div>
{requestEntry.tags.length > 0 ? (
<div className="launcher-request-tags">
{requestEntry.tags.slice(0, 3).map((tag) => (
<span key={`${requestEntry.id}-${tag}`} className="launcher-request-tag">{tag}</span>
))}
</div>
) : null}
</div>
<button
type="button"
className="launcher-request-delete-btn"
onClick={(event) => {
event.stopPropagation();
void handleDeleteRequest(requestEntry);
}}
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
title="Delete request"
>
X
</button>
</article>
);
})}
</div>
</section>
</div>
<section className="launcher-request-admin-card launcher-request-admin-detail-card">
{!adminEditorDraft ? (
<div className="launcher-request-empty">Select a request from the list to review it.</div>
) : (
<div className="launcher-request-admin-detail">
<div className="launcher-request-admin-detail-top">
<div className="launcher-request-admin-detail-copy">
<div className="launcher-request-admin-kicker">Selected Request</div>
<h4 className="launcher-request-admin-title">{adminEditorDraft.title}</h4>
</div>
<div className="launcher-request-admin-detail-tabs" role="tablist" aria-label="Request detail views">
<button
type="button"
role="tab"
aria-selected={adminDetailTab === "routing"}
className={`launcher-request-admin-tab ${adminDetailTab === "routing" ? "is-active" : ""}`}
onClick={() => setAdminDetailTab("routing")}
>
Routing
</button>
<button
type="button"
role="tab"
aria-selected={adminDetailTab === "analysis"}
className={`launcher-request-admin-tab ${adminDetailTab === "analysis" ? "is-active" : ""}`}
onClick={() => setAdminDetailTab("analysis")}
>
Analysis
</button>
</div>
<div className="launcher-request-admin-detail-controls">
<label className="launcher-request-admin-field launcher-request-admin-field-inline">
<span className="launcher-request-filter-label">Request State</span>
<select
className="launcher-request-filter-select"
value={adminEditorDraft.status}
onChange={(event) => updateAdminDraft((current) => ({ ...current, status: event.target.value }))}
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="implemented">Implemented</option>
</select>
</label>
{(adminEditorDraft.analysis?.items?.length || 0) > 1 ? (
<label className="launcher-request-admin-field launcher-request-admin-field-inline">
<span className="launcher-request-filter-label">Analysis Item</span>
<select
className="launcher-request-filter-select"
value={String(selectedAdminAnalysisIndex)}
onChange={(event) => setSelectedAdminAnalysisIndex(Number(event.target.value) || 0)}
>
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
<option key={`analysis-tab-${itemIndex}`} value={itemIndex}>
{item.title || `Request ${itemIndex + 1}`}
</option>
))}
</select>
</label>
) : null}
<div className="launcher-request-admin-icon-actions">
<button
type="button"
className="launcher-request-admin-icon-btn"
onClick={() => void handleSaveAdminRequest()}
disabled={adminSaving || requeueingMode !== ""}
title="Save all edits to the selected request without changing its review state."
aria-label="Save request"
>
<SaveIcon />
</button>
<button
type="button"
className="launcher-request-admin-icon-btn is-success"
onClick={() => void handleApproveAdminRequest()}
disabled={adminSaving || requeueingMode !== ""}
title="Approve the current reviewed item and promote it into the active request list."
aria-label="Approve request"
>
<CheckIcon />
</button>
<button
type="button"
className="launcher-request-admin-icon-btn is-success"
onClick={() => void handleRequeueAnalysis("draft")}
disabled={adminSaving || requeueingMode !== ""}
title="Submit the current edited draft back through the analyzer for a fresh manual review pass."
aria-label="Manual submission"
>
<PlayIcon />
</button>
</div>
</div>
</div>
<section className="launcher-request-admin-analysis-item launcher-request-admin-tab-panel">
{adminDetailTab === "routing" ? (
<>
<div className="launcher-request-admin-analysis-head">
<div>
<div className="launcher-request-admin-kicker">Routing Pass</div>
<div className="launcher-request-admin-request-title">KB Routing Summary</div>
</div>
</div>
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Category</span>
<select
className="launcher-request-filter-select"
value={String(adminEditorDraft.category || "")}
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
>
<option value="">Select category</option>
{categoryOptions.map((option) => (
<option key={`request-category-${option}`} value={option}>{option}</option>
))}
</select>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Ambiguity</span>
<select
className="launcher-request-filter-select"
value={String(adminEditorDraft.analysis?.routing?.ambiguity || "medium")}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
ambiguity: event.target.value,
},
},
}))}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
</div>
<div className="launcher-request-admin-editor-grid">
<LauncherChipSelector
label="Request Tags"
values={adminEditorDraft.tags}
options={standardizedTagOptions}
placeholder="Add request tag"
onAdd={(value) => updateAdminDraft((current) => ({ ...current, tags: appendUniqueString(current.tags, value) }))}
onRemove={(value) => updateAdminDraft((current) => ({ ...current, tags: removeStringValue(current.tags, value) }))}
/>
<LauncherChipSelector
label="Suggested Tags"
values={Array.isArray(adminEditorDraft.analysis?.routing?.suggestedTags) ? adminEditorDraft.analysis.routing.suggestedTags : []}
options={standardizedTagOptions}
placeholder="Add suggested tag"
onAdd={(value) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
suggestedTags: appendUniqueString(
Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [],
value,
),
},
},
}))}
onRemove={(value) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
suggestedTags: removeStringValue(
Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [],
value,
),
},
},
}))}
/>
</div>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Routing Summary</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={String(adminEditorDraft.analysis?.routing?.summary || "")}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
summary: event.target.value,
},
},
}))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Routing Rationale</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={String(adminEditorDraft.analysis?.routing?.rationale || "")}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
rationale: event.target.value,
},
},
}))}
/>
</label>
<div className="launcher-request-admin-editor-grid">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Matched Terms</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-xs"
value={Array.isArray(adminEditorDraft.analysis?.routing?.matchedTerms) ? adminEditorDraft.analysis.routing.matchedTerms.join("\n") : ""}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
matchedTerms: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
},
},
}))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Likely Systems</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-xs"
value={Array.isArray(adminEditorDraft.analysis?.routing?.suggestedSystems) ? adminEditorDraft.analysis.routing.suggestedSystems.join("\n") : ""}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
suggestedSystems: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
},
},
}))}
/>
</label>
</div>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Possible Directions</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={Array.isArray(adminEditorDraft.analysis?.routing?.possibleDirections) ? adminEditorDraft.analysis.routing.possibleDirections.join("\n") : ""}
onChange={(event) => updateAdminDraft((current) => ({
...current,
analysis: {
...(current.analysis || {}),
routing: {
...(current.analysis?.routing || {}),
possibleDirections: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
},
},
}))}
/>
</label>
</>
) : selectedAnalysisItem ? (
<>
<div className="launcher-request-admin-analysis-head">
<div>
<div className="launcher-request-admin-kicker">Analysis</div>
<div className="launcher-request-admin-request-title">{selectedAnalysisItem.title || "Structured analysis item"}</div>
</div>
</div>
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Request Title</span>
<input
type="text"
className="launcher-request-filter-select"
value={adminEditorDraft.title}
onChange={(event) => updateAdminDraft((current) => ({ ...current, title: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Item Title</span>
<input
type="text"
className="launcher-request-filter-select"
value={String(selectedAnalysisItem.title || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, title: event.target.value }))}
/>
</label>
</div>
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Primary Category</span>
<select
className="launcher-request-filter-select"
value={String(selectedAnalysisItem.primaryCategory || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
>
<option value="">Select category</option>
{categoryOptions.map((option) => (
<option key={`analysis-category-${option}`} value={option}>{option}</option>
))}
</select>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Problem Type</span>
<select
className="launcher-request-filter-select"
value={String(selectedAnalysisItem.problemType || "unknown")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, problemType: event.target.value }))}
>
<option value="feature">Feature</option>
<option value="bug">Bug</option>
<option value="workflow">Workflow</option>
<option value="performance">Performance</option>
<option value="ux">UX</option>
<option value="content">Content</option>
<option value="unknown">Unknown</option>
</select>
</label>
</div>
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Recommendation</span>
<select
className="launcher-request-filter-select"
value={String(selectedAnalysisItem.statusRecommendation || "needs_review")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
>
<option value="needs_review">Needs Review</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
<option value="duplicate">Duplicate</option>
</select>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Confidence</span>
<input
type="number"
min="0"
max="1"
step="0.01"
className="launcher-request-filter-select"
value={Number.isFinite(Number(selectedAnalysisItem.confidence)) ? String(selectedAnalysisItem.confidence) : ""}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
...current,
confidence: event.target.value === "" ? null : Number(event.target.value),
}))}
/>
</label>
</div>
<LauncherChipSelector
label="Standardized Tags"
values={Array.isArray(selectedAnalysisItem.tags) ? selectedAnalysisItem.tags : []}
options={standardizedTagOptions}
placeholder="Add standardized tag"
onAdd={(value) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
...current,
tags: appendUniqueString(Array.isArray(current.tags) ? current.tags : [], value),
}))}
onRemove={(value) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
...current,
tags: removeStringValue(Array.isArray(current.tags) ? current.tags : [], value),
}))}
/>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Original Submission</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={adminEditorDraft.sourceText}
onChange={(event) => updateAdminDraft((current) => ({ ...current, sourceText: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Parsed Interpretation</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={String(selectedAnalysisItem.parsedInterpretation || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Implementation Approach</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-sm"
value={String(selectedAnalysisItem.implementationApproach || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
/>
</label>
<div className="launcher-request-admin-editor-grid">
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Review Rationale</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-xs"
value={String(selectedAnalysisItem.reviewRationale || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
/>
</label>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Possible Options</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-xs"
value={Array.isArray(selectedAnalysisItem.reviewOptions) ? selectedAnalysisItem.reviewOptions.join("\n") : ""}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
...current,
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
}))}
/>
</label>
</div>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Notes</span>
<textarea
className="launcher-request-textarea launcher-request-admin-textarea-xs"
value={String(selectedAnalysisItem.notes || "")}
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, notes: event.target.value }))}
/>
</label>
</>
) : (
<div className="launcher-request-empty">This request does not have a structured analysis item yet.</div>
)}
</section>
</div>
)}
</section>
</div>
</>
)}
</section>
);
}

View file

@ -0,0 +1,49 @@
// @ts-nocheck
import { formatEventDetail, formatEventLabel, formatRequestTimestamp } from "../utils";
export function LauncherLogsModal({
logsLoading,
recentSaveEvents,
setLogsModalOpen,
}) {
return (
<div className="launcher-modal-backdrop" onClick={() => setLogsModalOpen(false)}>
<section
className="launcher-modal launcher-modal-logs"
onClick={(event) => event.stopPropagation()}
aria-label="Request logs"
>
<div className="launcher-modal-head">
<div>
<div className="launcher-request-admin-kicker">Console View</div>
<h3 className="launcher-request-admin-title">Request Logs</h3>
</div>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => setLogsModalOpen(false)}
>
Close
</button>
</div>
<div className="launcher-modal-console">
{logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">Loading admin logs...</div>
) : null}
{!logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
) : null}
{recentSaveEvents.map((eventEntry, index) => (
<article key={`modal-log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-modal-console-row">
<div className="launcher-modal-console-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
<div className="launcher-modal-console-copy">
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
</div>
</article>
))}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,46 @@
// @ts-nocheck
import {
CHANGELOG_SECTIONS,
CHANGELOG_SPLASH_FOOTNOTE,
CHANGELOG_SPLASH_KICKER,
CHANGELOG_SPLASH_TITLE,
CHANGELOG_SPLASH_VERSION,
type ChangelogItem,
} from "../changelogData";
export function LauncherNewsPanel() {
return (
<div className="changelog-splash-card launcher-request-board-card">
<div className="changelog-splash-hero">
<div className="changelog-splash-kicker">{CHANGELOG_SPLASH_KICKER}</div>
<div className="changelog-splash-title" id="launcher-whats-new-title">{CHANGELOG_SPLASH_TITLE}</div>
<div className="changelog-splash-meta">Release {CHANGELOG_SPLASH_VERSION}</div>
</div>
<div className="changelog-splash-list">
{CHANGELOG_SECTIONS.map((section) => (
<section key={section.title} className="changelog-splash-section">
<h3 className="changelog-splash-section-title">{section.title}</h3>
<ul className="changelog-splash-bullets">
{section.items.map((item, index) => {
const key = `${section.title}-${index}`;
const normalizedItem: ChangelogItem = item;
if (typeof normalizedItem === "string") {
return <li key={key}>{normalizedItem}</li>;
}
return (
<li key={key}>
<div>{normalizedItem.text}</div>
{normalizedItem.note ? <div className="changelog-splash-bullet-note">{normalizedItem.note}</div> : null}
</li>
);
})}
</ul>
</section>
))}
</div>
<div className="changelog-splash-footer">
<div className="changelog-splash-footnote">{CHANGELOG_SPLASH_FOOTNOTE}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,234 @@
// @ts-nocheck
import {
FilterIcon,
formatRequestSubmittedDate,
getRequestDisplayStateClassName,
getRequestDisplayStateLabel,
} from "../utils";
export function LauncherPublicRequestBoard({
requestCount,
queuedPendingRequestCount,
needsReviewRequestCount,
activeRequestCount,
implementedRequestCount,
requestDraftOpen,
requestSubmitting,
requestSearchText,
requestFilterMenuOpen,
requestStatusFilterOptions,
requestTagFilterOptions,
requestStatusFilters,
requestTagFilters,
requestsLoading,
requests,
filteredRequests,
expandedRequestIds,
setRequestDraftOpen,
handleAdminPanelToggle,
setRequestSearchText,
setRequestFilterMenuOpen,
setRequestStatusFilters,
setRequestTagFilters,
toggleStringSelection,
handleToggleExpandedRequest,
requestDraft,
setRequestDraft,
handleAddRequest,
}) {
return (
<div className="changelog-splash-card">
<div className="changelog-splash-hero">
<div className="changelog-splash-kicker">Shared Request Board</div>
<div className="changelog-splash-title" id="launcher-requests-title">Requests</div>
<div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, {activeRequestCount} active, and {implementedRequestCount} implemented.
</div>
<div className="launcher-request-hero-actions">
<div className="launcher-request-toolbar-buttons">
<button
type="button"
className="launcher-primary-btn"
onClick={() => {
setRequestDraftOpen((value) => !value);
}}
disabled={requestSubmitting}
>
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => void handleAdminPanelToggle()}
>
Open Admin Window
</button>
</div>
<div className="launcher-request-filter-bar">
<input
type="text"
className="launcher-request-search-input"
value={requestSearchText}
onChange={(event) => setRequestSearchText(event.target.value)}
placeholder="Search requests..."
/>
<div className="launcher-request-filter-menu-wrap">
<button
type="button"
className={`launcher-secondary-btn launcher-request-filter-icon-btn ${requestFilterMenuOpen ? "is-active" : ""}`}
onClick={() => setRequestFilterMenuOpen((current) => !current)}
title="Open request filters"
aria-label="Open request filters"
>
<FilterIcon />
</button>
{requestFilterMenuOpen ? (
<div className="launcher-request-filter-menu">
{requestStatusFilterOptions.length > 0 ? (
<div className="launcher-request-filter-group">
<div className="launcher-request-filter-group-title">Status</div>
{requestStatusFilterOptions.map((option) => (
<label key={`public-status-${option.id}`} className="launcher-request-filter-option">
<input
type="checkbox"
checked={requestStatusFilters.includes(option.id)}
onChange={() => setRequestStatusFilters((current) => toggleStringSelection(current, option.id))}
/>
<span>({option.count}) {option.label}</span>
</label>
))}
</div>
) : null}
{requestTagFilterOptions.length > 0 ? (
<div className="launcher-request-filter-group">
<div className="launcher-request-filter-group-title">Tags</div>
{requestTagFilterOptions.map(({ tag, count }) => (
<label key={`public-tag-${tag}`} className="launcher-request-filter-option">
<input
type="checkbox"
checked={requestTagFilters.includes(tag)}
onChange={() => setRequestTagFilters((current) => toggleStringSelection(current, tag))}
/>
<span>({count}) {tag}</span>
</label>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
</div>
{requestDraftOpen ? (
<section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
What should be added or improved?
</label>
<textarea
id="launcher-request-draft"
className="launcher-request-textarea"
value={requestDraft}
onChange={(event) => setRequestDraft(event.target.value)}
placeholder="Type a request for the board..."
maxLength={1000}
/>
<div className="launcher-request-composer-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleAddRequest()}
disabled={requestSubmitting}
>
{requestSubmitting ? "Saving Request..." : "Save Request"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => {
setRequestDraft("");
setRequestDraftOpen(false);
}}
disabled={requestSubmitting}
>
Cancel
</button>
</div>
</section>
) : null}
<div className="launcher-request-list">
{requestsLoading ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">Loading saved requests...</div>
</section>
) : null}
{!requestsLoading && filteredRequests.length === 0 ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">
{requests.length === 0
? "No requests yet. Add the first one to start the board."
: "No requests match the current filter."}
</div>
</section>
) : null}
{!requestsLoading ? filteredRequests.map((requestEntry) => {
const isExpanded = expandedRequestIds.includes(requestEntry.id);
const isActiveRequest = requestEntry.status === "active" || requestEntry.status === "implemented";
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
return (
<section
key={requestEntry.id}
className={`launcher-request-entry is-${requestEntry.status} ${isExpanded ? "is-expanded" : ""} ${isActiveRequest ? "is-clickable" : ""}`}
onClick={isActiveRequest ? () => handleToggleExpandedRequest(requestEntry.id) : undefined}
>
<div className="launcher-request-entry-head">
<div className="launcher-request-entry-head-main">
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
{requestDisplayState}
</div>
<div className="launcher-request-entry-title-block">
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
</div>
</div>
<div className="launcher-request-entry-date" title={`Submitted ${formatRequestSubmittedDate(requestEntry.createdAt)}`}>
{formatRequestSubmittedDate(requestEntry.createdAt)}
</div>
</div>
{requestEntry.tags.length > 0 ? (
<div className="launcher-request-tags">
{requestEntry.tags.map((tag) => (
<span key={`${requestEntry.id}-${tag}`} className="launcher-request-tag">{tag}</span>
))}
</div>
) : null}
{isActiveRequest && isExpanded ? (
<div className="launcher-request-expanded">
<div className="launcher-request-expanded-block">
<div className="launcher-request-expanded-label">Parsed interpretation</div>
<p className="launcher-request-expanded-copy">{requestEntry.summary}</p>
</div>
<div className="launcher-request-expanded-block">
<div className="launcher-request-expanded-label">How we could do that</div>
<p className="launcher-request-expanded-copy">{requestEntry.implementationNotes}</p>
</div>
{requestEntry.sourceText ? (
<div className="launcher-request-expanded-block">
<div className="launcher-request-expanded-label">Original submission</div>
<p className="launcher-request-expanded-copy">{requestEntry.sourceText}</p>
</div>
) : null}
</div>
) : null}
</section>
);
}) : null}
</div>
<div className="changelog-splash-footer">
<div className="changelog-splash-footnote">
Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs stay behind protected admin access.
</div>
</div>
</div>
);
}

122
src/launcher/requestApi.ts Normal file
View file

@ -0,0 +1,122 @@
// @ts-nocheck
import { fetchJsonOrThrow, buildAdminHeaders } from "./utils";
export async function loadLauncherRequests() {
return fetchJsonOrThrow("/api/launcher-requests");
}
export async function loadLauncherRequestMeta() {
return fetchJsonOrThrow("/api/launcher-request-meta");
}
export async function createLauncherRequest(text) {
return fetchJsonOrThrow("/api/launcher-requests", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
}
export async function loadLauncherRecentSaveEvents(adminPassword) {
return fetchJsonOrThrow("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
}
export async function verifyLauncherAdminPassword(password) {
const payload = await fetchJsonOrThrow("/api/admin/auth-check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
if (!payload.accessGranted) {
throw new Error(String(payload.error || "Admin access denied."));
}
return payload;
}
export function buildLauncherAdminSavePayload(adminPassword, requestEntry) {
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,
}),
};
}
export async function saveLauncherAdminRequest(adminPassword, requestEntry) {
return fetchJsonOrThrow(
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`,
buildLauncherAdminSavePayload(adminPassword, requestEntry),
);
}
export async function promoteLauncherAdminRequest(adminPassword, requestEntry) {
return fetchJsonOrThrow(
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}/process-analysis`,
{
method: "POST",
headers: buildAdminHeaders(adminPassword, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
action: "promote",
analysis: requestEntry.analysis,
}),
},
);
}
export async function requeueLauncherAdminRequest(adminPassword, requestEntry, mode) {
return fetchJsonOrThrow(
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}/requeue-analysis`,
{
method: "POST",
headers: buildAdminHeaders(adminPassword, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
mode,
request: mode === "draft"
? {
title: requestEntry.title,
category: requestEntry.category,
tags: requestEntry.tags,
sourceText: requestEntry.sourceText,
summary: requestEntry.summary,
implementationNotes: requestEntry.implementationNotes,
}
: undefined,
}),
},
);
}
export async function deleteLauncherRequest(adminPassword, requestId) {
return fetchJsonOrThrow(`/api/launcher-requests/${encodeURIComponent(requestId)}`, {
method: "DELETE",
headers: buildAdminHeaders(adminPassword),
});
}
export async function triggerLauncherPendingQueue(adminPassword) {
return fetchJsonOrThrow("/api/launcher-requests/process-pending", {
method: "POST",
headers: buildAdminHeaders(adminPassword),
});
}

127
src/launcher/types.ts Normal file
View file

@ -0,0 +1,127 @@
export type WorldDefaultPayload = {
worldId?: string;
world?: {
id?: string;
};
};
export type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
export type BoardTab = "news" | "requests";
export type LauncherWindowMode = "public" | "admin";
export type LauncherRequestStatus = "pending" | "active" | "implemented";
export type AdminDetailTab = "routing" | "analysis";
export type LauncherRequestAnalysisRouting = {
summary?: string;
ambiguity?: "low" | "medium" | "high";
matchedTerms?: string[];
suggestedTags?: string[];
suggestedSystems?: string[];
suggestedModules?: string[];
rationale?: string;
possibleDirections?: string[];
kbSections?: string[];
};
export type LauncherRequestAnalysisItem = {
title?: string;
primaryCategory?: string;
tags?: string[];
statusRecommendation?: string;
parsedInterpretation?: string;
implementationApproach?: string;
affectedSystems?: string[];
affectedFiles?: string[];
problemType?: string;
rawExcerpt?: string;
confidence?: number | null;
reviewRationale?: string;
reviewOptions?: string[];
notes?: string;
};
export type LauncherRequest = {
id: string;
sourceSubmissionId?: string;
title: string;
status: LauncherRequestStatus;
category: string;
tags: string[];
sourceText: string;
summary: string;
implementationNotes: string;
analysis?: {
state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error";
confidence?: number | null;
model?: string;
createdAt?: string;
updatedAt?: string;
error?: string;
submissionId?: string;
sourceTextSnapshot?: string;
routing?: LauncherRequestAnalysisRouting;
itemCount?: number;
items?: LauncherRequestAnalysisItem[];
};
createdAt: string;
updatedAt: string;
};
export type LauncherRequestsPayload = {
requests?: LauncherRequest[];
};
export type RecentSaveEvent = {
at?: string;
type?: string;
requestId?: string;
textPreview?: string;
status?: string;
category?: string;
itemCount?: number;
model?: string;
reason?: string;
provider?: string;
pid?: number;
code?: number | null;
signal?: string;
error?: string;
};
export type RecentSaveEventsPayload = {
saves?: RecentSaveEvent[];
};
export type ProcessPendingPayload = {
ok?: boolean;
launched?: boolean;
reason?: string;
autorunEnabled?: boolean;
configured?: boolean;
queuedPendingCount?: number;
pid?: number;
};
export type RequeueAnalysisPayload = {
ok?: boolean;
launched?: boolean;
reason?: string;
request?: LauncherRequest;
requests?: LauncherRequest[];
requestId?: string;
queuedPendingCount?: number;
pid?: number;
};
export type LauncherRequestMetaPayload = {
allowedTags?: string[];
};
export type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
adminConfigured?: boolean;
error?: string;
};
export const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";

View file

@ -0,0 +1,641 @@
// @ts-nocheck
import { useEffect, useState } from "react";
import type {
AdminDetailTab,
BoardTab,
LauncherRequest,
LauncherRequestAnalysisItem,
} from "./types";
import {
createLauncherRequest,
deleteLauncherRequest,
loadLauncherRecentSaveEvents,
loadLauncherRequestMeta,
loadLauncherRequests,
promoteLauncherAdminRequest,
requeueLauncherAdminRequest,
saveLauncherAdminRequest,
triggerLauncherPendingQueue,
verifyLauncherAdminPassword,
} from "./requestApi";
import {
appendUniqueString,
cloneLauncherRequest,
hydrateLauncherRequestForUi,
isAdminAccessError,
isNeedsReviewRequest,
isQueuedPendingRequest,
normalizeStringList,
openAdminPanelWindow,
removeStringValue,
requestMatchesFilters,
} from "./utils";
export function useLauncherRequestBoard({ adminWindowMode }) {
const adminPanelOpen = adminWindowMode;
const [activeBoardTab, setActiveBoardTab] = useState<BoardTab>(adminWindowMode ? "requests" : "news");
const [requests, setRequests] = useState<LauncherRequest[]>([]);
const [requestsLoading, setRequestsLoading] = useState(true);
const [requestsError, setRequestsError] = useState("");
const [requestDraftOpen, setRequestDraftOpen] = useState(false);
const [requestDraft, setRequestDraft] = useState("");
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [requestMutatingId, setRequestMutatingId] = useState("");
const [requestSearchText, setRequestSearchText] = useState("");
const [requestFilterMenuOpen, setRequestFilterMenuOpen] = useState(false);
const [requestStatusFilters, setRequestStatusFilters] = useState<string[]>([]);
const [requestTagFilters, setRequestTagFilters] = useState<string[]>([]);
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
const [adminPassword, setAdminPassword] = useState("");
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
const [adminPasswordError, setAdminPasswordError] = useState("");
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
const [selectedAdminAnalysisIndex, setSelectedAdminAnalysisIndex] = useState(0);
const [adminSearchText, setAdminSearchText] = useState("");
const [adminFilterMenuOpen, setAdminFilterMenuOpen] = useState(false);
const [adminStatusFilters, setAdminStatusFilters] = useState<string[]>([]);
const [adminTagFilters, setAdminTagFilters] = useState<string[]>([]);
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
const [adminDetailTab, setAdminDetailTab] = useState<AdminDetailTab>("routing");
const [adminSaving, setAdminSaving] = useState(false);
const [recentSaveEvents, setRecentSaveEvents] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsModalOpen, setLogsModalOpen] = useState(false);
const [logsError, setLogsError] = useState("");
const [queueTriggering, setQueueTriggering] = useState(false);
const [requeueingMode, setRequeueingMode] = useState<"" | "saved" | "draft">("");
const [adminNotice, setAdminNotice] = useState("");
async function loadRequests(options?: { silent?: boolean }): Promise<void> {
const silent = options?.silent === true;
if (!silent) {
setRequestsLoading(true);
}
try {
const payload = await loadLauncherRequests();
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
setRequestsError("");
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to load requests."));
} finally {
if (!silent) {
setRequestsLoading(false);
}
}
}
async function loadRequestMeta(): Promise<void> {
try {
const payload = await loadLauncherRequestMeta();
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
} catch {
setAllowedRequestTags([]);
}
}
async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true);
try {
const payload = await loadLauncherRecentSaveEvents(adminPassword);
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
setLogsError("");
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
}
setLogsError(String(nextError || "Failed to load admin logs."));
} finally {
setLogsLoading(false);
}
}
async function verifyAdminPassword(password: string): Promise<void> {
await verifyLauncherAdminPassword(password);
}
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
await loadRequests({ silent: options?.silentRequests === true });
if (options?.includeLogs && adminAccessGranted && adminPassword) {
await loadRecentSaveEvents();
}
}
useEffect(() => {
void loadRequests();
void loadRequestMeta();
}, []);
useEffect(() => {
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
void loadRecentSaveEvents();
}, [adminPanelOpen, adminAccessGranted, adminPassword]);
useEffect(() => {
if (activeBoardTab !== "requests") {
return;
}
let cancelled = false;
const refreshBoard = async (): Promise<void> => {
if (!adminPanelOpen) {
try {
const payload = await loadLauncherRequests();
if (!cancelled) {
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
}
} catch {
// Keep the current list visible during background refresh failures.
}
}
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
try {
const payload = await loadLauncherRecentSaveEvents(adminPassword);
if (!cancelled) {
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
}
} catch {
// Avoid surfacing noisy polling failures in the admin panel.
}
};
const intervalId = window.setInterval(() => {
void refreshBoard();
}, 15000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [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(hydrateLauncherRequestForUi(selectedRequest)));
}
return;
}
setSelectedAdminRequestId(requests[0].id);
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(requests[0])));
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
useEffect(() => {
setSelectedAdminAnalysisIndex(0);
}, [selectedAdminRequestId]);
useEffect(() => {
setAdminDetailTab("routing");
}, [selectedAdminRequestId]);
async function handleAddRequest(): Promise<void> {
const text = requestDraft.trim();
if (!text) {
setRequestsError("Write a request before saving it.");
return;
}
setRequestSubmitting(true);
try {
const payload = await createLauncherRequest(text);
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
setRequestDraft("");
setRequestDraftOpen(false);
setRequestsError("");
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
if (adminPanelOpen && adminAccessGranted) {
void loadRecentSaveEvents();
}
window.setTimeout(() => {
void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
}, 3500);
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to save request."));
} finally {
setRequestSubmitting(false);
}
}
async function handleAdminPanelToggle(): Promise<void> {
setRequestDraftOpen(false);
setRequestsError("");
setLogsError("");
if (adminWindowMode) {
return;
}
if (!openAdminPanelWindow()) {
setAdminNotice("Allow popups to open the admin review window.");
}
}
async function handleAdminUnlock(): Promise<void> {
const submittedPassword = adminPasswordDraft.trim();
if (!submittedPassword) {
setAdminPasswordError("Enter the admin password to continue.");
return;
}
setAdminAuthSubmitting(true);
setAdminPasswordError("");
try {
await verifyAdminPassword(submittedPassword);
setAdminPassword(submittedPassword);
setAdminAccessGranted(true);
setAdminNotice("Admin access granted.");
await refreshAdminData({ includeLogs: true, silentRequests: true });
} catch (nextError: unknown) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError(String(nextError || "Failed to unlock the admin panel."));
} finally {
setAdminAuthSubmitting(false);
}
}
function handleSelectAdminRequest(requestId: string): void {
const nextRequest = requests.find((entry) => entry.id === requestId);
setSelectedAdminRequestId(requestId);
setSelectedAdminAnalysisIndex(0);
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(hydrateLauncherRequestForUi(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;
});
}
async function handleSaveAdminRequest(): Promise<void> {
if (!adminEditorDraft) {
return;
}
setAdminSaving(true);
try {
const payload = await saveLauncherAdminRequest(adminPassword, adminEditorDraft);
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
setRequests(nextRequests);
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(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<void> {
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 : [];
if (items.length === 0) {
setLogsError("This request does not have a structured analysis item to approve yet.");
return;
}
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 saveLauncherAdminRequest(adminPassword, nextDraft);
const promotePayload = await promoteLauncherAdminRequest(adminPassword, nextDraft);
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests.map(hydrateLauncherRequestForUi) : [];
setRequests(nextRequests);
const fallbackSelection = nextRequests[0] || null;
setSelectedAdminRequestId(fallbackSelection?.id || "");
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(hydrateLauncherRequestForUi(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);
}
}
async function handleRequeueAnalysis(mode: "saved" | "draft"): Promise<void> {
if (!adminEditorDraft) {
return;
}
setRequeueingMode(mode);
setLogsError("");
try {
const payload = await requeueLauncherAdminRequest(adminPassword, adminEditorDraft, mode);
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
setRequests(nextRequests);
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed)));
if (payload.launched) {
setAdminNotice(mode === "draft"
? "Edited draft resubmitted to the analyzer."
: "Saved request resubmitted to the analyzer.");
} else {
const reason = String(payload.reason || "no-op");
if (reason === "request-analysis-already-running") {
setAdminNotice("The queue worker is already running. This request will be picked up on the next pass.");
} else if (reason === "request-not-queued") {
setAdminNotice("That request is not currently eligible for review reruns.");
} else {
setAdminNotice(`Review rerun returned: ${reason}.`);
}
}
await refreshAdminData({ includeLogs: true, silentRequests: true });
window.setTimeout(() => {
void refreshAdminData({ includeLogs: true, silentRequests: true });
}, 4200);
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to requeue this request for review."));
} finally {
setRequeueingMode("");
}
}
function handleToggleExpandedRequest(requestId: string): void {
setExpandedRequestIds((current) => (
current.includes(requestId)
? current.filter((entry) => entry !== requestId)
: [...current, requestId]
));
}
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`);
if (!confirmed) {
return;
}
setRequestMutatingId(requestEntry.id);
try {
const payload = await deleteLauncherRequest(adminPassword, requestEntry.id);
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
setRequestsError("");
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
setAdminNotice(`Deleted request "${requestEntry.title}".`);
if (adminPanelOpen) {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setRequestsError(String(nextError || "Failed to delete request."));
} finally {
setRequestMutatingId("");
}
}
async function handleProcessPendingQueue(): Promise<void> {
setQueueTriggering(true);
try {
const payload = await triggerLauncherPendingQueue(adminPassword);
if (payload.launched) {
setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`);
} else {
const reason = String(payload.reason || "no-op");
if (reason === "no-pending-requests") {
setAdminNotice("No unprocessed pending requests are waiting in the queue.");
} else if (reason === "request-analysis-already-running") {
setAdminNotice("The request analysis worker is already running on the VPS.");
} else if (reason === "request-analysis-not-configured") {
setAdminNotice("Request analysis is not configured on the server.");
} else {
setAdminNotice(`Queue trigger returned: ${reason}.`);
}
}
await refreshAdminData({ includeLogs: true, silentRequests: true });
if (payload.launched) {
window.setTimeout(() => {
void refreshAdminData({ includeLogs: true, silentRequests: true });
}, 4200);
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to trigger the queue worker."));
} finally {
setQueueTriggering(false);
}
}
const requestCount = requests.length;
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length;
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
const implementedRequestCount = requests.filter((entry) => entry.status === "implemented").length;
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
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 requestTagFilterOptions = requestTags
.map((tag) => ({
tag,
count: requests.filter((entry) => entry.tags.includes(tag)).length,
}))
.filter((entry) => entry.count > 0);
const requestStatusFilterOptions = [
{ id: "pending", label: "Pending", count: pendingRequestCount },
{ id: "queued", label: "Queued", count: queuedPendingRequestCount },
{ id: "review", label: "Needs Review", count: needsReviewRequestCount },
{ id: "active", label: "Active", count: activeRequestCount },
{ id: "implemented", label: "Implemented", count: implementedRequestCount },
].filter((entry) => entry.count > 0);
const filteredRequests = requests.filter((entry) => requestMatchesFilters(
entry,
requestSearchText,
requestStatusFilters,
requestTagFilters,
));
const adminFilteredRequests = requests.filter((entry) => requestMatchesFilters(
entry,
adminSearchText,
adminStatusFilters,
adminTagFilters,
));
const selectedAnalysisItem = adminEditorDraft?.analysis?.items?.[selectedAdminAnalysisIndex] || null;
const standardizedTagOptions = normalizeStringList([
...allowedRequestTags,
...requestTags,
...requests.flatMap((entry) => [
...entry.tags,
...(Array.isArray(entry.analysis?.routing?.suggestedTags) ? entry.analysis.routing.suggestedTags : []),
...(Array.isArray(entry.analysis?.items) ? entry.analysis.items.flatMap((item) => Array.isArray(item.tags) ? item.tags : []) : []),
]),
...(adminEditorDraft?.tags || []),
...(Array.isArray(adminEditorDraft?.analysis?.routing?.suggestedTags) ? adminEditorDraft.analysis.routing.suggestedTags : []),
...(Array.isArray(selectedAnalysisItem?.tags) ? selectedAnalysisItem.tags : []),
]);
const categoryOptions = normalizeStringList([
...standardizedTagOptions,
...requests.map((entry) => entry.category),
...requests.flatMap((entry) => Array.isArray(entry.analysis?.items) ? entry.analysis.items.map((item) => String(item.primaryCategory || "")) : []),
String(adminEditorDraft?.category || ""),
String(selectedAnalysisItem?.primaryCategory || ""),
]);
const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board";
const boardHint = adminWindowMode
? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`
: (activeBoardTab === "news"
? "Latest announcements"
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`);
return {
adminPanelOpen,
activeBoardTab,
setActiveBoardTab,
requests,
requestsLoading,
requestsError,
requestDraftOpen,
setRequestDraftOpen,
requestDraft,
setRequestDraft,
requestSubmitting,
requestMutatingId,
requestSearchText,
setRequestSearchText,
requestFilterMenuOpen,
setRequestFilterMenuOpen,
requestStatusFilters,
setRequestStatusFilters,
requestTagFilters,
setRequestTagFilters,
allowedRequestTags,
expandedRequestIds,
adminAccessGranted,
adminPassword,
adminPasswordDraft,
setAdminPasswordDraft,
adminAuthSubmitting,
adminPasswordError,
selectedAdminRequestId,
selectedAdminAnalysisIndex,
setSelectedAdminAnalysisIndex,
adminSearchText,
setAdminSearchText,
adminFilterMenuOpen,
setAdminFilterMenuOpen,
adminStatusFilters,
setAdminStatusFilters,
adminTagFilters,
setAdminTagFilters,
adminEditorDraft,
adminDetailTab,
setAdminDetailTab,
adminSaving,
recentSaveEvents,
logsLoading,
logsModalOpen,
setLogsModalOpen,
logsError,
queueTriggering,
requeueingMode,
adminNotice,
refreshAdminData,
loadRecentSaveEvents,
handleAddRequest,
handleAdminPanelToggle,
handleAdminUnlock,
handleSelectAdminRequest,
updateAdminDraft,
updateAdminDraftItem,
handleSaveAdminRequest,
handleApproveAdminRequest,
handleRequeueAnalysis,
handleToggleExpandedRequest,
handleDeleteRequest,
handleProcessPendingQueue,
requestCount,
pendingRequestCount,
activeRequestCount,
implementedRequestCount,
queuedPendingRequestCount,
needsReviewRequestCount,
requestTags,
requestTagFilterOptions,
requestStatusFilterOptions,
filteredRequests,
adminFilteredRequests,
selectedAnalysisItem,
standardizedTagOptions,
categoryOptions,
boardTitle,
boardHint,
};
}

557
src/launcher/utils.tsx Normal file
View file

@ -0,0 +1,557 @@
import { openWorldshaperStudioWindow } from "../shared/windowing";
import type {
AdminAuthPayload,
LauncherRequest,
LauncherRequestAnalysisItem,
LauncherRequestAnalysisRouting,
LauncherRequestStatus,
LauncherWindowMode,
RecentSaveEvent,
WorldDefaultPayload,
} from "./types";
import { DEFAULT_EDITOR_WORLD_ID_FALLBACK } from "./types";
export function readLauncherWindowMode(): LauncherWindowMode {
if (typeof window === "undefined") {
return "public";
}
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get("admin") === "requests" ? "admin" : "public";
}
export function normalizeStringList(values: string[]): string[] {
return Array.from(new Set(
values
.map((entry) => String(entry || "").trim())
.filter(Boolean),
)).sort((left, right) => left.localeCompare(right));
}
export function appendUniqueString(values: string[], value: string): string[] {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) {
return normalizeStringList(values);
}
return normalizeStringList([...values, normalizedValue]);
}
export function removeStringValue(values: string[], value: string): string[] {
const normalizedValue = String(value || "").trim().toLowerCase();
return normalizeStringList(values.filter((entry) => entry.trim().toLowerCase() !== normalizedValue));
}
export function toggleStringSelection(current: string[], value: string): string[] {
return current.includes(value)
? current.filter((entry) => entry !== value)
: [...current, value].sort((left, right) => left.localeCompare(right));
}
export function normalizeSearchText(value: string): string {
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function extractRoutingTerms(requestEntry: LauncherRequest): string[] {
const tagTerms = requestEntry.tags
.map((entry) => String(entry || "").trim())
.filter(Boolean);
if (tagTerms.length > 0) {
return tagTerms.slice(0, 6);
}
const seen = new Set<string>();
const stopWords = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "have", "need",
"want", "make", "more", "just", "like", "does", "dont", "cannot", "should",
"would", "could", "about", "because", "there", "their", "they", "them", "then",
"than", "over", "under", "your", "while", "where",
]);
const matches = `${requestEntry.title} ${requestEntry.sourceText}`.match(/[A-Za-z][A-Za-z0-9/-]{2,}/g) || [];
return matches
.map((entry) => entry.trim())
.filter((entry) => {
const normalized = entry.toLowerCase();
if (stopWords.has(normalized) || seen.has(normalized)) {
return false;
}
seen.add(normalized);
return true;
})
.slice(0, 6);
}
function buildRoutingSummaryFallback(requestEntry: LauncherRequest, firstItem: LauncherRequestAnalysisItem | null): string {
const normalizedSummary = String(requestEntry.summary || "").trim();
if (normalizedSummary && normalizedSummary !== "Awaiting parsing and categorization.") {
return normalizedSummary;
}
if (firstItem?.parsedInterpretation) {
return String(firstItem.parsedInterpretation).trim();
}
const normalizedSource = String(requestEntry.sourceText || "").replace(/\s+/g, " ").trim();
if (normalizedSource) {
return normalizedSource.length > 220
? `${normalizedSource.slice(0, 217).trim()}...`
: normalizedSource;
}
return "No routing summary has been stored yet.";
}
function buildFallbackRouting(requestEntry: LauncherRequest): LauncherRequestAnalysisRouting {
const firstItem = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items[0] : null;
const routedTags = Array.isArray(firstItem?.tags) && firstItem.tags.length > 0
? firstItem.tags
: requestEntry.tags.length > 0
? requestEntry.tags
: (requestEntry.category && requestEntry.category !== "Unsorted" ? [requestEntry.category] : []);
const likelySystems = Array.isArray(firstItem?.affectedSystems) && firstItem.affectedSystems.length > 0
? firstItem.affectedSystems
: (routedTags.length > 0 ? routedTags : []);
const possibleDirections = Array.isArray(firstItem?.reviewOptions) && firstItem.reviewOptions.length > 0
? firstItem.reviewOptions
: (requestEntry.implementationNotes.trim() ? [requestEntry.implementationNotes.trim()] : []);
return {
summary: buildRoutingSummaryFallback(requestEntry, firstItem),
ambiguity: requestEntry.status === "pending" ? "medium" : "low",
matchedTerms: extractRoutingTerms(requestEntry),
suggestedTags: Array.isArray(routedTags) ? routedTags : [],
suggestedSystems: likelySystems,
suggestedModules: [],
rationale: String(
firstItem?.reviewRationale
|| requestEntry.implementationNotes
|| `Routing was reconstructed from the saved request title, tags, and submission text for "${requestEntry.title}".`
).trim(),
possibleDirections,
kbSections: [],
};
}
function mergeRoutingWithFallback(
requestEntry: LauncherRequest,
existingRouting: LauncherRequestAnalysisRouting | undefined,
): LauncherRequestAnalysisRouting {
const fallbackRouting = buildFallbackRouting(requestEntry);
const normalizedRouting = existingRouting || {};
return {
summary: String(normalizedRouting.summary || "").trim() || fallbackRouting.summary,
ambiguity: normalizedRouting.ambiguity || fallbackRouting.ambiguity,
matchedTerms: Array.isArray(normalizedRouting.matchedTerms) && normalizedRouting.matchedTerms.length > 0
? normalizedRouting.matchedTerms
: fallbackRouting.matchedTerms,
suggestedTags: Array.isArray(normalizedRouting.suggestedTags) && normalizedRouting.suggestedTags.length > 0
? normalizedRouting.suggestedTags
: fallbackRouting.suggestedTags,
suggestedSystems: Array.isArray(normalizedRouting.suggestedSystems) && normalizedRouting.suggestedSystems.length > 0
? normalizedRouting.suggestedSystems
: fallbackRouting.suggestedSystems,
suggestedModules: Array.isArray(normalizedRouting.suggestedModules) && normalizedRouting.suggestedModules.length > 0
? normalizedRouting.suggestedModules
: fallbackRouting.suggestedModules,
rationale: String(normalizedRouting.rationale || "").trim() || fallbackRouting.rationale,
possibleDirections: Array.isArray(normalizedRouting.possibleDirections) && normalizedRouting.possibleDirections.length > 0
? normalizedRouting.possibleDirections
: fallbackRouting.possibleDirections,
kbSections: Array.isArray(normalizedRouting.kbSections) && normalizedRouting.kbSections.length > 0
? normalizedRouting.kbSections
: fallbackRouting.kbSections,
};
}
export function hydrateLauncherRequestForUi(requestEntry: LauncherRequest): LauncherRequest {
const nextRequest = cloneLauncherRequest(requestEntry);
nextRequest.analysis = {
...(nextRequest.analysis || {}),
createdAt: nextRequest.analysis?.createdAt || nextRequest.createdAt,
updatedAt: nextRequest.analysis?.updatedAt || nextRequest.updatedAt,
itemCount: nextRequest.analysis?.itemCount ?? (Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis?.items.length : 0),
items: Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis.items : [],
routing: mergeRoutingWithFallback(nextRequest, nextRequest.analysis?.routing),
};
return nextRequest;
}
function buildRequestSearchCorpus(requestEntry: LauncherRequest): string {
return normalizeSearchText([
requestEntry.title,
requestEntry.category,
requestEntry.tags.join(" "),
requestEntry.sourceText,
requestEntry.summary,
requestEntry.implementationNotes,
requestEntry.analysis?.routing?.summary,
requestEntry.analysis?.routing?.rationale,
...(Array.isArray(requestEntry.analysis?.routing?.matchedTerms) ? requestEntry.analysis?.routing?.matchedTerms : []),
...(Array.isArray(requestEntry.analysis?.items)
? requestEntry.analysis.items.flatMap((item) => [
item.title,
item.primaryCategory,
item.parsedInterpretation,
item.implementationApproach,
...(Array.isArray(item.tags) ? item.tags : []),
])
: []),
].filter(Boolean).join(" "));
}
function matchesRequestFilterToken(requestEntry: LauncherRequest, token: string): boolean {
if (token === "pending") {
return requestEntry.status === "pending";
}
if (token === "queued") {
return isQueuedPendingRequest(requestEntry);
}
if (token === "review") {
return isNeedsReviewRequest(requestEntry);
}
if (token === "active") {
return requestEntry.status === "active";
}
if (token === "implemented") {
return requestEntry.status === "implemented";
}
return false;
}
export function requestMatchesFilters(
requestEntry: LauncherRequest,
searchText: string,
statusSelections: string[],
tagSelections: string[],
): boolean {
const normalizedSearchText = normalizeSearchText(searchText);
if (normalizedSearchText && !buildRequestSearchCorpus(requestEntry).includes(normalizedSearchText)) {
return false;
}
if (statusSelections.length > 0 && !statusSelections.some((token) => matchesRequestFilterToken(requestEntry, token))) {
return false;
}
if (tagSelections.length > 0 && !tagSelections.some((tag) => requestEntry.tags.includes(tag))) {
return false;
}
return true;
}
export function FilterIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function LogsIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 9h8M8 12h8M8 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function SaveIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M8 4h7v5H8zM8 14h8v5H8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
export function CheckIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12.5l4.2 4.2L19 7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function PlayIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 6l10 6-10 6z" fill="currentColor" />
</svg>
);
}
type LauncherChipSelectorProps = {
label: string;
values: string[];
options: string[];
placeholder: string;
emptyLabel?: string;
onAdd: (value: string) => void;
onRemove: (value: string) => void;
};
export function LauncherChipSelector({
label,
values,
options,
placeholder,
emptyLabel = "No tags selected yet.",
onAdd,
onRemove,
}: LauncherChipSelectorProps) {
const availableOptions = options.filter((option) => !values.includes(option));
return (
<div className="launcher-chip-field">
<div className="launcher-chip-field-head">
<span className="launcher-request-filter-label">{label}</span>
<select
className="launcher-request-filter-select"
value=""
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) {
return;
}
onAdd(nextValue);
}}
disabled={availableOptions.length === 0}
>
<option value="">{availableOptions.length > 0 ? placeholder : "Everything added"}</option>
{availableOptions.map((option) => (
<option key={`${label}-${option}`} value={option}>{option}</option>
))}
</select>
</div>
<div className="launcher-chip-list">
{values.length > 0 ? values.map((value) => (
<span key={`${label}-${value}`} className="launcher-chip">
<span>{value}</span>
<button
type="button"
className="launcher-chip-remove"
onClick={() => onRemove(value)}
aria-label={`Remove ${value}`}
title={`Remove ${value}`}
>
x
</button>
</span>
)) : (
<span className="launcher-chip-empty">{emptyLabel}</span>
)}
</div>
</div>
);
}
export async function resolveDefaultWorldId(): Promise<string> {
const response = await fetch("/api/world-default");
if (!response.ok) {
throw new Error(`Failed to load default world (${response.status}).`);
}
const payload = await response.json() as WorldDefaultPayload;
const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim();
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
}
export async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
let detail = `Request failed (${response.status}).`;
try {
const payload = await response.json() as { error?: string };
detail = String(payload?.error || detail);
} catch {
// Ignore JSON parse failures and fall back to status text.
}
throw new Error(detail);
}
return response.json() as Promise<T>;
}
export function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
const normalizedPassword = String(password || "").trim();
if (!normalizedPassword) {
return {
...(headers || {}),
};
}
return {
...(headers || {}),
"x-worldshaper-admin-password": normalizedPassword,
};
}
export function isAdminAccessError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("admin access denied")
|| text.includes("admin access is not configured");
}
export function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
}
export function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
return "Saved recently";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(parsed);
}
export function formatRequestSubmittedDate(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
return "Recently";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(parsed);
}
export function normalizeAnalysisState(value: string | undefined): string {
return String(value || "").trim().toLowerCase();
}
export function formatAnalysisStateLabel(value: string | undefined): string {
const normalized = normalizeAnalysisState(value);
if (normalized === "processing") {
return "Processing";
}
if (normalized === "processed") {
return "Processed";
}
if (normalized === "needs_review") {
return "Needs Review";
}
if (normalized === "error") {
return "Error";
}
return "Unprocessed";
}
export function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
if (requestEntry.status === "implemented") {
return "Implemented";
}
if (requestEntry.status === "active") {
return "Active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "Queued";
}
if (analysisState === "needs_review") {
return "Needs Review";
}
if (analysisState === "error") {
return "Analysis Error";
}
if (analysisState === "processed") {
return "Reviewed";
}
return formatAnalysisStateLabel(analysisState);
}
export function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string {
if (requestEntry.status === "implemented") {
return "implemented";
}
if (requestEntry.status === "active") {
return "active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "queued";
}
if (analysisState === "needs_review") {
return "needs-review";
}
if (analysisState === "error") {
return "error";
}
if (analysisState === "processed") {
return "processed";
}
if (analysisState === "processing") {
return "processing";
}
return "pending";
}
export function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing");
}
export function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error");
}
export function formatEventLabel(event: RecentSaveEvent): string {
switch (String(event.type || "").trim()) {
case "launcher-request-add":
return "Request submitted";
case "launcher-request-delete":
return "Request deleted";
case "launcher-request-update":
return "Request updated";
case "launcher-request-review":
return "Analysis saved for review";
case "launcher-request-promote":
return "Pending request promoted";
case "launcher-request-analysis-error":
return "Analysis failed";
case "launcher-request-analysis-launch":
return "Queue worker launched";
case "launcher-request-analysis-finish":
return "Queue worker finished";
case "launcher-request-analysis-launch-error":
return "Queue worker launch error";
case "launcher-request-analysis-requeue":
return "Request requeued for review";
default:
return String(event.type || "Event");
}
}
export function formatEventDetail(event: RecentSaveEvent): string {
const parts = [
event.requestId ? `Request ${event.requestId}` : "",
event.category ? `Category ${event.category}` : "",
event.status ? `Status ${event.status}` : "",
event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "",
event.provider ? `Provider ${event.provider}` : "",
event.model ? `Model ${event.model}` : "",
event.reason ? `Reason ${event.reason}` : "",
event.pid ? `PID ${event.pid}` : "",
Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "",
event.signal ? `Signal ${event.signal}` : "",
event.error ? String(event.error) : "",
event.textPreview ? `Preview: ${event.textPreview}` : "",
].filter(Boolean);
return parts.join(" | ");
}
export function openStudioPopup(worldId: string): boolean {
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
return Boolean(popup);
}
export function openRepo(): void {
window.location.assign("https://repo.andraxion.net/");
}
export function openAdminPanelWindow(): boolean {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set("admin", "requests");
nextUrl.searchParams.set("tab", "requests");
const popup = window.open(nextUrl.toString(), "worldshaper-admin-panel", "popup=yes,width=1620,height=980,resizable=yes,scrollbars=yes");
if (popup) {
popup.focus();
}
return Boolean(popup);
}
export type { AdminAuthPayload, LauncherRequestStatus };

View file

@ -1,7 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import WorldshaperLauncher from './WorldshaperLauncher.tsx'
import WorldshaperLauncher from './launcher/WorldshaperLauncher.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>

210
src/shared/windowing.ts Normal file
View file

@ -0,0 +1,210 @@
export type PopupBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio";
export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds";
export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer";
export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds";
export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin);
const normalizedMapId = String(mapId || "").trim();
const normalizedWorldId = String(options?.worldId || "").trim();
if (normalizedMapId) {
popupUrl.searchParams.set("mapId", normalizedMapId);
}
if (normalizedWorldId) {
popupUrl.searchParams.set("worldId", normalizedWorldId);
}
return popupUrl.toString();
}
export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin);
const normalizedMapId = String(mapId || "").trim();
const normalizedToken = String(token || "").trim();
if (normalizedMapId) {
popupUrl.searchParams.set("mapId", normalizedMapId);
}
if (normalizedToken) {
popupUrl.searchParams.set("token", normalizedToken);
}
return popupUrl.toString();
}
export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
const width = 1360;
const height = 900;
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
? hostWindow.outerWidth
: hostWindow.innerWidth;
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
? hostWindow.outerHeight
: hostWindow.innerHeight;
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
return { left, top, width, height };
}
export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
const width = 1280;
const height = 820;
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
? hostWindow.outerWidth
: hostWindow.innerWidth;
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
? hostWindow.outerHeight
: hostWindow.innerHeight;
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
return { left, top, width, height };
}
export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
const width = Math.max(640, Number(parsed.width) || 0);
const height = Math.max(480, Number(parsed.height) || 0);
const left = Math.max(0, Number(parsed.left) || 0);
const top = Math.max(0, Number(parsed.top) || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
}
export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
const width = Math.max(640, Number(parsed.width) || 0);
const height = Math.max(480, Number(parsed.height) || 0);
const left = Math.max(0, Number(parsed.left) || 0);
const top = Math.max(0, Number(parsed.top) || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
}
export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void {
if (sourceWindow.closed) {
return;
}
try {
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
sourceWindow.localStorage.setItem(
WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void {
if (sourceWindow.closed) {
return;
}
try {
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
sourceWindow.localStorage.setItem(
WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function openWorldshaperStudioWindow(
mapId: string,
hostWindow: Window = window,
options?: { worldId?: string },
): Window | null {
const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options);
const initialBounds = readWorldshaperStudioBounds(hostWindow);
const popupFeatures = [
"popup=yes",
"resizable=yes",
"scrollbars=no",
"width=" + initialBounds.width,
"height=" + initialBounds.height,
"left=" + initialBounds.left,
"top=" + initialBounds.top,
].join(",");
const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures);
if (!popup) {
return null;
}
try {
popup.moveTo(initialBounds.left, initialBounds.top);
popup.resizeTo(initialBounds.width, initialBounds.height);
} catch {
// Ignore browser restrictions.
}
popup.location.href = popupUrl;
popup.focus();
return popup;
}
export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null {
const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow);
const initialBounds = readWorldshaperHeightViewerBounds(hostWindow);
const popupFeatures = [
"popup=yes",
"resizable=yes",
"scrollbars=no",
"width=" + initialBounds.width,
"height=" + initialBounds.height,
"left=" + initialBounds.left,
"top=" + initialBounds.top,
].join(",");
const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures);
if (!popup) {
return null;
}
try {
popup.moveTo(initialBounds.left, initialBounds.top);
popup.resizeTo(initialBounds.width, initialBounds.height);
} catch {
// Ignore browser restrictions.
}
popup.location.href = popupUrl;
popup.focus();
return popup;
}

View file

@ -6,7 +6,7 @@ import {
loadWorldshaperStudioBootstrap,
loadStandaloneWorldshaperBootstrap,
} from "../worldshaperStudio/bootstrap";
import { persistWorldshaperHeightViewerBounds } from "../worldshaperStudio/windowing";
import { persistWorldshaperHeightViewerBounds } from "../shared/windowing";
import { createDebouncedCallback } from "../worldshaperStudio/debounce";
const VIEWER_STYLE_ID = "worldshaper-height-viewer-styles";

View file

@ -1,39 +1 @@
export type ChangelogItem = string | {
text: string;
note?: string;
};
export type ChangelogSection = {
title: string;
items: ReadonlyArray<ChangelogItem>;
};
export const CHANGELOG_SPLASH_VERSION = "2026-06-26-launcher-presentation-update";
export const CHANGELOG_SPLASH_KICKER = "Launch Experience Update";
export const CHANGELOG_SPLASH_TITLE = "What's New";
export const CHANGELOG_SPLASH_FOOTNOTE = "This release focuses on presentation, access, and a cleaner studio handoff.";
export const CHANGELOG_SECTIONS: ReadonlyArray<ChangelogSection> = [
{
title: "Studio Launch Experience",
items: [
"Worldshaper now opens from a dedicated launch page built to frame the studio instead of burying it behind a utility screen.",
"The editor now launches only in its slim floating window, keeping the first impression focused on the intended workspace.",
"The launch page now opens with an editor showcase backdrop that sets the tone before you step inside.",
],
},
{
title: "Project Access",
items: [
"Added a direct Repo destination from the launcher, making project browsing and source access part of the front door.",
"Release highlights now live on the main page, so returning creators can catch up before jumping back into the world.",
],
},
{
title: "Presentation & Structure",
items: [
"The launcher now gives the studio controls and release notes their own stage, mirroring the feel of the in-editor update window.",
"The entry flow has been tightened into a cleaner, more cinematic handoff from main page to creation space.",
],
},
];
export * from "../launcher/changelogData";

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
import { WORLDSHAPER_THEME_PRESETS } from "./themePresets";
import {
WORLDSHAPER_STUDIO_MARKUP_SHELL,
WORLDSHAPER_STUDIO_MARKUP_SIDEBAR,
WORLDSHAPER_STUDIO_MARKUP_STAGE,
} from "./domMarkupSections";
export function buildWorldshaperStudioPopupMarkup(): string {
const themePresetButtons = WORLDSHAPER_THEME_PRESETS.map((preset) => `
<button
class="theme-preset-btn"
data-theme-preset="${preset.id}"
type="button"
title="${preset.label} theme"
aria-label="Switch to ${preset.label} theme"
>
<span
class="theme-preset-swatch"
style="--theme-swatch-a:${preset.swatch[0]}; --theme-swatch-b:${preset.swatch[1]}; --theme-swatch-c:${preset.swatch[2]}; --theme-swatch-d:${preset.swatch[3]};"
></span>
</button>
`).join("");
return (
WORLDSHAPER_STUDIO_MARKUP_SHELL.replace("__THEME_PRESET_BUTTONS__", themePresetButtons)
+ WORLDSHAPER_STUDIO_MARKUP_SIDEBAR
+ WORLDSHAPER_STUDIO_MARKUP_STAGE
);
}
export function getWorldshaperStudioBodyMarkup(): string {
return buildWorldshaperStudioPopupMarkup()
.replace(/^<body>/i, "")
.replace(/<\/body>\s*$/i, "");
}

View file

@ -0,0 +1,475 @@
export const WORLDSHAPER_STUDIO_MARKUP_SHELL = `
<body>
<div class="shell">
<div class="menu-bar" id="menuBar">
<button class="menu-btn" id="undoBtn" type="button">Undo</button>
<button class="menu-btn" id="redoBtn" type="button">Redo</button>
<button class="menu-btn" id="saveBtn" type="button">Save</button>
<button class="menu-btn" id="testHeightBtn" type="button">Test Height</button>
<div class="menu-bar-center">
<label class="menu-layer-label" for="menuLayerSelect">Change Layer:</label>
<select class="menu-layer-select" id="menuLayerSelect"></select>
</div>
<div class="menu-bar-right">
<div class="theme-preset-bar" role="group" aria-label="Editor theme presets">
__THEME_PRESET_BUTTONS__
</div>
<span id="saveStatus" class="save-status" title="Ready" aria-label="Ready">Ready</span>
</div>
</div>
<div class="body" id="editorBody">
`;
export const WORLDSHAPER_STUDIO_MARKUP_SIDEBAR = `
<aside class="sidebar" id="sidebar">
<div class="sidebar-tabs" id="sidebarTabs">
<div class="sidebar-tab-row">
<button class="sidebar-tab-btn" id="informationTabBtn" type="button">Settings</button>
<button class="sidebar-tab-btn" id="historyTabBtn" type="button">History</button>
<button class="sidebar-tab-btn" id="newsTabBtn" type="button">News</button>
</div>
<div class="sidebar-tab-row">
<button class="sidebar-tab-btn" id="instancesTabBtn" type="button">Entities</button>
<button class="sidebar-tab-btn" id="tilesTabBtn" type="button">Graphics</button>
<button class="sidebar-tab-btn active" id="layersTabBtn" type="button">Layers</button>
</div>
<div class="sidebar-tab-row">
<button class="sidebar-tab-btn" id="triggersTabBtn" type="button">Triggers</button>
<button class="sidebar-tab-btn" id="pathsTabBtn" type="button">Paths</button>
<button class="sidebar-tab-btn" id="transitionsTabBtn" type="button">Transitions</button>
</div>
<div class="sidebar-tab-row">
<span></span>
</div>
</div>
<div class="sidebar-panels-host" id="sidebarPanelsHost">
<div class="sidebar-panel hidden" id="informationPanel">
<h3>Settings</h3>
<div class="information-panel-layout">
<div class="selector-section">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleInformationSettingsSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>World Info</span>
</button>
</div>
<div class="selector-section-body" id="informationSettingsSectionBody">
<div class="map-manager">
<div class="field-row">
<label for="mapIdLocked">World Id</label>
<input id="mapIdLocked" class="info-readonly" type="text" readonly />
</div>
<div class="field-row">
<label for="mapNameInput">World Name</label>
<input id="mapNameInput" type="text" />
</div>
<div class="field-row">
<label for="mapWidthInput">Loaded Width</label>
<div class="info-cell-value info-dim-value" id="mapWidthValue">
<input id="mapWidthInput" class="info-dim-input info-readonly" type="number" min="1" max="512" readonly />
<div class="info-dim-controls" id="mapWidthControls">
<button class="icon-action-btn" id="confirmWidthBtn" type="button" title="Apply width change">âÃâœÃ¢â¬Å</button>
<button class="icon-action-btn danger" id="cancelWidthBtn" type="button" title="Discard width change">̢̮â¬â</button>
</div>
</div>
</div>
<div class="field-row">
<label for="mapHeightInput">Loaded Height</label>
<div class="info-cell-value info-dim-value" id="mapHeightValue">
<input id="mapHeightInput" class="info-dim-input info-readonly" type="number" min="1" max="512" readonly />
<div class="info-dim-controls" id="mapHeightControls">
<button class="icon-action-btn" id="confirmHeightBtn" type="button" title="Apply height change">âÃâœÃ¢â¬Å</button>
<button class="icon-action-btn danger" id="cancelHeightBtn" type="button" title="Discard height change">̢̮â¬â</button>
</div>
</div>
</div>
<div class="field-row">
<label for="mapBackgroundColorInput">Background Color</label>
<input id="mapBackgroundColorInput" type="color" />
</div>
</div>
</div>
<div class="selector-section">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleInformationConfigurationSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>Configuration</span>
</button>
</div>
<div class="selector-section-body" id="informationConfigurationSectionBody">
<div class="map-manager">
<div class="field-row">
<label for="engineOverridesBtn">Engine Overrides</label>
<div>
<button id="engineOverridesBtn" class="mini-btn engine-overrides-launch-btn" type="button">Open Override Manager</button>
<div class="engine-overrides-summary" id="engineOverridesSummary">No engine overrides active.</div>
</div>
</div>
<div class="field-row">
<label for="backgroundModeBtn">Background Brush</label>
<button id="backgroundModeBtn" class="background-mode-btn" type="button">
<span class="background-mode-preview" id="backgroundModePreview"></span>
<span class="background-mode-copy">
<span class="background-mode-title" id="backgroundModeTitle">Inherit</span>
<span class="background-mode-meta" id="backgroundModeMeta">Click to cycle tile, hole, inherit.</span>
</span>
</button>
</div>
</div>
<div class="information-utility-actions">
<button class="mini-btn" id="restoreToolWindowsBtn" type="button">Restore all windows</button>
<button class="mini-btn" id="resetWorkspaceLayoutBtn" type="button">Reset workspace layout</button>
</div>
<div class="experimental-import-panel">
<button id="experimentalImportToggleBtn" class="experimental-import-toggle" type="button" aria-expanded="false">
<span class="experimental-import-check" id="experimentalImportCheck">[ ]</span>
<span class="experimental-import-copy">
<span class="experimental-import-title">Experimental Imports</span>
<span class="experimental-import-meta">Import compatible sprite or tile JSON from other editor builds.</span>
</span>
<span class="experimental-import-chevron">&#9662;</span>
</button>
<div class="experimental-import-body hidden" id="experimentalImportBody">
<div class="experimental-import-warning">Warning: supports a single entry or a full <code>sprites.json</code> / <code>tiles.json</code> gallery. Matching art is deduped and only new images are kept.</div>
<div class="experimental-import-actions">
<button class="mini-btn" id="importSpritesBtn" type="button">Import Sprites</button>
<button class="mini-btn" id="importTilesBtn" type="button">Import Tiles</button>
<button class="mini-btn experimental-import-icon-btn" id="importJsonBtn" type="button" title="Paste JSON import" aria-label="Paste JSON import">&#128221;</button>
</div>
<input id="importSpritesInput" class="hidden" type="file" accept=".json,application/json" />
<input id="importTilesInput" class="hidden" type="file" accept=".json,application/json" />
</div>
</div>
</div>
</div>
<div class="experimental-import-modal hidden" id="importJsonModal">
<div class="experimental-import-modal-card" role="dialog" aria-modal="true" aria-labelledby="importJsonModalTitle">
<div class="experimental-import-modal-head">
<h4 class="experimental-import-modal-title" id="importJsonModalTitle">Paste Import JSON</h4>
<select id="importJsonTypeSelect">
<option value="sprites">Sprites</option>
<option value="tiles">Tiles</option>
</select>
</div>
<div class="experimental-import-modal-copy">Paste a single entry or a full compatible gallery payload, then confirm to import only new art.</div>
<div class="experimental-import-modal-body">
<textarea id="importJsonTextarea" rows="12" spellcheck="false" placeholder="{ &quot;tiles&quot;: [ ... ] }"></textarea>
</div>
<div class="experimental-import-modal-actions">
<button class="mini-btn" id="importJsonConfirmBtn" type="button" title="Import JSON">&#10003;</button>
<button class="mini-btn danger" id="importJsonCancelBtn" type="button" title="Cancel import">&#10005;</button>
</div>
</div>
</div>
<div class="information-bottom-stack">
<div class="selector-section">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleInformationHotkeysSectionBtn" type="button" aria-expanded="false">
<span class="selector-section-chevron">&#9662;</span>
<span>Hotkeys</span>
</button>
</div>
<div class="selector-section-body hidden" id="informationHotkeysSectionBody">
<div class="info-help-panel" aria-label="Worldshaper Studio keyboard help">
<div class="info-help-title">Editor Controls</div>
<div class="info-help-list">
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Ctrl</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Wheel</span>
</span>
</div>
<div class="shortcut-action">Zoom canvas</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">MMB</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Drag</span>
</span>
</div>
<div class="shortcut-action">Pan the room view</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">L Shift</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Drag</span>
</span>
</div>
<div class="shortcut-action">Straight line draw</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Alt</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Drag</span>
</span>
</div>
<div class="shortcut-action">Erase on active layer</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">L Ctrl</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Drag</span>
</span>
</div>
<div class="shortcut-action"><span class="shortcut-shape-icon square" aria-hidden="true"></span><span>Rectangle outline</span></div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">R Ctrl</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-mouse-shell">
<span class="shortcut-mouse-dot"></span>
<span class="shortcut-mouse-label">Drag</span>
</span>
</div>
<div class="shortcut-action"><span class="shortcut-shape-icon circle" aria-hidden="true"></span><span>Circle outline</span></div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">R Shift</span>
</div>
<div class="shortcut-action">Hold to hide the grid</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">M</span>
</div>
<div class="shortcut-action">Open world overview</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">O</span>
</div>
<div class="shortcut-action">Toggle chunk bounds</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Ctrl</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-keycap">Z</span>
</div>
<div class="shortcut-action">Undo</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Ctrl</span>
<span class="shortcut-plus">+</span>
<span class="shortcut-keycap">Y</span>
</div>
<div class="shortcut-action">Redo</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Escape</span>
</div>
<div class="shortcut-action">Clear canvas selection / collapse cells</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">
<span class="shortcut-keycap">Delete</span>
</div>
<div class="shortcut-action">Delete focused tile or entity</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="sidebar-panel" id="layersPanel">
<h3>Layers</h3>
<div class="selector-section">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleDrawLayerSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>Draw Layers</span>
</button>
<button class="mini-btn" id="addLayerBtn" type="button">Add Layer</button>
</div>
<div class="selector-section-body" id="drawLayerSectionBody">
<div class="layer-list" id="layerList"></div>
</div>
</div>
<div class="selector-section">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleHeightLayerSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>Height Layers</span>
</button>
<button class="mini-btn" id="addHeightLayerBtn" type="button">Add Height Layer</button>
</div>
<div class="selector-section-body" id="heightLayerSectionBody">
<div class="layer-list" id="heightLayerList"></div>
<p class="muted selector-hint">Select a height layer, then paint tiles to grow its sparse patch footprint. The list order is the height stack: top entry is Z1, next is Z2, and so on.</p>
</div>
</div>
</div>
<div class="sidebar-panel hidden" id="tilesPanel">
<h3>Graphics</h3>
<div class="entity-filter-tabs" role="tablist" aria-label="Graphics categories">
<button class="entity-filter-tab active" id="graphicsTilesBtn" type="button" aria-pressed="true">Tiles</button>
<button class="entity-filter-tab" id="graphicsSpritesBtn" type="button" aria-pressed="false">Sprites</button>
<button class="entity-filter-tab" id="graphicsOtherBtn" type="button" aria-pressed="false">Other</button>
</div>
<div class="selector-toolbar">
<button class="panel-square-btn" id="newTileFolderBtn" type="button" title="Create graphics folder">&#128193;</button>
<button class="panel-square-btn" id="newTileBtn" type="button" title="Create new graphic" aria-label="Create new graphic">
<span class="panel-icon-image-plus" aria-hidden="true">
<span class="panel-icon-image-frame"></span>
<span class="panel-icon-image-plus-mark">+</span>
</span>
</button>
<button class="panel-square-btn" id="tileSearchModeBtn" type="button" title="Search graphics" aria-label="Search graphics" aria-pressed="false">
<span class="panel-icon-search" aria-hidden="true">&#128269;</span>
</button>
</div>
<div class="npc-list" id="paintPalette"></div>
<p class="muted selector-hint">Tiles paint the world. Sprites power entities. Other graphics stay stored without entering the entity picker.</p>
</div>
<div class="sidebar-panel hidden" id="instancesPanel">
<h3>Entities</h3>
<div class="entity-filter-tabs" role="tablist" aria-label="Entity categories">
<button class="entity-filter-tab active" id="entityTypeFriendlyBtn" type="button" aria-pressed="true">Friendly</button>
<button class="entity-filter-tab" id="entityTypeHostileBtn" type="button" aria-pressed="false">Hostile</button>
<button class="entity-filter-tab" id="entityTypePropBtn" type="button" aria-pressed="false">Props</button>
</div>
<div class="hidden" id="entitySearchModeHost"></div>
<div class="selector-section" id="entityCatalogSection">
<div class="selector-section-header">
<button class="selector-section-toggle" id="toggleTemplateSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>Catalog</span>
</button>
<div class="selector-section-actions">
<button class="panel-square-btn" id="newTemplateFolderBtn" type="button" title="Create entity catalog folder">&#128193;</button>
<button class="panel-square-btn" id="entitySearchModeBtn" type="button" title="Search entities" aria-label="Search entities" aria-pressed="false">
<span class="panel-icon-search" aria-hidden="true">&#128269;</span>
</button>
</div>
</div>
<div class="selector-section-body" id="instanceTemplateSectionBody">
<div class="npc-list" id="instancePalette"></div>
<p class="muted selector-hint">Select a catalog entity, then click the canvas to place it. The selection stays active for multi-click placement.</p>
</div>
</div>
<div class="selector-section" id="placedEntitiesSection">
<div class="selector-section-header">
<button class="selector-section-toggle" id="togglePlacedSectionBtn" type="button" aria-expanded="true">
<span class="selector-section-chevron">&#9662;</span>
<span>Placed Entities</span>
</button>
<div class="selector-section-actions">
<button class="mini-btn" id="newNpcBtn" type="button">New Entity</button>
<button class="panel-square-btn" id="newPlacedFolderBtn" type="button" title="Create placed entity folder">&#128193;</button>
</div>
</div>
<div class="selector-section-body" id="placedInstanceSectionBody">
<div class="npc-list" id="npcList"></div>
</div>
</div>
</div>
<div class="sidebar-panel hidden" id="triggersPanel">
<h3>Triggers</h3>
<div class="selector-toolbar">
<button class="panel-square-btn" id="newTriggerFolderBtn" type="button" title="Create trigger folder">&#128193;</button>
</div>
<div class="npc-list" id="triggerList"></div>
<p class="muted selector-hint">Trigger placement UI will land here next.</p>
</div>
<div class="sidebar-panel hidden" id="pathsPanel">
<h3>Paths</h3>
<div class="selector-toolbar">
<button class="panel-square-btn" id="newPathFolderBtn" type="button" title="Create path folder">&#128193;</button>
</div>
<div class="npc-list" id="pathList"></div>
<p class="muted selector-hint">Path placement UI will land here next.</p>
</div>
<div class="sidebar-panel hidden" id="transitionsPanel">
<h3>Transitions</h3>
<div class="selector-toolbar">
<button class="panel-square-btn" id="newTransitionFolderBtn" type="button" title="Create transition folder">&#128193;</button>
</div>
<div class="npc-list" id="transitionList"></div>
<p class="muted selector-hint">Transition placement UI will land here next.</p>
</div>
<div class="sidebar-panel hidden history-panel-layout" id="historyPanel">
<h3>History</h3>
<div class="history-stack">
<div class="history-list-scroll">
<div class="history-list" id="historyList"></div>
</div>
<div class="history-current" id="historyCurrent">
<div class="history-current-label">Current State</div>
<div class="history-current-empty">No history yet.</div>
</div>
<div class="history-preview" id="historyPreview">
<h4>Change Preview</h4>
<div class="history-preview-empty">Select a history entry to inspect it.</div>
</div>
</div>
</div>
</div>
<div class="sidebar-static-footer" aria-label="Tool pane links">
<div class="sidebar-footer-links">
<div class="sidebar-footer-linkbar">
<a class="sidebar-footer-link" href="http://www.andraxion.net" target="_blank" rel="noreferrer">Andraxion Studios</a>
</div>
</div>
</div>
</aside>
`;
export const WORLDSHAPER_STUDIO_MARKUP_STAGE = `
<section class="stage" id="stage">
<div class="meta" id="meta">
<div class="meta-main" id="metaMain"></div>
<div class="meta-stats" id="metaStats"></div>
</div>
<button
class="canvas-tool-btn"
id="canvasSelectToolBtn"
type="button"
title="Tile selector: off"
aria-label="Toggle tile selector"
aria-pressed="false"
>
<span class="canvas-tool-btn-icon" aria-hidden="true"></span>
</button>
<div class="viewport" id="viewport">
<div class="viewport-layer"><div class="pixi-host" id="pixiHost" aria-hidden="true"></div><canvas id="roomCanvas"></canvas></div>
<div class="viewport-spacer" id="viewportSpacer" aria-hidden="true"></div>
</div>
</section>
<div class="tool-window-layer" id="toolWindowLayer" aria-hidden="true"></div>
</div>
</div>
`;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import { buildWorldshaperStudioThemeOverrideCss } from "./themePresets";
import {
WORLDSHAPER_STUDIO_STYLE_SHELL,
WORLDSHAPER_STUDIO_STYLE_SIDEBAR,
WORLDSHAPER_STUDIO_STYLE_STAGE,
} from "./domStyleSections";
export function buildWorldshaperStudioStyles(): string {
return (
WORLDSHAPER_STUDIO_STYLE_SHELL
+ WORLDSHAPER_STUDIO_STYLE_SIDEBAR
+ WORLDSHAPER_STUDIO_STYLE_STAGE
+ buildWorldshaperStudioThemeOverrideCss()
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
// @ts-nocheck
import { buildSpritePreviewDataUrl } from "../editorCore";
import {
buildSpriteCatalog,
buildTileCatalogById,
DEFAULT_MAP_BACKGROUND_COLOR,
} from "../components/worldshaperShared";
import type { WorldshaperStudioBootstrap } from "./bootstrap";
function getContentRecords(payload: unknown, key: string) {
const records = payload && Array.isArray(payload[key]) ? payload[key] : [];
return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
}
export function cloneRuntimeValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
export function buildSpriteCatalogFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites");
if (spriteRecords.length > 0) {
return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
}
return cloneRuntimeValue(bootstrap?.spriteCatalog) || {};
}
export function buildTileCatalogByIdFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles");
if (tileRecords.length > 0) {
return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
}
return cloneRuntimeValue(bootstrap?.tileCatalogById) || {};
}
export function buildNpcOverlaysFromWorldChunks(
chunks: unknown[],
spriteCatalog: Record<string, unknown>,
chunkWidth: number,
chunkHeight: number,
originChunkX: number,
originChunkY: number,
) {
return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => {
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
const instances = Array.isArray(chunk?.instances) ? chunk.instances : [];
return instances
.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => {
const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
? cloneRuntimeValue(entry.record)
: {};
const spriteId = String(record.spriteId || entry.spriteId || "").trim();
const spriteEntry = spriteCatalog[spriteId] || null;
const overlayX = offsetX + Math.max(0, Number(entry.x) || 0);
const overlayY = offsetY + Math.max(0, Number(entry.y) || 0);
record.position = {
x: overlayX,
y: overlayY,
};
return {
id: String(entry.id || "").trim(),
layer: Number(entry.layer) || 0,
name: String(record.name || entry.id || "NPC"),
spriteId,
x: overlayX,
y: overlayY,
dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
opacity: spriteEntry ? spriteEntry.opacity : 1,
record,
};
})
.filter((entry) => entry.id);
});
}
export function normalizeMapBackgroundColor(value: unknown, fallback?: string) {
const safeFallback = fallback || DEFAULT_MAP_BACKGROUND_COLOR;
const raw = String(value || "").trim();
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : safeFallback;
}
export function createInitialWorldRuntimeState(bootstrap: WorldshaperStudioBootstrap) {
const worldId = String(bootstrap.worldId || bootstrap.mapId || "").trim();
const chunkRadius = Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0));
const originChunkX = Math.floor(Number(bootstrap.worldOriginChunkX) || 0);
const originChunkY = Math.floor(Number(bootstrap.worldOriginChunkY) || 0);
return {
enabled: !!worldId,
worldId,
worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World",
defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(),
heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)),
chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32),
chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32),
chunkRadius,
originChunkX,
originChunkY,
tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0),
tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0),
spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0),
spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0),
centerChunkX: originChunkX + chunkRadius,
centerChunkY: originChunkY + chunkRadius,
sourceChunks: Array.isArray(bootstrap.sourceChunks)
? bootstrap.sourceChunks.map((entry) => ({
chunkX: Math.floor(Number(entry?.chunkX) || 0),
chunkY: Math.floor(Number(entry?.chunkY) || 0),
}))
: [],
bookmarks: Array.isArray(bootstrap.worldBookmarks)
? bootstrap.worldBookmarks.map((entry, index) => ({
id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`,
label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`,
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
}))
: [],
chunkCache: new Map(),
dirtyChunkKeys: new Set(),
pendingNeighborhoodFetches: new Map(),
prefetchedNeighborhoodKeys: new Set(),
pendingLoadKey: "",
pendingLoadPromise: null,
requestSerial: 0,
documentDirty: false,
};
}
export const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256;
export const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4;
export const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";

View file

@ -0,0 +1,201 @@
// @ts-nocheck
import { createHistoryController } from "./historyController";
import { createInteractionController } from "./interactionController";
import { createImportController } from "./importController";
import { createNpcController } from "./npcController";
import { createChangelogSplashWindowController } from "./changelogSplashWindowController";
import { createEntityEditorWindowController } from "./entityEditorWindowController";
import { createEngineOverrideWindowController } from "./engineOverrideWindowController";
import { createPersistenceController } from "./persistenceController";
import { createRenderController } from "./renderController";
import { createSidebarController } from "./sidebarController";
import { createStatusLogWindowController } from "./statusLogWindowController";
import { createTileArtEditorWindowController } from "./tileArtEditorWindowController";
import { createToolWindowController } from "./toolWindowController";
import { createWorldOverviewWindowController } from "./worldOverviewWindowController";
import { createDebouncedCallback } from "./debounce";
export function initializeRuntimeControllers(config) {
const {
scope,
uiScope,
resetWorkspaceLayout,
setStatus,
createNewTile,
createNewSpriteGraphic,
duplicateGraphicRecord,
openTilePaletteContextMenu,
openPlacedEntityContextMenu,
applyNpcEditorChange,
getEditorEngineOverrides,
saveEditorEngineOverrides,
getEffectiveHeightBlurStep,
isRendererDebugEnabled,
reloadGraphicsContentFromApi,
syncDocumentTitle,
syncCanvasDimensionsToTileSize,
refreshEditorEngineOverridesUi,
cacheStandaloneMapBootstrap,
currentMapId,
persistPopupBounds,
popupSessionStore,
windowRef,
isWorldModeActive,
getInitialWorldViewTile,
centerViewportOnWorldTile,
prefetchAdjacentWorldNeighborhoods,
worldRuntimeState,
syncWorldNeighborhoodForViewport,
drawNow,
} = config;
const toolWindowController = createToolWindowController(scope);
const tileArtEditorWindowController = createTileArtEditorWindowController(scope);
const entityEditorWindowController = createEntityEditorWindowController(scope);
const engineOverrideWindowController = createEngineOverrideWindowController(scope);
const worldOverviewWindowController = createWorldOverviewWindowController(scope);
const changelogSplashWindowController = createChangelogSplashWindowController(scope);
const statusLogWindowController = createStatusLogWindowController(scope);
const syncToolPanels = () => toolWindowController.syncPanels();
const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab);
const restoreAllToolWindows = () => toolWindowController.restoreAllWindows();
const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId);
const closeTileArtEditorWindow = () => tileArtEditorWindowController.close();
const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId);
const closeEntityEditorWindow = () => entityEditorWindowController.close();
const openEngineOverrideWindow = () => engineOverrideWindowController.open();
const closeEngineOverrideWindow = () => engineOverrideWindowController.close();
const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh();
const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary();
const openWorldOverviewWindow = () => worldOverviewWindowController.open();
const closeWorldOverviewWindow = () => worldOverviewWindowController.close();
const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh();
const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options);
const openStatusLogWindow = () => statusLogWindowController.open();
const closeStatusLogWindow = () => statusLogWindowController.close();
const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options });
const resetWorkspaceLayoutFlow = () => {
resetWorkspaceLayout();
toolWindowController.restoreAllWindows();
setStatus("Workspace layout reset.", false);
};
scope.syncToolPanels = syncToolPanels;
scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
scope.restoreAllToolWindows = restoreAllToolWindows;
scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
scope.createNewTile = createNewTile;
scope.createNewSpriteGraphic = createNewSpriteGraphic;
scope.duplicateGraphicRecord = duplicateGraphicRecord;
scope.openTileArtEditorWindow = openTileArtEditorWindow;
scope.closeTileArtEditorWindow = closeTileArtEditorWindow;
scope.openEntityEditorWindow = openEntityEditorWindow;
scope.closeEntityEditorWindow = closeEntityEditorWindow;
scope.openEngineOverrideWindow = openEngineOverrideWindow;
scope.closeEngineOverrideWindow = closeEngineOverrideWindow;
scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
scope.openWorldOverviewWindow = openWorldOverviewWindow;
scope.closeWorldOverviewWindow = closeWorldOverviewWindow;
scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
scope.openStatusLogWindow = openStatusLogWindow;
scope.closeStatusLogWindow = closeStatusLogWindow;
scope.openNewsWindow = openNewsWindow;
scope.openTilePaletteContextMenu = openTilePaletteContextMenu;
scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
scope.applyNpcEditorChange = applyNpcEditorChange;
scope.getEditorEngineOverrides = getEditorEngineOverrides;
scope.saveEditorEngineOverrides = saveEditorEngineOverrides;
scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
scope.isRendererDebugEnabled = isRendererDebugEnabled;
scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
uiScope.syncToolPanels = syncToolPanels;
uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
uiScope.restoreAllToolWindows = restoreAllToolWindows;
uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
uiScope.createNewTile = createNewTile;
uiScope.createNewSpriteGraphic = createNewSpriteGraphic;
uiScope.duplicateGraphicRecord = duplicateGraphicRecord;
uiScope.openEntityEditorWindow = openEntityEditorWindow;
uiScope.closeEntityEditorWindow = closeEntityEditorWindow;
uiScope.openEngineOverrideWindow = openEngineOverrideWindow;
uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow;
uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
uiScope.openWorldOverviewWindow = openWorldOverviewWindow;
uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow;
uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
uiScope.openStatusLogWindow = openStatusLogWindow;
uiScope.closeStatusLogWindow = closeStatusLogWindow;
uiScope.openNewsWindow = openNewsWindow;
uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu;
uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
uiScope.applyNpcEditorChange = applyNpcEditorChange;
uiScope.getEditorEngineOverrides = getEditorEngineOverrides;
uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides;
uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
uiScope.isRendererDebugEnabled = isRendererDebugEnabled;
uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
syncDocumentTitle();
createHistoryController(scope);
createNpcController(scope);
createSidebarController(scope);
const renderController = createRenderController(scope);
createPersistenceController(scope);
createImportController(scope);
const interactionController = createInteractionController(scope);
const persistPopupBoundsDeferred = createDebouncedCallback(() => {
persistPopupBounds();
}, 160);
syncCanvasDimensionsToTileSize();
toolWindowController.initialize();
tileArtEditorWindowController.initialize();
entityEditorWindowController.initialize();
engineOverrideWindowController.initialize();
worldOverviewWindowController.initialize();
changelogSplashWindowController.initialize();
statusLogWindowController.initialize();
renderController.initializeRenderAssets();
interactionController.initializeEditorState();
interactionController.bindDomEvents();
interactionController.initializeUi();
refreshEditorEngineOverridesUi();
cacheStandaloneMapBootstrap(currentMapId);
if (isWorldModeActive()) {
windowRef.requestAnimationFrame(() => {
const initialWorldView = getInitialWorldViewTile();
centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY);
prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY);
syncWorldNeighborhoodForViewport();
drawNow();
setStatus("World mode loaded. Endless navigation is active.", false);
});
}
windowRef.requestAnimationFrame(() => {
changelogSplashWindowController.maybeOpenForCurrentVersion();
});
windowRef.addEventListener("resize", () => {
persistPopupBoundsDeferred();
});
windowRef.addEventListener("beforeunload", () => {
popupSessionStore.flushPersistedLayout(windowRef);
persistPopupBounds();
});
return {
renderController,
statusLogWindowController,
changelogSplashWindowController,
persistPopupBoundsDeferred,
};
}

View file

@ -0,0 +1,67 @@
// @ts-nocheck
export function createRuntimeLogging({ windowRef, runtimeUniqueId }) {
const editorLogEntries = [];
const EDITOR_LOG_LIMIT = 500;
let statusLogWindowController = null;
function formatEditorLogTimestamp(timestamp) {
try {
return new Date(timestamp).toLocaleString();
} catch {
return String(timestamp || "");
}
}
function appendEditorLogEntry(level, message) {
const normalizedMessage = String(message || "").trim();
if (!normalizedMessage) {
return null;
}
const timestamp = Date.now();
const entry = {
id: runtimeUniqueId(),
timestamp,
timestampLabel: formatEditorLogTimestamp(timestamp),
level: String(level || "Information").trim() || "Information",
message: normalizedMessage,
};
editorLogEntries.push(entry);
while (editorLogEntries.length > EDITOR_LOG_LIMIT) {
editorLogEntries.shift();
}
statusLogWindowController?.refresh?.();
return entry;
}
function getEditorLogEntries() {
return editorLogEntries.slice();
}
function clearEditorLogEntries() {
editorLogEntries.splice(0, editorLogEntries.length);
statusLogWindowController?.refresh?.();
}
windowRef.addEventListener("error", (event) => {
const message = String(event?.message || event?.error?.message || "Unknown runtime error");
appendEditorLogEntry("Error", message);
});
windowRef.addEventListener("unhandledrejection", (event) => {
const reason = event?.reason;
const message = typeof reason === "string"
? reason
: String(reason?.message || reason || "Unhandled promise rejection");
appendEditorLogEntry("Error", message);
});
return {
appendEditorLogEntry,
getEditorLogEntries,
clearEditorLogEntries,
setStatusLogWindowController(nextController) {
statusLogWindowController = nextController;
},
};
}

View file

@ -0,0 +1,420 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {
buildSpritePreviewDataUrl,
getSpritePalette,
normalizeImagePlayback,
} from "../editorCore";
import { normalizeEditorTags } from "./tagUtils";
export const TILE_ART_SIZE = 16;
export const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M20.8 4.2c1.6-1.6 4.3-1.6 5.9 0s1.6 4.3 0 5.9l-3.1 3.1-5.9-5.9 3.1-3.1z" fill="#eef6ff" stroke="#08111d" stroke-width="1.5"/>
<path d="M10.8 14.1l6.9-6.9 6.1 6.1-6.9 6.9-2.7.9-.9 2.7-3 3a2.2 2.2 0 0 1-3.1 0l-1-1a2.2 2.2 0 0 1 0-3.1l3-3 2.7-.9.9-2.7z" fill="#7ee8c6" stroke="#08111d" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="8.4" cy="23.6" r="2" fill="#ff5f6d"/>
</svg>`,
)}") 4 28, crosshair`;
export function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function normalizeRoleList(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(
value
.map((entry) => String(entry || "").trim().toLowerCase())
.filter((entry) => entry === "tile" || entry === "sprite"),
));
}
export function normalizeTimelineRows(rows) {
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
});
}
export function normalizeWorkingFrames(record) {
const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
const normalizedFrames = rawFrames.map((entry, index) => ({
...cloneValue(entry),
id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
enabled: entry.enabled !== false,
index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
rows: normalizeTimelineRows(entry.rows),
}));
if (normalizedFrames.length > 0) {
return normalizedFrames;
}
return [{
id: "frame_0",
enabled: true,
index: 0,
rows: normalizeTimelineRows(record?.rows),
}];
}
export function sortWorkingFrames(frames) {
return frames
.map((frame, sourceIndex) => ({
frame,
sourceIndex,
sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
}))
.sort((left, right) => (
left.sortIndex !== right.sortIndex
? left.sortIndex - right.sortIndex
: left.sourceIndex - right.sourceIndex
))
.map((entry) => entry.frame);
}
export function normalizeWorkingGraphicRecord(recordType, record) {
const source = cloneValue(record) || {};
const roles = normalizeRoleList(source.roles);
const nextRoles = recordType === "tile"
? Array.from(new Set([...roles, "tile"]))
: (
recordType === "sprite"
? Array.from(new Set([...roles, "sprite"]))
: roles.filter((entry) => entry !== "sprite")
);
const frames = normalizeWorkingFrames(source).map((frame, index) => ({
...frame,
index,
}));
const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
const defaultFrameId = String(
frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
|| frames[0]?.id
|| "frame_0",
).trim() || "frame_0";
const workingRows = normalizeTimelineRows(
Array.isArray(source.rows) && source.rows.length > 0
? source.rows
: (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
);
return {
...source,
id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
name: typeof source.name === "string" ? source.name : "",
description: typeof source.description === "string" ? source.description : "",
width: TILE_ART_SIZE,
height: TILE_ART_SIZE,
pixelScale: Math.max(1, Number(source.pixelScale) || 2),
opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
tags: normalizeEditorTags(source.tags),
roles: nextRoles,
tileSymbol: nextRoles.includes("tile")
? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
: "",
defaultFrame: defaultFrameId,
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
playback: normalizeImagePlayback(source.playback),
frames,
rows: workingRows,
};
}
export function normalizeOpacityValue(value, fallback = 1) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(0, Math.min(1, parsed));
}
export function formatOpacityValue(value) {
const normalized = normalizeOpacityValue(value, 1);
return normalized.toFixed(2).replace(/\.?0+$/, "");
}
export function cloneRows(rows) {
return Array.isArray(rows)
? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
: Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
}
export function buildRowsPreviewRecord(rows) {
return {
width: TILE_ART_SIZE,
height: TILE_ART_SIZE,
rows: cloneRows(rows),
};
}
export function formatPlaybackLabel(value) {
const normalized = normalizeImagePlayback(value);
if (normalized === "rewind") {
return "Rewind";
}
if (normalized === "stop") {
return "Stop";
}
return "Normal";
}
export function getWorkingCellSymbol(record, x, y) {
const rows = Array.isArray(record?.rows) ? record.rows : [];
const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
return String(row.charAt(x) || ".").charAt(0) || ".";
}
export function paintWorkingRowsCell(rows, x, y, symbol) {
const nextRows = cloneRows(rows);
const nextSymbol = String(symbol || ".").charAt(0) || ".";
const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
return nextRows;
}
export function getRowsMatrix(rows) {
return cloneRows(rows).map((row) => Array.from(row));
}
export function buildRowsFromMatrix(matrix) {
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
});
}
export function getAlternatePaintSymbol(record, preferredSymbol) {
const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
const palette = getSpritePalette(record || undefined);
const nextSymbol = Object.keys(palette)
.map((symbol) => String(symbol || "").charAt(0))
.find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
return nextSymbol || ".";
}
export function shiftRows(rows, offsetX, offsetY) {
const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
const sourceRows = cloneRows(rows);
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
continue;
}
nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextRows);
}
export function flipRowsHorizontally(rows) {
return cloneRows(rows).map((row) => row.split("").reverse().join(""));
}
export function flipRowsVertically(rows) {
return cloneRows(rows).slice().reverse();
}
export function rotateRowsClockwise(rows) {
const matrix = getRowsMatrix(rows);
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextMatrix);
}
export function rotateRowsCounterClockwise(rows) {
const matrix = getRowsMatrix(rows);
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextMatrix);
}
export function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
const minX = Math.max(0, Math.min(startX, endX));
const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
const minY = Math.max(0, Math.min(startY, endY));
const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
const fillMask = new Set();
const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
const width = Math.max(1, (maxX - minX) + 1);
const height = Math.max(1, (maxY - minY) + 1);
const centerX = minX + (width / 2);
const centerY = minY + (height / 2);
const denomX = Math.max(0.5, width / 2);
const denomY = Math.max(0.5, height / 2);
const triangleAx = minX + (width - 1) / 2;
const triangleAy = minY;
const triangleBx = minX;
const triangleBy = maxY;
const triangleCx = maxX;
const triangleCy = maxY;
const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
let include;
const sampleX = x + 0.5;
const sampleY = y + 0.5;
if (shape === "rectangle") {
include = true;
} else if (shape === "circle") {
const normX = (sampleX - centerX) / denomX;
const normY = (sampleY - centerY) / denomY;
include = (normX * normX) + (normY * normY) <= 1;
} else if (triangleDenominator !== 0) {
const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
const c = 1 - a - b;
include = a >= 0 && b >= 0 && c >= 0;
} else {
include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
}
if (include === true) {
fillMask.add(`${x}:${y}`);
}
}
}
return fillMask;
}
export function buildOutlineMask(fillMask) {
const outlineMask = new Set();
fillMask.forEach((key) => {
const [xText, yText] = String(key || "").split(":");
const x = Number(xText);
const y = Number(yText);
const neighbors = [
`${x - 1}:${y}`,
`${x + 1}:${y}`,
`${x}:${y - 1}`,
`${x}:${y + 1}`,
];
if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
outlineMask.add(key);
}
});
return outlineMask;
}
export function applyMaskToRows(baseRows, mask, symbol) {
const matrix = getRowsMatrix(baseRows);
mask.forEach((key) => {
const [xText, yText] = String(key || "").split(":");
const x = Number(xText);
const y = Number(yText);
if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
return;
}
matrix[y][x] = String(symbol || ".").charAt(0) || ".";
});
return buildRowsFromMatrix(matrix);
}
export function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
const matrix = getRowsMatrix(baseRows);
let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
const deltaX = Math.abs(x1 - x0);
const deltaY = Math.abs(y1 - y0);
const stepX = x0 < x1 ? 1 : -1;
const stepY = y0 < y1 ? 1 : -1;
let error = deltaX - deltaY;
while (true) {
matrix[y0][x0] = normalizedSymbol;
if (x0 === x1 && y0 === y1) {
break;
}
const nextError = error * 2;
if (nextError > -deltaY) {
error -= deltaY;
x0 += stepX;
}
if (nextError < deltaX) {
error += deltaX;
y0 += stepY;
}
}
return buildRowsFromMatrix(matrix);
}
export function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
const normalizedTone = tone === "erase" ? "erase" : "draw";
return ""
+ `<span class="tile-art-menu-shape-icon is-${normalizedShape} is-${normalizedVariant} is-${normalizedTone}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-shape-outline\"></span>"
+ "<span class=\"tile-art-menu-shape-fill\"></span>"
+ "</span>";
}
export function buildLineOptionIconMarkup(tone = "draw") {
const normalizedTone = tone === "erase" ? "erase" : "draw";
return ""
+ `<span class="tile-art-menu-line-icon is-${normalizedTone}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-line-stroke\"></span>"
+ "</span>";
}
export function buildCurrentShapeToolIconMarkup(state) {
if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
return buildLineOptionIconMarkup("draw");
}
return buildShapeOptionIconMarkup(
state?.activeShapeKind || "rectangle",
state?.activeShapeVariant || "outline",
"draw",
);
}
export function buildCurrentEraseToolIconMarkup(state) {
return buildShapeOptionIconMarkup(
state?.activeEraseKind || "rectangle",
"fill",
"erase",
);
}
export function buildTransformCategoryIconMarkup(kind) {
const normalizedKind = kind === "flip" ? "flip" : "rotate";
return ""
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
+ "</span>";
}
export function buildTransformOptionIconMarkup(kind) {
const normalizedKind = [
"rotate-cw",
"rotate-ccw",
"flip-h",
"flip-v",
].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
return ""
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
+ "</span>";
}
export function buildFramePreviewDataUrl(rows, scale = 10) {
return buildSpritePreviewDataUrl(buildRowsPreviewRecord(rows), scale);
}

View file

@ -22,9 +22,40 @@ import {
} from "./textTransferUtils";
import { clampFloatingWindowRect } from "./floatingWindowUtils";
import { appendContextMenuItems, menuItem, menuSubmenu, openContextMenuAtPoint } from "./contextMenuSchema";
import {
applyMaskToRows,
buildCurrentEraseToolIconMarkup,
buildCurrentShapeToolIconMarkup,
buildFramePreviewDataUrl,
buildLineOptionIconMarkup,
buildOutlineMask,
buildShapeFillMask,
buildShapeOptionIconMarkup,
buildTransformCategoryIconMarkup,
buildTransformOptionIconMarkup,
cloneRows,
cloneValue,
EYEDROPPER_CURSOR,
flipRowsHorizontally,
flipRowsVertically,
formatOpacityValue,
formatPlaybackLabel,
getAlternatePaintSymbol,
getLineRows,
getWorkingCellSymbol,
normalizeOpacityValue,
normalizeTimelineRows,
normalizeWorkingFrames,
normalizeWorkingGraphicRecord,
paintWorkingRowsCell,
rotateRowsClockwise,
rotateRowsCounterClockwise,
shiftRows,
sortWorkingFrames,
TILE_ART_SIZE,
} from "./tileArtEditorHelpers";
const TILE_ART_WINDOW_KEY = "tileArtEditor";
const TILE_ART_SIZE = 16;
const GRID_CELL_SIZE = 21;
const MIN_WIDTH = 452;
const MIN_HEIGHT = 628;
@ -38,415 +69,11 @@ const TOOL_MENU_TAG_PREFIX = "tile-art-tool-menu:";
const SHORTCUT_HELP_TOOLTIP_TAG = "tile-art-shortcut-help";
const ANIMATION_SPEED_TOOLTIP_TAG = "tile-art-animation-speed";
const ANIMATION_PLAYBACK_TOOLTIP_TAG = "tile-art-animation-playback";
const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M20.8 4.2c1.6-1.6 4.3-1.6 5.9 0s1.6 4.3 0 5.9l-3.1 3.1-5.9-5.9 3.1-3.1z" fill="#eef6ff" stroke="#08111d" stroke-width="1.5"/>
<path d="M10.8 14.1l6.9-6.9 6.1 6.1-6.9 6.9-2.7.9-.9 2.7-3 3a2.2 2.2 0 0 1-3.1 0l-1-1a2.2 2.2 0 0 1 0-3.1l3-3 2.7-.9.9-2.7z" fill="#7ee8c6" stroke="#08111d" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="8.4" cy="23.6" r="2" fill="#ff5f6d"/>
</svg>`,
)}") 4 28, crosshair`;
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function clampWindowRect(layerRect, left, top, width, height) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
function normalizeRoleList(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(
value
.map((entry) => String(entry || "").trim().toLowerCase())
.filter((entry) => entry === "tile" || entry === "sprite"),
));
}
function normalizeTimelineRows(rows) {
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
});
}
function normalizeWorkingFrames(record) {
const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
const normalizedFrames = rawFrames.map((entry, index) => ({
...cloneValue(entry),
id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
enabled: entry.enabled !== false,
index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
rows: normalizeTimelineRows(entry.rows),
}));
if (normalizedFrames.length > 0) {
return normalizedFrames;
}
return [{
id: "frame_0",
enabled: true,
index: 0,
rows: normalizeTimelineRows(record?.rows),
}];
}
function sortWorkingFrames(frames) {
return frames
.map((frame, sourceIndex) => ({
frame,
sourceIndex,
sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
}))
.sort((left, right) => (
left.sortIndex !== right.sortIndex
? left.sortIndex - right.sortIndex
: left.sourceIndex - right.sourceIndex
))
.map((entry) => entry.frame);
}
function normalizeWorkingGraphicRecord(recordType, record) {
const source = cloneValue(record) || {};
const roles = normalizeRoleList(source.roles);
const nextRoles = recordType === "tile"
? Array.from(new Set([...roles, "tile"]))
: (
recordType === "sprite"
? Array.from(new Set([...roles, "sprite"]))
: roles.filter((entry) => entry !== "sprite")
);
const frames = normalizeWorkingFrames(source).map((frame, index) => ({
...frame,
index,
}));
const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
const defaultFrameId = String(
frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
|| frames[0]?.id
|| "frame_0",
).trim() || "frame_0";
const workingRows = normalizeTimelineRows(
Array.isArray(source.rows) && source.rows.length > 0
? source.rows
: (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
);
return {
...source,
id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
name: typeof source.name === "string" ? source.name : "",
description: typeof source.description === "string" ? source.description : "",
width: TILE_ART_SIZE,
height: TILE_ART_SIZE,
pixelScale: Math.max(1, Number(source.pixelScale) || 2),
opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
tags: normalizeEditorTags(source.tags),
roles: nextRoles,
tileSymbol: nextRoles.includes("tile")
? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
: "",
defaultFrame: defaultFrameId,
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
playback: normalizeImagePlayback(source.playback),
frames,
rows: workingRows,
};
}
function normalizeOpacityValue(value, fallback = 1) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(0, Math.min(1, parsed));
}
function formatOpacityValue(value) {
const normalized = normalizeOpacityValue(value, 1);
return normalized.toFixed(2).replace(/\.?0+$/, "");
}
function buildRowsPreviewRecord(rows) {
return {
width: TILE_ART_SIZE,
height: TILE_ART_SIZE,
rows: cloneRows(rows),
};
}
function formatPlaybackLabel(value) {
const normalized = normalizeImagePlayback(value);
if (normalized === "rewind") {
return "Rewind";
}
if (normalized === "stop") {
return "Stop";
}
return "Normal";
}
function cloneRows(rows) {
return Array.isArray(rows)
? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
: Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
}
function getWorkingCellSymbol(record, x, y) {
const rows = Array.isArray(record?.rows) ? record.rows : [];
const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
return String(row.charAt(x) || ".").charAt(0) || ".";
}
function paintWorkingRowsCell(rows, x, y, symbol) {
const nextRows = cloneRows(rows);
const nextSymbol = String(symbol || ".").charAt(0) || ".";
const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
return nextRows;
}
function getRowsMatrix(rows) {
return cloneRows(rows).map((row) => Array.from(row));
}
function buildRowsFromMatrix(matrix) {
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
});
}
function getAlternatePaintSymbol(record, preferredSymbol) {
const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
const palette = getSpritePalette(record || undefined);
const nextSymbol = Object.keys(palette)
.map((symbol) => String(symbol || "").charAt(0))
.find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
return nextSymbol || ".";
}
function shiftRows(rows, offsetX, offsetY) {
const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
const sourceRows = cloneRows(rows);
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
continue;
}
nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextRows);
}
function flipRowsHorizontally(rows) {
return cloneRows(rows).map((row) => row.split("").reverse().join(""));
}
function flipRowsVertically(rows) {
return cloneRows(rows).slice().reverse();
}
function rotateRowsClockwise(rows) {
const matrix = getRowsMatrix(rows);
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextMatrix);
}
function rotateRowsCounterClockwise(rows) {
const matrix = getRowsMatrix(rows);
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
}
}
return buildRowsFromMatrix(nextMatrix);
}
function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
const minX = Math.max(0, Math.min(startX, endX));
const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
const minY = Math.max(0, Math.min(startY, endY));
const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
const fillMask = new Set();
const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
const width = Math.max(1, (maxX - minX) + 1);
const height = Math.max(1, (maxY - minY) + 1);
const centerX = minX + (width / 2);
const centerY = minY + (height / 2);
const denomX = Math.max(0.5, width / 2);
const denomY = Math.max(0.5, height / 2);
const triangleAx = minX + (width - 1) / 2;
const triangleAy = minY;
const triangleBx = minX;
const triangleBy = maxY;
const triangleCx = maxX;
const triangleCy = maxY;
const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
let include;
const sampleX = x + 0.5;
const sampleY = y + 0.5;
if (shape === "rectangle") {
include = true;
} else if (shape === "circle") {
const normX = (sampleX - centerX) / denomX;
const normY = (sampleY - centerY) / denomY;
include = (normX * normX) + (normY * normY) <= 1;
} else if (triangleDenominator !== 0) {
const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
const c = 1 - a - b;
include = a >= 0 && b >= 0 && c >= 0;
} else {
include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
}
if (include === true) {
fillMask.add(`${x}:${y}`);
}
}
}
return fillMask;
}
function buildOutlineMask(fillMask) {
const outlineMask = new Set();
fillMask.forEach((key) => {
const [xText, yText] = String(key || "").split(":");
const x = Number(xText);
const y = Number(yText);
const neighbors = [
`${x - 1}:${y}`,
`${x + 1}:${y}`,
`${x}:${y - 1}`,
`${x}:${y + 1}`,
];
if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
outlineMask.add(key);
}
});
return outlineMask;
}
function applyMaskToRows(baseRows, mask, symbol) {
const matrix = getRowsMatrix(baseRows);
mask.forEach((key) => {
const [xText, yText] = String(key || "").split(":");
const x = Number(xText);
const y = Number(yText);
if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
return;
}
matrix[y][x] = String(symbol || ".").charAt(0) || ".";
});
return buildRowsFromMatrix(matrix);
}
function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
const matrix = getRowsMatrix(baseRows);
let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
const deltaX = Math.abs(x1 - x0);
const deltaY = Math.abs(y1 - y0);
const stepX = x0 < x1 ? 1 : -1;
const stepY = y0 < y1 ? 1 : -1;
let error = deltaX - deltaY;
while (true) {
matrix[y0][x0] = normalizedSymbol;
if (x0 === x1 && y0 === y1) {
break;
}
const nextError = error * 2;
if (nextError > -deltaY) {
error -= deltaY;
x0 += stepX;
}
if (nextError < deltaX) {
error += deltaX;
y0 += stepY;
}
}
return buildRowsFromMatrix(matrix);
}
function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
const normalizedTone = tone === "erase" ? "erase" : "draw";
return ""
+ `<span class="tile-art-menu-shape-icon is-${normalizedShape} is-${normalizedVariant} is-${normalizedTone}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-shape-outline\"></span>"
+ "<span class=\"tile-art-menu-shape-fill\"></span>"
+ "</span>";
}
function buildLineOptionIconMarkup(tone = "draw") {
const normalizedTone = tone === "erase" ? "erase" : "draw";
return ""
+ `<span class="tile-art-menu-line-icon is-${normalizedTone}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-line-stroke\"></span>"
+ "</span>";
}
function buildCurrentShapeToolIconMarkup(state) {
if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
return buildLineOptionIconMarkup("draw");
}
return buildShapeOptionIconMarkup(
state?.activeShapeKind || "rectangle",
state?.activeShapeVariant || "outline",
"draw",
);
}
function buildCurrentEraseToolIconMarkup(state) {
return buildShapeOptionIconMarkup(
state?.activeEraseKind || "rectangle",
"fill",
"erase",
);
}
function buildTransformCategoryIconMarkup(kind) {
const normalizedKind = kind === "flip" ? "flip" : "rotate";
return ""
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
+ "</span>";
}
function buildTransformOptionIconMarkup(kind) {
const normalizedKind = [
"rotate-cw",
"rotate-ccw",
"flip-h",
"flip-v",
].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
return ""
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
+ "</span>";
}
export function createTileArtEditorWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
@ -833,7 +460,7 @@ export function createTileArtEditorWindowController(scope) {
}
const currentFrame = playbackFrames.find((frame) => String(frame.id || "").trim() === String(state.animationPreviewFrameId || "").trim()) || playbackFrames[0];
state.animationPreviewFrameId = String(currentFrame?.id || "").trim();
const previewUrl = buildSpritePreviewDataUrl(buildRowsPreviewRecord(currentFrame?.rows), 10);
const previewUrl = buildFramePreviewDataUrl(currentFrame?.rows, 10);
if (previewUrl) {
state.animationPreviewImageEl.src = previewUrl;
state.animationPreviewImageEl.classList.remove("hidden");

View file

@ -1,210 +1 @@
export type PopupBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio";
export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds";
export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer";
export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds";
export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin);
const normalizedMapId = String(mapId || "").trim();
const normalizedWorldId = String(options?.worldId || "").trim();
if (normalizedMapId) {
popupUrl.searchParams.set("mapId", normalizedMapId);
}
if (normalizedWorldId) {
popupUrl.searchParams.set("worldId", normalizedWorldId);
}
return popupUrl.toString();
}
export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin);
const normalizedMapId = String(mapId || "").trim();
const normalizedToken = String(token || "").trim();
if (normalizedMapId) {
popupUrl.searchParams.set("mapId", normalizedMapId);
}
if (normalizedToken) {
popupUrl.searchParams.set("token", normalizedToken);
}
return popupUrl.toString();
}
export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
const width = 1360;
const height = 900;
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
? hostWindow.outerWidth
: hostWindow.innerWidth;
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
? hostWindow.outerHeight
: hostWindow.innerHeight;
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
return { left, top, width, height };
}
export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
const width = 1280;
const height = 820;
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
? hostWindow.outerWidth
: hostWindow.innerWidth;
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
? hostWindow.outerHeight
: hostWindow.innerHeight;
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
return { left, top, width, height };
}
export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
const width = Math.max(640, Number(parsed.width) || 0);
const height = Math.max(480, Number(parsed.height) || 0);
const left = Math.max(0, Number(parsed.left) || 0);
const top = Math.max(0, Number(parsed.top) || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredWorldshaperStudioBounds(hostWindow);
}
}
export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
const width = Math.max(640, Number(parsed.width) || 0);
const height = Math.max(480, Number(parsed.height) || 0);
const left = Math.max(0, Number(parsed.left) || 0);
const top = Math.max(0, Number(parsed.top) || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
}
}
export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void {
if (sourceWindow.closed) {
return;
}
try {
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
sourceWindow.localStorage.setItem(
WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void {
if (sourceWindow.closed) {
return;
}
try {
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
sourceWindow.localStorage.setItem(
WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function openWorldshaperStudioWindow(
mapId: string,
hostWindow: Window = window,
options?: { worldId?: string },
): Window | null {
const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options);
const initialBounds = readWorldshaperStudioBounds(hostWindow);
const popupFeatures = [
"popup=yes",
"resizable=yes",
"scrollbars=no",
"width=" + initialBounds.width,
"height=" + initialBounds.height,
"left=" + initialBounds.left,
"top=" + initialBounds.top,
].join(",");
const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures);
if (!popup) {
return null;
}
try {
popup.moveTo(initialBounds.left, initialBounds.top);
popup.resizeTo(initialBounds.width, initialBounds.height);
} catch {
// Ignore browser restrictions.
}
popup.location.href = popupUrl;
popup.focus();
return popup;
}
export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null {
const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow);
const initialBounds = readWorldshaperHeightViewerBounds(hostWindow);
const popupFeatures = [
"popup=yes",
"resizable=yes",
"scrollbars=no",
"width=" + initialBounds.width,
"height=" + initialBounds.height,
"left=" + initialBounds.left,
"top=" + initialBounds.top,
].join(",");
const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures);
if (!popup) {
return null;
}
try {
popup.moveTo(initialBounds.left, initialBounds.top);
popup.resizeTo(initialBounds.width, initialBounds.height);
} catch {
// Ignore browser restrictions.
}
popup.location.href = popupUrl;
popup.focus();
return popup;
}
export * from "../shared/windowing";

View file

@ -0,0 +1,602 @@
// @ts-nocheck
export function createFilledRows(width, height, fillChar) {
return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1)));
}
function writeRowSegment(rows, y, x, segment) {
if (!Array.isArray(rows) || !segment) {
return;
}
const targetY = Math.floor(Number(y) || 0);
if (targetY < 0 || targetY >= rows.length) {
return;
}
const safeX = Math.max(0, Math.floor(Number(x) || 0));
const sourceRow = String(rows[targetY] || "");
const paddedRow = sourceRow.length >= safeX
? sourceRow
: (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
const before = paddedRow.slice(0, safeX);
const afterStart = safeX + segment.length;
const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : "";
rows[targetY] = before + segment + after;
}
export function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) {
const layerMap = new Map();
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
rawLayers.forEach((rawLayer) => {
const layerNumber = Number(rawLayer?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
if (!layerMap.has(layerNumber)) {
layerMap.set(layerNumber, {
layer: layerNumber,
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
rows: createFilledRows(worldWidth, worldHeight, fillChar),
instanceIds: [],
});
}
const targetLayer = layerMap.get(layerNumber);
const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : [];
sourceRows.forEach((row, localY) => {
const targetY = offsetY + localY;
if (targetY < 0 || targetY >= targetLayer.rows.length) {
return;
}
const maxWidth = Math.max(0, worldWidth - offsetX);
writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth));
});
const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds)
? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean)
: [];
targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds]));
});
});
if (!layerMap.has(0)) {
layerMap.set(0, {
layer: 0,
rows: createFilledRows(worldWidth, worldHeight, "."),
instanceIds: [],
});
}
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
}
export function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) {
const patches = [];
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : [];
rawHeightLayers.forEach((entry, index) => {
const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`;
patches.push({
id: String(entry?.id || fallbackId).trim() || fallbackId,
name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined,
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
x: offsetX + Math.max(0, Number(entry?.x) || 0),
y: offsetY + Math.max(0, Number(entry?.y) || 0),
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
});
});
});
return patches.sort((a, b) => {
if (a.z !== b.z) {
return a.z - b.z;
}
return String(a.name || a.id).localeCompare(String(b.name || b.id));
});
}
export function buildWorldLayerMetadata(chunks) {
const layerMap = new Map();
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
rawLayers.forEach((rawLayer) => {
const layerNumber = Number(rawLayer?.layer) || 0;
if (layerMap.has(layerNumber)) {
return;
}
layerMap.set(layerNumber, {
layer: layerNumber,
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
rows: [],
instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [],
});
});
});
if (!layerMap.has(0)) {
layerMap.set(0, {
layer: 0,
rows: [],
instanceIds: [],
});
}
if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) {
layerMap.set(1, {
layer: 1,
rows: [],
instanceIds: [],
});
}
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
}
export function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) {
return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => {
const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || "");
const paddedRow = sourceRow.length >= startX + width
? sourceRow
: sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length));
return paddedRow.slice(startX, startX + width);
});
}
export function buildChunkHeightLayersFromDocument({ mapDocument, cloneHeightLayers, baseTileX, baseTileY, chunkWidth, chunkHeight }) {
return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : [])
.map((entry) => {
const patchX = Math.max(0, Number(entry?.x) || 0);
const patchY = Math.max(0, Number(entry?.y) || 0);
const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [];
const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0);
const patchHeight = rows.length;
const patchRight = patchX + patchWidth;
const patchBottom = patchY + patchHeight;
const chunkRight = baseTileX + chunkWidth;
const chunkBottom = baseTileY + chunkHeight;
const overlapLeft = Math.max(baseTileX, patchX);
const overlapTop = Math.max(baseTileY, patchY);
const overlapRight = Math.min(chunkRight, patchRight);
const overlapBottom = Math.min(chunkBottom, patchBottom);
if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) {
return null;
}
const localRows = [];
for (let y = overlapTop; y < overlapBottom; y += 1) {
const sourceRow = String(rows[y - patchY] || "");
localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, ""));
}
return {
id: String(entry?.id || "").trim(),
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
z: Math.max(1, Number(entry?.z) || 1),
x: overlapLeft - baseTileX,
y: overlapTop - baseTileY,
rows: localRows,
};
})
.filter((entry) => entry && entry.id);
}
export function buildChunkInstancesFromDocument({ mapDocument, cloneValue, baseTileX, baseTileY, chunkWidth, chunkHeight, tileOffsetX, tileOffsetY }) {
const chunkInstances = cloneValue(mapDocument.npcOverlays)
.filter((npc) => {
const localX = Math.floor(Number(npc?.x));
const localY = Math.floor(Number(npc?.y));
return Number.isFinite(localX)
&& Number.isFinite(localY)
&& localX >= baseTileX
&& localX < baseTileX + chunkWidth
&& localY >= baseTileY
&& localY < baseTileY + chunkHeight;
})
.map((npc) => ({
id: String(npc.id || "").trim(),
templateId: String(npc?.record?.templateId || "").trim(),
layer: Number(npc.layer) || 0,
x: Math.floor(Number(npc.x) || 0) - baseTileX,
y: Math.floor(Number(npc.y) || 0) - baseTileY,
record: {
...cloneValue(npc.record || {}),
id: String(npc.id || "").trim(),
layer: Number(npc.layer) || 0,
templateId: String(npc?.record?.templateId || "").trim(),
name: String(npc.name || npc?.record?.name || ""),
entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"),
faction: String(npc.faction || npc?.record?.faction || ""),
spriteId: String(npc.spriteId || npc?.record?.spriteId || ""),
dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""),
description: String(npc.description || npc?.record?.description || ""),
tags: cloneValue(npc?.record?.tags) || [],
enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true,
position: {
x: Math.floor(Number(npc.x) || 0) + tileOffsetX,
y: Math.floor(Number(npc.y) || 0) + tileOffsetY,
},
},
}))
.filter((entry) => entry.id);
const npcIdsByLayer = new Map();
chunkInstances.forEach((entry) => {
const layerNumber = Number(entry.layer) || 0;
if (!npcIdsByLayer.has(layerNumber)) {
npcIdsByLayer.set(layerNumber, []);
}
npcIdsByLayer.get(layerNumber).push(entry.id);
});
return {
chunkInstances,
npcIdsByLayer,
};
}
export function normalizeWorldChunkRows(rows, width, height, fillChar) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
return Array.from({ length: safeHeight }, (_entry, rowIndex) => {
const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || "");
return sourceRow.length >= safeWidth
? sourceRow.slice(0, safeWidth)
: (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length)));
});
}
export function cloneWorldChunkHeightLayers(source) {
return (Array.isArray(source) ? source : [])
.map((entry, index) => ({
id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`,
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
x: Math.max(0, Math.floor(Number(entry?.x) || 0)),
y: Math.max(0, Math.floor(Number(entry?.y) || 0)),
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
}))
.filter((entry) => entry.id);
}
export function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
const nextLayers = new Map();
(Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => {
const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0));
nextLayers.set(layerNumber, {
layer: layerNumber,
name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
instanceIds: [],
});
});
if (!nextLayers.has(0)) {
nextLayers.set(0, {
layer: 0,
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."),
instanceIds: [],
});
}
if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) {
nextLayers.set(1, {
layer: 1,
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "),
instanceIds: [],
});
}
(Array.isArray(instances) ? instances : []).forEach((entry) => {
const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0));
const instanceId = String(entry?.id || "").trim();
if (!instanceId) {
return;
}
if (!nextLayers.has(layerNumber)) {
nextLayers.set(layerNumber, {
layer: layerNumber,
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
instanceIds: [],
});
}
nextLayers.get(layerNumber).instanceIds.push(instanceId);
});
return Array.from(nextLayers.values())
.map((entry) => ({
...entry,
instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))),
}))
.sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0));
}
export function normalizeWorldChunkInstances({ sourceInstances, chunkX, chunkY, width, height, options, cloneValue, runtimeUniqueId }) {
const config = options && typeof options === "object" ? options : {};
const duplicateIds = config.duplicateIds === true;
const safeChunkX = Math.floor(Number(chunkX) || 0);
const safeChunkY = Math.floor(Number(chunkY) || 0);
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
return (Array.isArray(sourceInstances) ? sourceInstances : [])
.map((entry) => {
const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record)
? cloneValue(entry.record)
: {};
const nextId = duplicateIds
? runtimeUniqueId()
: (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId());
const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0));
const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0)));
const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0)));
const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim();
record.id = nextId;
record.layer = nextLayer;
record.templateId = nextTemplateId;
record.position = {
x: (safeChunkX * safeWidth) + nextX,
y: (safeChunkY * safeHeight) + nextY,
};
return {
id: nextId,
templateId: nextTemplateId,
layer: nextLayer,
x: nextX,
y: nextY,
record,
};
})
.filter((entry) => entry.id);
}
export function createEmptyWorldChunkPayload({ chunkX, chunkY, chunkWidth, chunkHeight, worldId }) {
const safeChunkX = Math.floor(Number(chunkX) || 0);
const safeChunkY = Math.floor(Number(chunkY) || 0);
return {
schemaVersion: 1,
worldId: String(worldId || "").trim(),
chunkX: safeChunkX,
chunkY: safeChunkY,
width: chunkWidth,
height: chunkHeight,
backgroundTileId: "",
roomLayers: [
{
layer: 0,
rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)),
instanceIds: [],
},
{
layer: 1,
rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)),
instanceIds: [],
},
],
heightLayers: [],
instances: [],
};
}
export function normalizeCachedWorldChunkPayload({ chunkPayload, chunkX, chunkY, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0);
const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0);
const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(chunkWidth) || 32));
const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(chunkHeight) || 32));
const instances = normalizeWorldChunkInstances({
sourceInstances: chunkPayload?.instances,
chunkX: safeChunkX,
chunkY: safeChunkY,
width: safeWidth,
height: safeHeight,
options,
cloneValue,
runtimeUniqueId,
});
const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight);
return {
schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)),
worldId: String(chunkPayload?.worldId || worldId || "").trim(),
chunkX: safeChunkX,
chunkY: safeChunkY,
width: safeWidth,
height: safeHeight,
backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(),
roomLayers,
heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers),
instances,
};
}
export function isChunkFillSymbol(ch, fillChar) {
const symbol = String(ch || "").charAt(0);
return !symbol || symbol === fillChar || symbol === "." || symbol === " ";
}
export function isWorldChunkPayloadEmpty({ chunkPayload, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId }) {
const normalized = normalizeCachedWorldChunkPayload({
chunkPayload,
chunkX: chunkPayload?.chunkX,
chunkY: chunkPayload?.chunkY,
chunkWidth,
chunkHeight,
worldId,
cloneValue,
runtimeUniqueId,
});
if (String(normalized?.backgroundTileId || "").trim()) {
return false;
}
if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) {
return false;
}
if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => (
Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || "")))
))) {
return false;
}
return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => {
const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " ";
return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => {
const sourceRow = String(row || "");
for (let index = 0; index < sourceRow.length; index += 1) {
if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) {
return true;
}
}
return false;
});
});
}
export function transformChunkLocalCoord(localX, localY, width, height, operation) {
const safeX = Math.floor(Number(localX) || 0);
const safeY = Math.floor(Number(localY) || 0);
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
switch (String(operation || "").trim()) {
case "flipHorizontal":
return { x: (safeWidth - 1) - safeX, y: safeY };
case "flipVertical":
return { x: safeX, y: (safeHeight - 1) - safeY };
case "rotate180":
return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY };
case "rotate90cw":
if (safeWidth !== safeHeight) {
return null;
}
return { x: (safeWidth - 1) - safeY, y: safeX };
case "rotate90ccw":
if (safeWidth !== safeHeight) {
return null;
}
return { x: safeY, y: (safeHeight - 1) - safeX };
default:
return { x: safeX, y: safeY };
}
}
export function transformChunkRows(rows, width, height, fillChar, operation) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar);
const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " "));
for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) {
const sourceRow = sourceRows[rowIndex];
for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) {
const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " ";
if (isChunkFillSymbol(char, fillChar)) {
continue;
}
const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation);
if (!nextCoord) {
continue;
}
nextRows[nextCoord.y][nextCoord.x] = char;
}
}
return nextRows.map((row) => row.join(""));
}
export function transformChunkHeightPatch(patch, width, height, operation) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : [];
const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0);
const patchHeight = sourceRows.length;
const transformedCells = [];
for (let localY = 0; localY < patchHeight; localY += 1) {
const row = sourceRows[localY] || "";
for (let localX = 0; localX < patchWidth; localX += 1) {
const char = String(row.charAt(localX) || " ").charAt(0) || " ";
if (char === " " || char === ".") {
continue;
}
const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX;
const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY;
if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) {
continue;
}
const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation);
if (!nextCoord) {
continue;
}
transformedCells.push({
x: nextCoord.x,
y: nextCoord.y,
char,
});
}
}
if (transformedCells.length <= 0) {
return null;
}
const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x);
const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x);
const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y);
const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y);
const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " "));
transformedCells.forEach((entry) => {
nextRows[entry.y - minY][entry.x - minX] = entry.char;
});
return {
id: String(patch?.id || "").trim(),
name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined,
z: Math.max(1, Math.floor(Number(patch?.z) || 1)),
x: minX,
y: minY,
rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")),
};
}
export function transformWorldChunkPayload({ chunkPayload, operation, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
const config = options && typeof options === "object" ? options : {};
const normalized = normalizeCachedWorldChunkPayload({
chunkPayload,
chunkX: chunkPayload?.chunkX,
chunkY: chunkPayload?.chunkY,
chunkWidth,
chunkHeight,
worldId,
cloneValue,
runtimeUniqueId,
options: config,
});
const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1));
const normalizedOperation = String(operation || "").trim();
if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) {
throw new Error("Chunk rotation requires square chunks.");
}
const instances = normalizeWorldChunkInstances({
sourceInstances: (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => {
const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation);
return {
...cloneValue(entry),
x: nextCoord?.x ?? entry.x,
y: nextCoord?.y ?? entry.y,
};
}),
chunkX: normalized.chunkX,
chunkY: normalized.chunkY,
width: safeWidth,
height: safeHeight,
options: config,
cloneValue,
runtimeUniqueId,
});
const roomLayers = buildWorldChunkLayerInstanceIds(
(Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({
...cloneValue(layer),
rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation),
})),
instances,
safeWidth,
safeHeight,
);
const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers)
.map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation))
.filter(Boolean)
.sort((left, right) => {
if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) {
return (Number(left?.z) || 0) - (Number(right?.z) || 0);
}
return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || ""));
});
return {
...normalized,
roomLayers,
heightLayers,
instances,
};
}