Back to Black Site Map Editor
SOURCE / PINNED RELEASE

Made of little things.

Black Site Map Editor

Release
32a57733b7e9…
Author-recorded commit
3f54890f542f…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor

Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/editor-controller.ts
import { followedAuthors } from './map-social';
import type { NostrEvent } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { readDeploymentControls, readObjectControls } from './editor-controls';
import { friendlyError } from './friendly-error';
import {
    MapHistory,
    createMap,
    cloneMap,
    place,
    hitTest,
    selectionValue,
    moveSelection,
    deleteSelection,
    resizeMap,
    validateMap,
    validatePlayable,
    type BlackSiteMap,
    type Layer,
    type Point,
    type Selection,
} from './model';
import {
    discoverMaps,
    loadMap,
    publishMap,
    deleteMap,
    saveDraft,
    loadDraft,
    resolveMap,
    saveTutorialSeen,
    type MapRecord,
} from './map-host';
import type { MapIntent } from './map-intent';
import type { Shortcuts } from './shortcuts';
import { store, type EditorState, type WorkspaceTab } from './state/editor-state';
import { toolLabel } from './tools';

/** View hooks the controller drives imperatively; components register themselves. */
export interface CanvasView {
    fit(): void;
    zoom(factor: number): void;
    draw(): void;
}
export interface ConfirmView {
    ask(message: string, title: string): Promise<boolean>;
    readonly isOpen: boolean;
}
export interface DialogView {
    show(stepId?: string): void;
    close(): void;
    readonly isOpen: boolean;
}

type MapContext = {
    base?: MapRecord;
    remixOf?: NostrEvent;
    remixRelay?: string;
    content?: string;
    hashtags?: string[];
    image?: string;
};
export type MetadataFields = {
    id: string;
    name: string;
    desc: string;
    style: BlackSiteMap['theme']['style'];
    ground: string;
    road: string;
    markings: boolean;
};

const hexRgb = (value: string): [number, number, number] =>
    [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16)) as [number, number, number];
const freshId = (id: string) => `${id.slice(0, 19)}-${Date.now().toString(36)}`;

export class EditorController {
    readonly ui: {
        canvas?: CanvasView;
        confirm?: ConfirmView;
        publication?: DialogView;
        library?: DialogView;
        help?: DialogView;
        settings?: DialogView;
        shortcuts?: DialogView;
    } = {};
    shortcuts: Shortcuts = { states: () => [], get: () => undefined, close() {} };
    #history = new MapHistory(store.state.map);
    #identityEpoch = 0;
    #requestEpoch = 0;
    #workspaceVersion = 0;
    #publishReceipt: unknown;
    #libraryAuthor: string | undefined;
    #following = false;
    #nextUntil: number | undefined;
    #pendingIntent: MapIntent | undefined;
    #intentInProgress = false;
    #intentVersion = 0;

    get state(): EditorState {
        return store.state;
    }
    get map(): BlackSiteMap {
        return this.#history.current;
    }
    get workspaceVersion(): number {
        return this.#workspaceVersion;
    }
    get intentPending(): boolean {
        return Boolean(this.#pendingIntent) || this.#intentInProgress;
    }
    get anyDialogOpen(): boolean {
        return [...document.querySelectorAll('dialog')].some((dialog) => dialog.open);
    }

    // ---- state plumbing ----
    /** Publish the history and an optional patch; drops a selection whose object no longer exists. */
    #sync(patch: Partial<EditorState> = {}) {
        const map = this.#history.current;
        let selection = 'selection' in patch ? patch.selection : store.state.selection;
        if (selection && !selectionValue(map, selection)) selection = undefined;
        store.set({
            ...patch,
            map,
            canUndo: this.#history.canUndo,
            canRedo: this.#history.canRedo,
            selection,
        });
    }
    status(message: string, kind = '') {
        store.set({ status: { message, kind } });
    }
    fail = (error: unknown) => this.status(friendlyError(error), 'error');
    /** Wrap a handler so thrown or rejected errors become a status message. */
    guard = (fn: () => unknown) => () => {
        try {
            const result = fn();
            if (result instanceof Promise) result.catch(this.fail);
        } catch (error) {
            this.fail(error);
        }
    };
    bumpShortcuts() {
        store.set({ shortcutsRev: store.state.shortcutsRev + 1 });
    }

    // ---- workspace ----
    confirmAction(message: string, title = 'Confirm'): Promise<boolean> {
        return this.ui.confirm ? this.ui.confirm.ask(message, title) : Promise.resolve(false);
    }
    async replaceAllowed() {
        return (
            !store.state.dirty ||
            (await this.confirmAction(
                'Replace your unsaved map? Save a draft or download a backup first to keep it.',
                'Replace map',
            ))
        );
    }
    setMap(value: BlackSiteMap, context: MapContext = {}, dirty = false) {
        if (store.state.busy) throw new Error('Wait for the current publication to finish.');
        this.#workspaceVersion++;
        this.#requestEpoch++;
        this.#history = new MapHistory(value);
        this.#publishReceipt = undefined;
        this.#sync({
            selection: undefined,
            base: context.base,
            remixOf: context.remixOf,
            remixRelay: context.remixRelay,
            content: context.content ?? '',
            hashtags: context.hashtags ?? [],
            image: context.image,
            dirty,
            publishDetails: '',
        });
        this.ui.canvas?.fit();
    }
    commit(value: BlackSiteMap, patch: Partial<EditorState> = {}) {
        const errors = validateMap(value);
        if (errors.length) throw new Error(errors.join('\n'));
        this.#history.commit(value);
        this.#workspaceVersion++;
        this.#sync({ ...patch, dirty: true });
    }
    undo() {
        if (store.state.busy) return;
        this.#history.undo();
        this.#workspaceVersion++;
        this.#sync({ dirty: true, selection: undefined });
    }
    redo() {
        if (store.state.busy) return;
        this.#history.redo();
        this.#workspaceVersion++;
        this.#sync({ dirty: true, selection: undefined });
    }
    updateMetadata(fields: MetadataFields) {
        const m = cloneMap(this.map);
        m.id = fields.id;
        m.name = fields.name;
        m.desc = fields.desc;
        m.theme.style = fields.style;
        m.theme.ground = hexRgb(fields.ground);
        m.theme.road = hexRgb(fields.road);
        m.theme.markings = fields.markings;
        this.commit(m);
    }
    resize(width: number, height: number) {
        this.commit(resizeMap(this.map, width, height));
        this.ui.canvas?.fit();
    }
    setContent(content: string) {
        this.#workspaceVersion++;
        store.set({ content, dirty: true });
    }
    setHashtags(value: string) {
        const hashtags = value
            .split(',')
            .map((s) => s.trim().toLowerCase())
            .filter(Boolean);
        this.#workspaceVersion++;
        store.set({ hashtags, dirty: true });
    }
    applyDeployment(container: HTMLElement) {
        this.commit(readDeploymentControls(container, this.map));
        this.status('Extra scenery and enemies updated.');
    }
    applyJson(source: string) {
        const value = JSON.parse(source);
        const errors = validateMap(value);
        if (errors.length) throw new Error(errors.join('\n'));
        const base = store.state.base;
        if (base && value.id !== base.metadata.id)
            throw new Error(
                'A published map keeps its ID. Use New or Remix to publish a separate map.',
            );
        this.commit(value, { selection: undefined });
        this.ui.canvas?.fit();
        this.status('Map JSON applied.');
    }
    validate() {
        const errors = validatePlayable(this.map);
        store.set({
            validation: errors.length
                ? errors.map(friendlyError).join('\n')
                : 'Ready to play · insertion can reach extraction.',
        });
        this.status(
            errors.length ? 'Map needs attention. See Map Check.' : 'Your map is ready to play.',
            errors.length ? 'error' : 'success',
        );
    }

    // ---- tools, selection and canvas edits ----
    setTool(tool: string) {
        store.set({ tool });
        this.status(`${toolLabel(tool)} tool ready.`);
    }
    setPlaceOptions(patch: Partial<EditorState['placeOptions']>) {
        store.set({ placeOptions: { ...store.state.placeOptions, ...patch } });
    }
    toggleGrid() {
        store.set({ showGrid: !store.state.showGrid });
    }
    setWorkspaceTab(workspaceTab: WorkspaceTab) {
        store.set({ workspaceTab });
    }
    select(selection: Selection | undefined) {
        store.set({ selection });
    }
    selectObject(selection: Selection) {
        this.setTool('select');
        this.select(selection);
    }
    selectAt(p: Point): Selection | undefined {
        const selection = hitTest(this.map, ...p);
        this.select(selection);
        return selection;
    }
    moveSelectionBy(dx: number, dy: number, selection = store.state.selection) {
        if (selection) this.commit(moveSelection(this.map, selection, dx, dy));
    }
    nudge(dx: number, dy: number) {
        if (!store.state.selection) {
            this.status('Select an object first, then use the arrow keys to move it.');
            return;
        }
        this.moveSelectionBy(dx, dy);
    }
    eraseAt(p: Point) {
        const s = hitTest(this.map, ...p);
        if (s && !['start', 'exfil'].includes(s.layer))
            this.commit(deleteSelection(this.map, s), { selection: undefined });
    }
    placeRect(mode: string, from: Point, to: Point) {
        const { selection, placeOptions } = store.state;
        const result = place(this.map, mode as Layer, from, to, {
            color: placeOptions.color,
            patrol: placeOptions.patrol,
            cacheKind: placeOptions.cacheKind || undefined,
            buildingIndex:
                selection?.layer === 'buildings' ? selection.index : selection?.buildingIndex,
        });
        this.commit(result.map, { selection: result.selection });
        this.status(`Placed ${toolLabel(mode).toLowerCase()}.`);
    }
    applyObject(container: HTMLElement) {
        const selection = store.state.selection;
        if (!selection) return;
        this.commit(readObjectControls(container, this.map, selection));
        this.status('Object updated.');
    }
    deleteSelected() {
        const selection = store.state.selection;
        if (selection && !['start', 'exfil'].includes(selection.layer))
            this.commit(deleteSelection(this.map, selection), { selection: undefined });
    }

    // ---- files ----
    download(value: string, filename: string) {
        const blob = new Blob([value], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
        store.set({
            exportFallback: 'If the download does not appear, use Save draft to keep a backup.',
        });
    }
    exportJson() {
        this.download(JSON.stringify(this.map, null, 2), `${this.map.id}.json`);
    }
    exportEvent() {
        const { base, remixOf, remixRelay, content, hashtags } = store.state;
        this.download(
            JSON.stringify(
                this.#publishReceipt ?? {
                    map: this.map,
                    base: base?.event,
                    remixOf,
                    remixRelay,
                    content,
                    hashtags,
                },
                null,
                2,
            ),
            `${this.map.id}-publication.json`,
        );
    }
    async importFile(file: File) {
        if (file.size > 262144)
            throw new Error('This map file is too large. Choose a smaller map file.');
        const value = JSON.parse(await file.text());
        const errors = validateMap(value);
        if (errors.length) throw new Error(errors.join('\n'));
        if (await this.replaceAllowed()) {
            this.setMap(value, {}, true);
            this.status('Imported a local copy. Publishing creates a map under your identity.');
        }
    }
    async newMap() {
        if (store.state.busy || !(await this.replaceAllowed())) return;
        this.setMap(createMap(`map-${Date.now().toString(36)}`));
        this.status('New map. Place insertion, extraction, terrain and units.');
    }
    async remixCurrent() {
        const { busy, viewer, base } = store.state;
        if (busy || !viewer || !base) return;
        const epoch = this.#identityEpoch;
        if (
            !(await this.confirmAction(
                'Create a new map from this one and keep the original credits?',
                'Remix current map',
            ))
        )
            return;
        if (store.state.busy || epoch !== this.#identityEpoch) return;
        const value = cloneMap(this.map);
        value.id = freshId(value.id);
        const { content, hashtags, image } = store.state;
        this.setMap(
            value,
            {
                remixOf: base.event,
                remixRelay: base.relayHints[0],
                content,
                hashtags,
                image,
            },
            true,
        );
        this.status(
            'Remix ready under your current identity. Original and parent credits retained.',
        );
    }

    // ---- library ----
    #library(patch: Partial<EditorState['library']>) {
        store.set({ library: { ...store.state.library, ...patch } });
    }
    async openLibrary() {
        if (store.state.busy) return;
        this.ui.library?.show();
        if (store.state.viewer) await this.browse(store.state.viewer);
        else this.showTemplates();
    }
    showTemplates() {
        if (store.state.busy) return;
        this.#requestEpoch++;
        this.#library({
            view: 'templates',
            loadMore: false,
            status: 'Built-in Black Site maps · exact map tiles, procedural generation removed. Use as a local starting point.',
        });
    }
    async useTemplate(template: BlackSiteMap) {
        if (store.state.busy || !(await this.replaceAllowed())) return;
        const m = cloneMap(template);
        m.id = freshId(m.id);
        this.setMap(m, { content: `Based on Black Site built-in ${template.name}.` }, true);
        this.ui.library?.close();
        this.status('Template ready. Author attribution is included in publication notes.');
    }
    searchAuthor(value: string) {
        const author = value.trim().toLowerCase();
        if (!/^[0-9a-f]{64}$/.test(author))
            throw new Error('Enter a 64-character hex Nostr public key.');
        return this.browse(author);
    }
    loadMore() {
        return this.browse(this.#libraryAuthor, true, this.#following);
    }
    async browse(author?: string, append = false, following = false) {
        if (author === '') {
            this.#library({ status: 'Connect your Nostr identity in the host to load your maps.' });
            return;
        }
        if (!runtimeHasDomain('outbox'))
            throw new Error('Map browsing is unavailable here. Try another host.');
        const epoch = ++this.#requestEpoch,
            accountEpoch = this.#identityEpoch;
        this.#libraryAuthor = author;
        this.#following = following;
        this.#library({
            status: 'Finding maps…',
            ...(append ? {} : { view: 'records', records: [], loadMore: false }),
        });
        try {
            let authors: string[] | undefined;
            if (following) {
                if (!store.state.viewer)
                    throw new Error('Sign in to browse maps from people you follow.');
                authors = await followedAuthors(store.state.viewer);
                if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
                if (!authors.length) {
                    this.#library({
                        view: 'records',
                        records: [],
                        loadMore: false,
                        status: 'You are not following anyone yet. Follow creators in your Nostr client, or explore Global maps.',
                    });
                    return;
                }
            }
            const result = await discoverMaps({
                author,
                authors,
                until: append ? this.#nextUntil : undefined,
            });
            if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
            const previous = store.state.library.records;
            const records = append
                ? [
                      ...previous,
                      ...result.maps.filter(
                          (m) => !previous.some((r) => r.metadata.address === m.metadata.address),
                      ),
                  ]
                : result.maps;
            this.#nextUntil = result.nextUntil;
            this.#library({
                view: 'records',
                records,
                loadMore: this.#nextUntil !== undefined && !result.paginationBlocked,
                status: `${records.length} maps${result.invalid ? ' · some maps could not be opened' : ''}${result.paginationBlocked ? ' · more maps may be available; try again later' : ''}${result.incomplete ? ' · some results are missing; try again' : ''}${result.error ? ' · maps could not be loaded; try again' : ''}${!records.length && !result.incomplete && !result.error ? ' · no published maps found' : ''}`,
            });
        } catch (error) {
            if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
            this.#library({
                view: 'records',
                records: [],
                loadMore: false,
                status:
                    error instanceof Error
                        ? error.message
                        : 'Maps could not be loaded. Please retry.',
            });
        }
    }

    async deleteRecord(record: MapRecord) {
        const m = record.metadata;
        if (
            !(await this.confirmAction(
                `Remove ${m.title} from your published maps? Copies shared by others may remain.`,
                `Delete published map`,
            ))
        )
            return;
        const epoch = this.#identityEpoch;
        await deleteMap(record);
        if (epoch !== this.#identityEpoch) return;
        this.#library({
            records: store.state.library.records.filter((r) => r.metadata.address !== m.address),
        });
        this.status('Deletion request published.', 'success');
        return true;
    }
    async openRecord(record: MapRecord, remix: boolean) {
        if (store.state.busy || !(await this.replaceAllowed())) return;
        if (!runtimeHasDomain('resource'))
            throw new Error('Map downloads are unavailable here. Try another host.');
        const epoch = this.#identityEpoch,
            version = this.#workspaceVersion,
            request = ++this.#requestEpoch;
        this.#library({ status: 'Opening map…' });
        const loaded = await loadMap(record);
        if (
            epoch !== this.#identityEpoch ||
            request !== this.#requestEpoch ||
            version !== this.#workspaceVersion ||
            store.state.busy
        )
            return;
        if (remix) {
            loaded.id = freshId(loaded.id);
            this.setMap(
                loaded,
                {
                    remixOf: record.event,
                    remixRelay: record.relayHints[0],
                    content: record.metadata.content,
                    hashtags: record.metadata.hashtags,
                },
                true,
            );
        } else {
            if (record.event.pubkey !== store.state.viewer)
                throw new Error('Only the author can edit this map. Use Remix.');
            this.setMap(loaded, {
                base: record,
                content: record.metadata.content,
                hashtags: record.metadata.hashtags,
                image: record.metadata.image ?? undefined,
            });
        }
        this.ui.library?.close();
        this.status(
            remix
                ? 'Remix ready. Parent, original and author credits will be preserved.'
                : 'Map opened. Publishing will save your changes to this map.',
            'success',
        );
    }

    // ---- drafts and publication ----
    #draft() {
        const { base, remixOf, remixRelay, content, hashtags, image } = store.state;
        return {
            map: cloneMap(this.map),
            base,
            remixOf,
            remixRelay,
            content,
            hashtags,
            image,
            savedAt: Date.now(),
        };
    }
    async saveDraft() {
        const key = store.state.viewer || 'guest';
        const epoch = this.#identityEpoch,
            version = this.#workspaceVersion;
        await saveDraft(key, this.#draft());
        if (epoch !== this.#identityEpoch) return;
        if (version === this.#workspaceVersion) store.set({ dirty: false });
        this.status('Draft saved.', 'success');
    }
    async restoreDraft() {
        if (store.state.busy) return;
        if (!runtimeHasDomain('storage'))
            throw new Error('Draft storage is unavailable in this host.');
        const epoch = this.#identityEpoch;
        const saved = await loadDraft(store.state.viewer || 'guest');
        if (epoch !== this.#identityEpoch) return;
        if (!saved) {
            this.#library({ status: 'No saved draft for this identity.' });
            return;
        }
        if (!(await this.replaceAllowed())) return;
        if (epoch !== this.#identityEpoch || store.state.busy) return;
        this.setMap(saved.map, saved);
        this.ui.library?.close();
        this.status('Saved draft restored.', 'success');
    }
    async publish() {
        if (store.state.busy) return;
        store.set({ publication: { phase: 'idle', message: '', stage: 0, warnings: [] } });
        const epoch = this.#identityEpoch;
        const update = (patch: Partial<EditorState['publication']>) =>
            store.set({ publication: { ...store.state.publication, ...patch } });
        const warn = (message: string) =>
            update({ warnings: [...store.state.publication.warnings, message] });
        try {
            const errors = validatePlayable(this.map);
            store.set({ validation: errors.map(friendlyError).join('\n') });
            if (errors.length)
                throw new Error('Check your map and clear a path to extraction before publishing.');
            const { viewer, base } = store.state;
            if (!viewer) throw new Error('Connect your Nostr identity in the host first.');
            if (
                !(await this.confirmAction(
                    `${base ? 'Update' : 'Publish'} ${this.map.name} using your account? Your map and creator credits will be public. You may be asked to approve sharing.`,
                    base ? 'Update map' : 'Publish map',
                ))
            )
                return;
            if (store.state.busy) return;
            if (store.state.viewer !== viewer || this.#identityEpoch !== epoch)
                throw new Error('Identity changed during confirmation. Review and confirm again.');
            const snapshot = this.#draft();
            store.set({
                busy: true,
                publication: {
                    phase: 'working',
                    message: 'Saving a draft backup…',
                    stage: 0,
                    warnings: [],
                },
            });
            this.ui.publication?.show();
            if (runtimeHasDomain('storage')) {
                try {
                    await saveDraft(viewer, snapshot);
                } catch {
                    warn(
                        'A draft backup could not be saved. Keep this window open or download your map.',
                    );
                }
            }
            if (epoch !== this.#identityEpoch)
                throw new Error(
                    'Identity changed before publication. Retry under the intended account.',
                );
            const published = await publishMap(snapshot.map, {
                base: snapshot.base,
                remixOf: snapshot.remixOf,
                remixRelay: snapshot.remixRelay,
                content: snapshot.content,
                hashtags: snapshot.hashtags,
                image: snapshot.image,
                onStatus: (message) => {
                    if (epoch !== this.#identityEpoch) return;
                    const stage = /Sharing your map/.test(message)
                        ? 2
                        : /Uploading|Waiting for sharing/.test(message)
                          ? 1
                          : 0;
                    update({ message, stage });
                    this.status(message);
                },
            });
            if (epoch !== this.#identityEpoch)
                throw new Error(
                    'Your account changed during publication. Open My maps to confirm the result before retrying.',
                );
            this.#publishReceipt = published.event;
            store.set({
                base: published,
                remixOf: undefined,
                remixRelay: undefined,
                dirty: false,
                publishDetails: JSON.stringify(published.event, null, 2),
            });
            update({
                phase: 'success',
                message: `${published.metadata.title} published successfully.`,
                stage: 3,
            });
            this.status(`${published.metadata.title} published.`, 'success');
            if (runtimeHasDomain('storage')) {
                try {
                    await saveDraft(viewer, this.#draft());
                } catch {
                    warn('Your map was published, but the local draft backup could not be saved.');
                }
            }
        } catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            if (epoch === this.#identityEpoch) {
                const details = error as { upload?: unknown; template?: unknown };
                this.#publishReceipt = {
                    upload: details?.upload,
                    template: details?.template,
                    error: message,
                };
                store.set({
                    publishDetails: JSON.stringify(this.#publishReceipt, null, 2),
                    dirty: true,
                });
                this.fail(error);
            }
            update({
                phase: 'error',
                message:
                    epoch === this.#identityEpoch
                        ? friendlyError(error)
                        : 'Your account changed. Open My maps to check whether publication completed before retrying.',
                error: message,
            });
            this.ui.publication?.show();
        } finally {
            store.set({ busy: false });
            if (this.#pendingIntent) void this.processPendingIntent();
        }
    }

    // ---- identity and inbound map requests ----
    identityChanged = (pubkey: string) => {
        if (pubkey === store.state.viewer) return;
        this.#identityEpoch++;
        this.#requestEpoch++;
        this.#nextUntil = undefined;
        const base = store.state.base;
        store.set({
            viewer: pubkey,
            identityLabel: pubkey ? 'Signed in' : 'Sign in to share maps',
            library: { ...store.state.library, view: 'none', records: [], loadMore: false },
        });
        if (base && base.event.pubkey !== pubkey)
            this.status(
                'Identity changed. This published map belongs to another account. Export it or create a remix; updates are disabled.',
                'error',
            );
        else
            this.status(
                pubkey
                    ? 'Identity connected. Open Maps to load your published maps.'
                    : 'Connect your Nostr identity in the host to manage published maps. Local authoring is available.',
            );
        if (this.#pendingIntent) void this.processPendingIntent();
    };
    get identityEpoch() {
        return this.#identityEpoch;
    }
    setIdentityLabel(identityLabel: string) {
        store.set({ identityLabel });
    }
    tutorialClosed() {
        // Closing by any route marks it seen; a failed write only means it may auto-open again.
        if (runtimeHasDomain('storage')) saveTutorialSeen().catch(() => {});
    }
    confirmClosed() {
        setTimeout(() => {
            if (this.#pendingIntent) void this.processPendingIntent();
        }, 0);
    }
    receiveMapIntent = async (request: MapIntent) => {
        this.#pendingIntent = request;
        this.#intentVersion++;
        if (store.state.busy || this.#intentInProgress) {
            this.status('Another napplet requested a map. It will open after the current map.');
            return;
        }
        await this.processPendingIntent();
    };
    #closeOverlays() {
        this.ui.library?.close();
        this.ui.help?.close();
    }
    async processPendingIntent() {
        if (
            !this.#pendingIntent ||
            store.state.busy ||
            this.#intentInProgress ||
            this.ui.confirm?.isOpen ||
            this.ui.publication?.isOpen
        )
            return;
        const request = this.#pendingIntent;
        if (request.action !== 'new' && !store.state.viewer) {
            this.status(
                'A napplet requested a published map. Connect your Nostr identity in the host to open it.',
            );
            return;
        }
        this.#pendingIntent = undefined;
        this.#intentInProgress = true;
        const deliveryVersion = this.#intentVersion,
            accountVersion = this.#identityEpoch,
            workspace = this.#workspaceVersion;
        const stale = () =>
            deliveryVersion !== this.#intentVersion ||
            accountVersion !== this.#identityEpoch ||
            workspace !== this.#workspaceVersion ||
            store.state.busy;
        try {
            if (!(await this.replaceAllowed())) {
                this.status('Map request cancelled. Your workspace is unchanged.');
                return;
            }
            if (stale()) return;
            if (request.action === 'new') {
                this.setMap(request.map ?? createMap(`map-${Date.now().toString(36)}`), {}, true);
                this.#closeOverlays();
                this.status(
                    'New map opened from another napplet. Review the map before publishing.',
                );
                return;
            }
            const source = await resolveMap(request.target!);
            if (stale()) return;
            let remix = request.action === 'remix';
            if (source.event.pubkey !== store.state.viewer && !remix) {
                if (request.action === 'edit')
                    throw new Error(
                        'This map belongs to another player. Open Maps and choose Remix to make your own version.',
                    );
                if (
                    !(await this.confirmAction(
                        'This map belongs to another player. Create your own remix with the original author credits?',
                        'Open as remix',
                    ))
                )
                    return;
                remix = true;
            }
            if (stale()) return;
            const value = await loadMap(source);
            if (stale()) return;
            this.#closeOverlays();
            this.setWorkspaceTab('map');
            if (remix) {
                value.id = freshId(value.id);
                this.setMap(
                    value,
                    {
                        remixOf: source.event,
                        remixRelay: source.relayHints[0],
                        content: source.metadata.content,
                        hashtags: source.metadata.hashtags,
                    },
                    true,
                );
            } else
                this.setMap(value, {
                    base: source,
                    content: source.metadata.content,
                    hashtags: source.metadata.hashtags,
                    image: source.metadata.image ?? undefined,
                });
            this.status(
                remix
                    ? 'Your remix is ready. Original creator credits are preserved.'
                    : 'Your map is open for editing.',
                'success',
            );
        } catch (error) {
            this.fail(error);
        } finally {
            this.#intentInProgress = false;
            if (this.#pendingIntent) void this.processPendingIntent();
        }
    }
}

export const editor = new EditorController();
/** Shorthand for template handlers that report errors in the status line. */
export const guard = editor.guard;