diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index c11200f9..39148043 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -160,6 +160,12 @@ "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2" }, + { + "name": "backrooms-canvas", + "source": "extensions/backrooms-canvas", + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "version": "1.0.0" + }, { "name": "cast-imaging", "source": "plugins/cast-imaging", @@ -550,33 +556,6 @@ "description": "Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes.", "version": "2.0.0" }, - { - "name": "foundry-agent-canvas", - "description": "Interactive Copilot canvas for designing, configuring, testing, and deploying Microsoft Foundry hosted agents.", - "version": "1.0.2", - "author": { - "name": "Microsoft", - "url": "https://www.microsoft.com" - }, - "repository": "https://github.com/microsoft/foundry-toolkit", - "homepage": "https://github.com/microsoft/foundry-toolkit", - "license": "MIT", - "keywords": [ - "agent-builder", - "agent-inspector", - "azure-ai", - "foundry", - "hosted-agents", - "microsoft-foundry", - "canvas" - ], - "source": { - "source": "github", - "repo": "microsoft/foundry-toolkit", - "path": "foundry-agent-canvas", - "sha": "4f2d5fe02f6d89cac43e1a06de39c127cb53a55d" - } - }, { "name": "frontend-web-dev", "source": "plugins/frontend-web-dev", @@ -587,7 +566,7 @@ "name": "gem-team", "source": "plugins/gem-team", "description": "Self-Learning Multi-agent orchestration framework for spec-driven development and automated verification. With smarter tool calling and leaner context.", - "version": "1.84.0" + "version": "1.87.0" }, { "name": "gesture-review", @@ -767,6 +746,33 @@ "repo": "microsoft/Build-CLI" } }, + { + "name": "microsoft-foundry", + "description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.", + "version": "1.0.3", + "author": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "repository": "https://github.com/microsoft/foundry-toolkit", + "homepage": "https://github.com/microsoft/foundry-toolkit", + "license": "MIT", + "keywords": [ + "agent-builder", + "agent-inspector", + "azure-ai", + "foundry", + "hosted-agents", + "microsoft-foundry", + "canvas" + ], + "source": { + "source": "github", + "repo": "microsoft/foundry-toolkit", + "path": "microsoft-foundry", + "sha": "9e5fae9942514a8b90281886285052a99fc1932b" + } + }, { "name": "modernize-dotnet", "description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.", @@ -1181,7 +1187,7 @@ { "name": "ui5", "description": "SAPUI5 / OpenUI5 plugin for GitHub CoPilot. Create and validate UI5 projects, access API documentation, run UI5 linter, get development guidelines and best practices for UI5 development.", - "version": "0.1.4", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1202,13 +1208,13 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5", - "sha": "80f2d93287054f9d30dd990e842e15bcfca581c9" + "ref": "v0.1.7" } }, { "name": "ui5-modernization", "description": "Complete UI5 modernization toolkit with workflow and specialized fix patterns for modernizing SAPUI5/OpenUI5 applications", - "version": "0.1.6", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1228,13 +1234,13 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5-modernization", - "ref": "v0.1.6" + "ref": "v0.1.7" } }, { "name": "ui5-typescript-conversion", "description": "SAPUI5 / OpenUI5 plugin for GitHub CoPilot. Convert JavaScript based UI5 projects to TypeScript.", - "version": "0.1.4", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1256,19 +1262,24 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5-typescript-conversion", - "sha": "80f2d93287054f9d30dd990e842e15bcfca581c9" + "ref": "v0.1.7" } }, + { + "name": "uizze", + "source": "plugins/uizze", + "description": "Stop generic UI from shipping. Ground GitHub Copilot in 800,000+ real web and iOS screens, write a product-specific design contract, and enforce a hard finish gate.", + "version": "1.0.0" + }, { "name": "upgrade-agent", - "description": "GitHub Copilot upgrade is an AI-powered agent that helps you upgrade applications to newer versions of languages, frameworks, and runtimes. It assesses your application, creates an upgrade plan, applies code changes, and validates the results through an interactive upgrade workflow.", - "version": "1.1.222", + "description": "AI-powered upgrade assistant for upgrading and migrating applications. Helps modernize legacy code and upgrade .NET applications to current frameworks.", + "version": "1.1.247", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" }, - "repository": "https://github.com/microsoft/upgrade-agent-plugins", - "license": "MIT", + "homepage": "https://github.com/microsoft/upgrade-agent-plugins", "keywords": [ "modernization", "upgrade", @@ -1276,11 +1287,13 @@ "dotnet", "canvas" ], + "license": "MIT", + "repository": "https://github.com/microsoft/upgrade-agent-plugins", "source": { "source": "github", "repo": "microsoft/upgrade-agent-plugins", "path": "plugins/upgrade-agent", - "sha": "379d344e42823b25223f878c002f38fb3a2c1d2b" + "sha": "a70e1ae1ff63dd19ff874e874b4b587a5291f68a" } }, { diff --git a/agents/gem-browser-tester.agent.md b/agents/gem-browser-tester.agent.md index 5b860d18..6951a881 100644 --- a/agents/gem-browser-tester.agent.md +++ b/agents/gem-browser-tester.agent.md @@ -104,7 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -114,6 +114,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-code-simplifier.agent.md b/agents/gem-code-simplifier.agent.md index d44330d9..a1318d80 100644 --- a/agents/gem-code-simplifier.agent.md +++ b/agents/gem-code-simplifier.agent.md @@ -70,6 +70,7 @@ Code Smells: long param list, feature envy, primitive obsession, magic numbers, Principles: preserve behavior, small steps, version control, one thing at a time. Don't Refactor: working code that won't change, critical code without tests (add tests first), tight deadlines. Ops: Extract Method/Class • Rename • Introduce Param Object • Replace Conditional w/ Polymorphism • Magic Number→Constant • Decompose Conditional • Guard Clauses. +Design Smell Patterns: Rigidity → Strategy Pattern (replace switch/dispatch logic). Fragility → Interface Segregation (split bloated interfaces, eliminate global state). Immobility → Layer separation (extract pure functions from UI/DB). Viscosity → Reduce boilerplate (make clean path = easy path). Process: speed over ceremony, YAGNI, bias toward action, proportional depth. @@ -109,7 +110,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -119,6 +120,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-critic.agent.md b/agents/gem-critic.agent.md index 765f1797..2ea17468 100644 --- a/agents/gem-critic.agent.md +++ b/agents/gem-critic.agent.md @@ -53,6 +53,10 @@ IMPORTANT: Batch/join dependency-free steps; serialize only true dependencies wh - Simplicity: Less code / files / patterns, simplest approach? - Conventions: Right reasons? - Coupling: Too tight or too loose? + - Rigidity: Would this design make future changes cascade? Are modules too coupled? + - Fragility: Could changes here break unrelated functionality? Hidden dependencies? + - Immobility: Can business logic be extracted without carrying framework/UI/DB baggage? + - Viscosity: Is doing it right significantly harder than a shortcut? If so, simplify the clean path. - Future-proofing: For a future that may not come? - Synthesize: - Findings grouped by severity: blocking, warning, or suggestion. @@ -100,7 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -110,6 +114,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-debugger.agent.md b/agents/gem-debugger.agent.md index 66126a01..8f3765e4 100644 --- a/agents/gem-debugger.agent.md +++ b/agents/gem-debugger.agent.md @@ -112,7 +112,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -122,6 +122,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-designer-mobile.agent.md b/agents/gem-designer-mobile.agent.md index a96811b5..32e29bd6 100644 --- a/agents/gem-designer-mobile.agent.md +++ b/agents/gem-designer-mobile.agent.md @@ -192,7 +192,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -202,6 +202,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-designer.agent.md b/agents/gem-designer.agent.md index d17b5e5a..5eedfce4 100644 --- a/agents/gem-designer.agent.md +++ b/agents/gem-designer.agent.md @@ -154,7 +154,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -164,6 +164,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-devops.agent.md b/agents/gem-devops.agent.md index c22bb791..abccb8f1 100644 --- a/agents/gem-devops.agent.md +++ b/agents/gem-devops.agent.md @@ -157,7 +157,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -167,6 +167,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-documentation-writer.agent.md b/agents/gem-documentation-writer.agent.md index 1e721d9e..1c4924a9 100644 --- a/agents/gem-documentation-writer.agent.md +++ b/agents/gem-documentation-writer.agent.md @@ -137,7 +137,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -147,6 +147,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-implementer-mobile.agent.md b/agents/gem-implementer-mobile.agent.md index 57bf8e5d..cd8516e9 100644 --- a/agents/gem-implementer-mobile.agent.md +++ b/agents/gem-implementer-mobile.agent.md @@ -95,7 +95,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -105,6 +105,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-implementer.agent.md b/agents/gem-implementer.agent.md index 7660aa22..1d9109b3 100644 --- a/agents/gem-implementer.agent.md +++ b/agents/gem-implementer.agent.md @@ -94,7 +94,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -104,6 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-mobile-tester.agent.md b/agents/gem-mobile-tester.agent.md index 89e0ed88..72b94839 100644 --- a/agents/gem-mobile-tester.agent.md +++ b/agents/gem-mobile-tester.agent.md @@ -115,7 +115,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -125,6 +125,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-orchestrator.agent.md b/agents/gem-orchestrator.agent.md index c1338fd9..fef1870a 100644 --- a/agents/gem-orchestrator.agent.md +++ b/agents/gem-orchestrator.agent.md @@ -21,9 +21,7 @@ IMPORTANT: You MUST STRICTLY perform `orchestration_work` only. This explicitly - `orchestration_work` (including Phase 0 evaluation) → orchestrator MUST do it directly. - `project_work` (Phases 1 through 4 task execution) → delegate to agent. -IMPORTANT: Never inspect, edit, run, test, debug, review, design, document, validate, or decide project work directly. `Phase 0` is your non-delegable entry point for every single interaction. - -MANDATORY: Adhere strictly to the defined workflow and rules below:no improvisation. +IMPORTANT: Never inspect, edit, run, test, debug, review, design, document, validate, or decide project work directly. `Phase 0` is your non-delegable entry point for every single interaction. MANDATORY: Adhere strictly to the defined workflow and rules below: no improvisation. @@ -423,7 +421,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -432,6 +430,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Execute autonomously: ask only for true blockers. Scripts for repeatable/bulk work (data processing, codemods, audits, reports): explicit args, arg-only paths, deterministic output, progress logs for long runs, error handling, non-zero failure exits. Test on small input first. Retry transient failures 3×. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-planner.agent.md b/agents/gem-planner.agent.md index ec5d3dba..a2ba95b5 100644 --- a/agents/gem-planner.agent.md +++ b/agents/gem-planner.agent.md @@ -79,6 +79,11 @@ IMPORTANT: Focus strictly on architectural milestones, dependency mapping, and s - Explicitly check for hidden assumptions, missing pre-requisites, potential edge cases, or gaps in the requirements. - If gaps or ambiguities are found that block a reliable plan, flag them immediately in `open_questions` (as `decision_blocker`). - Ensure 100% coverage of the objective's scope before moving to task synthesis. +- Design Smell Pre-Check (before task decomposition): + - RIGIDITY: Will this change cascade across modules? Flag coupling risk, isolate via interfaces. + - FRAGILITY: Does this touch global state/singletons? Reduce blast radius, add encapsulation boundary. + - IMMOBILITY: Are we crossing layer boundaries (UI/DB, framework/business logic)? Flag layer violation, plan extraction. + - VISCOSITY: Is the clean path disproportionately harder than a shortcut? Simplify clean path first before decomposing. - Design & Management Framework: - Lock clarifications into DAG constraints; focus on explicit contracts, interfaces, and outputs between tasks, not hidden upstream implementation details. - Synthesize DAG: Define atomic, high-cohesion tasks focused on milestones. **Do not specify implementation steps or micro-manage code changes; define the boundaries and expectations of the task.** @@ -363,7 +368,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -373,6 +378,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-researcher.agent.md b/agents/gem-researcher.agent.md index 18564dcf..786bdfea 100644 --- a/agents/gem-researcher.agent.md +++ b/agents/gem-researcher.agent.md @@ -123,7 +123,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -133,6 +133,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. - Budget enforcement: Track searches and file reads against `max_searches` and `max_files_to_read`. Halt exploration and return current findings when budget exhausted. ### Constitutional diff --git a/agents/gem-reviewer.agent.md b/agents/gem-reviewer.agent.md index 9f7870eb..ccc47962 100644 --- a/agents/gem-reviewer.agent.md +++ b/agents/gem-reviewer.agent.md @@ -134,7 +134,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -144,6 +144,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-skill-creator.agent.md b/agents/gem-skill-creator.agent.md index 1fdc10b4..05aeb5b1 100644 --- a/agents/gem-skill-creator.agent.md +++ b/agents/gem-skill-creator.agent.md @@ -161,7 +161,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -171,6 +171,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 6d7363f0..ad276021 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -96,4 +96,5 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [the-workshop](../plugins/the-workshop/README.md) | Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it. | 6 items | multi-agent, coordination, desks, persistent-memory, agent-signals, developer-experience | | [typescript-mcp-development](../plugins/typescript-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in TypeScript/Node.js using the official SDK. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | typescript, mcp, model-context-protocol, nodejs, server-development | | [typespec-m365-copilot](../plugins/typespec-m365-copilot/README.md) | Comprehensive collection of prompts, instructions, and resources for building declarative agents and API plugins using TypeSpec for Microsoft 365 Copilot extensibility. | 3 items | typespec, m365-copilot, declarative-agents, api-plugins, agent-development, microsoft-365 | +| [uizze](../plugins/uizze/README.md) | Stop generic UI from shipping. Ground GitHub Copilot in 800,000+ real web and iOS screens, write a product-specific design contract, and enforce a hard finish gate. | 1 items | ui, design, frontend, ios, web, design-review, quality-gate | | [visual-pr](../plugins/visual-pr/README.md) | Capture, annotate, and embed screenshots and animated GIF demos in pull request descriptions. Includes Playwright-based UI capture, PIL image annotations, PR embedding workflows for GitHub and Azure DevOps, and screen recording with variable timing. | 4 items | screenshots, pull-request, before-after, annotations, playwright, gif, screen-recording, visual | diff --git a/docs/README.skills.md b/docs/README.skills.md index 28cfdc7b..600ca342 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -31,10 +31,12 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [acreadiness-assess](../skills/acreadiness-assess/SKILL.md)
`gh skills install github/awesome-copilot acreadiness-assess` | Run the AgentRC readiness assessment on the current repository and produce a static HTML dashboard at reports/index.html. Wraps `npx github:microsoft/agentrc readiness` and hands off rendering to the @ai-readiness-reporter custom agent. Supports policies (--policy) for org-specific scoring. Use when asked to assess, audit, or score the AI readiness of a repo. | `report-template.html` | | [acreadiness-generate-instructions](../skills/acreadiness-generate-instructions/SKILL.md)
`gh skills install github/awesome-copilot acreadiness-generate-instructions` | Generate tailored AI agent instruction files via AgentRC instructions command. Produces .github/copilot-instructions.md (default, recommended for Copilot in VS Code) plus optional per-area .instructions.md files with applyTo globs for monorepos. Use after running /acreadiness-assess to close gaps in the AI Tooling pillar. | None | | [acreadiness-policy](../skills/acreadiness-policy/SKILL.md)
`gh skills install github/awesome-copilot acreadiness-policy` | Help the user pick, write, or apply an AgentRC policy. Policies customise readiness scoring by disabling irrelevant checks, overriding impact/level, setting pass-rate thresholds, or chaining org baselines with team overrides. Use when the user asks about strict mode, AI-only scoring, custom weights, CI gating, or wants org-wide standardisation. | None | +| [ad-campaign-analyzer](../skills/ad-campaign-analyzer/SKILL.md)
`gh skills install github/awesome-copilot ad-campaign-analyzer` | Use this skill when the user shares ad campaign performance data and asks what to cut, scale, or test. Trigger for prompts like "analyze my ad campaigns", "where am I wasting ad spend", "reallocate my ad budget", "which ads are actually working", or "ROAS analysis". Do not trigger for campaign planning or creative generation without performance data. | None | | [add-educational-comments](../skills/add-educational-comments/SKILL.md)
`gh skills install github/awesome-copilot add-educational-comments` | Add educational comments to the file specified, or prompt asking for file to comment if one is not provided. | None | | [adobe-illustrator-scripting](../skills/adobe-illustrator-scripting/SKILL.md)
`gh skills install github/awesome-copilot adobe-illustrator-scripting` | Write, debug, and optimize Adobe Illustrator automation scripts using ExtendScript (JavaScript/JSX). Use when creating or modifying scripts that manipulate documents, layers, paths, text frames, colors, symbols, artboards, or any Illustrator DOM objects. Covers the complete JavaScript object model, coordinate system, measurement units, export workflows, and scripting best practices. | `references/object-model-quick-reference.md`
`scripts/batch-export-png.jsx`
`scripts/create-color-grid.jsx`
`scripts/find-replace-text.jsx` | | [agent-governance](../skills/agent-governance/SKILL.md)
`gh skills install github/awesome-copilot agent-governance` | Patterns and techniques for adding governance, safety, and trust controls to AI agent systems. Use this skill when:
- Building AI agents that call external tools (APIs, databases, file systems)
- Implementing policy-based access controls for agent tool usage
- Adding semantic intent classification to detect dangerous prompts
- Creating trust scoring systems for multi-agent workflows
- Building audit trails for agent actions and decisions
- Enforcing rate limits, content filters, or tool restrictions on agents
- Working with any agent framework (PydanticAI, CrewAI, OpenAI Agents, LangChain, AutoGen) | None | | [agent-owasp-compliance](../skills/agent-owasp-compliance/SKILL.md)
`gh skills install github/awesome-copilot agent-owasp-compliance` | Check any AI agent codebase against the OWASP Agentic Security Initiative (ASI) Top 10 risks.
Use this skill when:
- Evaluating an agent system's security posture before production deployment
- Running a compliance check against OWASP ASI 2026 standards
- Mapping existing security controls to the 10 agentic risks
- Generating a compliance report for security review or audit
- Comparing agent framework security features against the standard
- Any request like "is my agent OWASP compliant?", "check ASI compliance", or "agentic security audit" | None | +| [agent-skill-stack](../skills/agent-skill-stack/SKILL.md)
`gh skills install github/awesome-copilot agent-skill-stack` | Find, evaluate, and assemble the smallest compatible set of AI Agent Skills for an end-to-end natural-language goal. Use when a user wants Skills for a multi-step workflow, asks which Skills fit a project, needs an installed-Skill audit or conflict check, has low Skill recall, wants indirect helpers such as humanizers or compliance checks, or wants a project-specific Skill Stack with controlled installation. Search local Skills, registries, GitHub, and OpenCLI; compare adoption, verified fit, safety, and overlap. Do not use for locating one known or common Skill; use the generic find-skills workflow. | `agents`
`references/discovery-ranking.md`
`references/local-index-and-profiles.md`
`references/security-installation.md`
`references/workflow-model.md`
`scripts/inventory_skills.py`
`scripts/project_profile.py`
`scripts/render_stack_card.py`
`scripts/skill_index.py`
`scripts/stage_install.py` | | [agent-supply-chain](../skills/agent-supply-chain/SKILL.md)
`gh skills install github/awesome-copilot agent-supply-chain` | Verify supply chain integrity for AI agent plugins, tools, and dependencies. Use this skill when:
- Generating SHA-256 integrity manifests for agent plugins or tool packages
- Verifying that installed plugins match their published manifests
- Detecting tampered, modified, or untracked files in agent tool directories
- Auditing dependency pinning and version policies for agent components
- Building provenance chains for agent plugin promotion (dev → staging → production)
- Any request like "verify plugin integrity", "generate manifest", "check supply chain", or "sign this plugin" | None | | [agentic-eval](../skills/agentic-eval/SKILL.md)
`gh skills install github/awesome-copilot agentic-eval` | Patterns and techniques for evaluating and improving AI agent outputs. Use this skill when:
- Implementing self-critique and reflection loops
- Building evaluator-optimizer pipelines for quality-critical generation
- Creating test-driven code refinement workflows
- Designing rubric-based or LLM-as-judge evaluation systems
- Adding iterative improvement to agent outputs (code, reports, analysis)
- Measuring and improving agent response quality | None | | [ai-prompt-engineering-safety-review](../skills/ai-prompt-engineering-safety-review/SKILL.md)
`gh skills install github/awesome-copilot ai-prompt-engineering-safety-review` | Comprehensive AI prompt engineering safety review and improvement prompt. Analyzes prompts for safety, bias, security vulnerabilities, and effectiveness while providing detailed improvement recommendations with extensive frameworks, testing methodologies, and educational content. | None | @@ -97,6 +99,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [codeql](../skills/codeql/SKILL.md)
`gh skills install github/awesome-copilot codeql` | Comprehensive guide for setting up and configuring CodeQL code scanning via GitHub Actions workflows and the CodeQL CLI. This skill should be used when users need help with code scanning configuration, CodeQL workflow files, CodeQL CLI commands, SARIF output, security analysis setup, or troubleshooting CodeQL analysis. | `references/alert-management.md`
`references/cli-commands.md`
`references/compiled-languages.md`
`references/sarif-output.md`
`references/troubleshooting.md`
`references/workflow-configuration.md` | | [comment-code-generate-a-tutorial](../skills/comment-code-generate-a-tutorial/SKILL.md)
`gh skills install github/awesome-copilot comment-code-generate-a-tutorial` | Transform this Python script into a polished, beginner-friendly project by refactoring the code, adding clear instructional comments, and generating a complete markdown tutorial. | None | | [commit-message-storyteller](../skills/commit-message-storyteller/SKILL.md)
`gh skills install github/awesome-copilot commit-message-storyteller` | Analyzes git diffs or staged changes and generates narrative commit messages that explain WHY a change was made, not just what changed — following Conventional Commits format. Use when asked to "write a commit message", "generate a commit", "describe my changes", "what should I commit this as", "commit this", "summarize my diff", or "help me commit". Works with git diff output, staged files, or plain descriptions of changes. | `references/conventional-commits-guide.md` | +| [competitor-ad-intelligence](../skills/competitor-ad-intelligence/SKILL.md)
`gh skills install github/awesome-copilot competitor-ad-intelligence` | Use this skill when the user asks to analyze, tear down, or reverse-engineer a competitor's paid ads. Trigger for prompts like "what ads is [competitor] running", "tear down their ad strategy", "competitor ad analysis", "find ad angles we haven't tried", or "reverse-engineer their paid funnel". Do not trigger for organic/SEO competitor research or website positioning analysis. | None | | [containerize-aspnet-framework](../skills/containerize-aspnet-framework/SKILL.md)
`gh skills install github/awesome-copilot containerize-aspnet-framework` | Containerize an ASP.NET .NET Framework project by creating Dockerfile and .dockerfile files customized for the project. | None | | [containerize-aspnetcore](../skills/containerize-aspnetcore/SKILL.md)
`gh skills install github/awesome-copilot containerize-aspnetcore` | Containerize an ASP.NET Core project by creating Dockerfile and .dockerfile files customized for the project. | None | | [content-management-systems](../skills/content-management-systems/SKILL.md)
`gh skills install github/awesome-copilot content-management-systems` | Workflow for building and modifying content management systems across WordPress, Shopify, Wix, Squarespace, Drupal, WooCommerce, Joomla, HubSpot CMS Hub, Webflow, Adobe Experience Manager, and similar platforms. Use when working on CMS themes, plugins, apps, modules, admin panels, media uploads, content models, editors, markdown pipelines, or static export workflows. | `references/cms-platform-workflows.md` | @@ -407,6 +410,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [vscode-ext-localization](../skills/vscode-ext-localization/SKILL.md)
`gh skills install github/awesome-copilot vscode-ext-localization` | Guidelines for proper localization of VS Code extensions, following VS Code extension development guidelines, libraries and good practices | None | | [web-design-reviewer](../skills/web-design-reviewer/SKILL.md)
`gh skills install github/awesome-copilot web-design-reviewer` | This skill enables visual inspection of websites running locally or remotely to identify and fix design issues. Triggers on requests like "review website design", "check the UI", "fix the layout", "find design problems". Detects issues with responsive design, accessibility, visual consistency, and layout breakage, then performs fixes at the source code level. | `references/framework-fixes.md`
`references/visual-checklist.md` | | [webapp-testing](../skills/webapp-testing/SKILL.md)
`gh skills install github/awesome-copilot webapp-testing` | Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. | `assets/test-helper.js` | +| [webmcpify](../skills/webmcpify/SKILL.md)
`gh skills install github/awesome-copilot webmcpify` | Make a web app agent-ready — propose a WebMCP tool manifest, integrate, verify in a real browser, heal; unrelated code stays untouched. Use for "webmcpify", "add WebMCP", or "expose app actions to AI agents". | `references/heal.md`
`references/integrate.md`
`references/inventory.md`
`references/runtime.md`
`references/security.md`
`references/verify.md`
`templates` | | [what-context-needed](../skills/what-context-needed/SKILL.md)
`gh skills install github/awesome-copilot what-context-needed` | Ask Copilot what files it needs to see before answering a question | None | | [winmd-api-search](../skills/winmd-api-search/SKILL.md)
`gh skills install github/awesome-copilot winmd-api-search` | Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI/ML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values). | `LICENSE.txt`
`scripts/Invoke-WinMdQuery.ps1`
`scripts/Update-WinMdCache.ps1`
`scripts/cache-generator` | | [winui3-migration-guide](../skills/winui3-migration-guide/SKILL.md)
`gh skills install github/awesome-copilot winui3-migration-guide` | UWP-to-WinUI 3 migration reference. Maps legacy UWP APIs to correct Windows App SDK equivalents with before/after code snippets. Covers namespace changes, threading (CoreDispatcher to DispatcherQueue), windowing (CoreWindow to AppWindow), dialogs, pickers, sharing, printing, background tasks, and the most common Copilot code generation mistakes. | None | diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 507cc28f..1b9fb3c4 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -330,31 +330,45 @@ function isMissingPathAtLocator(output) { ); } -function fetchLocatorIntoRepo(repoDir, locator) { +// Resolves the git object a locator's content should be read from. +// +// A locator can be a commit SHA, a short tag name (e.g. "v1.1.247"), or a +// fully-qualified tag ref. `git fetch origin ` only updates FETCH_HEAD; +// it does NOT create a local `refs/tags/`, so `git show :...` dies with +// "invalid object name". Reading through FETCH_HEAD (or HEAD for the already +// checked-out primary) sidesteps that without having to classify the locator. +function resolveLocatorReadRef(repoDir, locator, primaryFetchSpec) { + if (locator === primaryFetchSpec) { + // The primary locator was fetched and checked out at HEAD when the submission + // was cloned, so its content is readable via HEAD without another fetch. + return { status: "pass", readRef: "HEAD", output: "" }; + } + const result = runCommand("git", ["fetch", "--depth=1", "origin", locator], { cwd: repoDir }); if (result.exitCode === 0) { - return { - status: "pass", - output: "", - }; + // FETCH_HEAD points at whatever was just fetched. At most one non-primary + // locator is fetched per gate and it is read immediately below, so FETCH_HEAD + // is not clobbered before use. + return { status: "pass", readRef: "FETCH_HEAD", output: "" }; } const status = classifySmokeFailure(result.output); return { status, + readRef: null, output: `git fetch failed for "${locator}": ${result.output}`, }; } -function readPluginManifestAtLocator(repoDir, locator, normalizedPluginPath) { +function readPluginManifestAtLocator(repoDir, readRef, locator, normalizedPluginPath) { const manifestCandidates = PLUGIN_JSON_CANDIDATES.map((segments) => toPosixPath(normalizedPluginPath, ...segments) ); for (const manifestPath of manifestCandidates) { - const showResult = runCommand("git", ["show", `${locator}:${manifestPath}`], { cwd: repoDir }); + const showResult = runCommand("git", ["show", `${readRef}:${manifestPath}`], { cwd: repoDir }); if (showResult.exitCode === 0) { - const rawShow = spawnSync("git", ["show", `${locator}:${manifestPath}`], { cwd: repoDir, encoding: "utf8" }); + const rawShow = spawnSync("git", ["show", `${readRef}:${manifestPath}`], { cwd: repoDir, encoding: "utf8" }); const rawStdout = String(rawShow.stdout ?? ""); try { @@ -388,7 +402,7 @@ function readPluginManifestAtLocator(repoDir, locator, normalizedPluginPath) { }; } -function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { +export function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { const expectedVersion = String(plugin?.version ?? "").trim(); const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/"); const locators = [plugin?.source?.ref, plugin?.source?.sha] @@ -408,22 +422,20 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { let hasInfraError = false; for (const locator of locators) { - if (locator !== primaryFetchSpec) { - const fetchResult = fetchLocatorIntoRepo(repoDir, locator); - if (fetchResult.status === "fail") { - hasFailure = true; - messages.push(`- ${locator}: ${fetchResult.output}`); - continue; - } - - if (fetchResult.status === "infra_error") { - hasInfraError = true; - messages.push(`- ${locator}: ${fetchResult.output}`); - continue; - } + const refResult = resolveLocatorReadRef(repoDir, locator, primaryFetchSpec); + if (refResult.status === "fail") { + hasFailure = true; + messages.push(`- ${locator}: ${refResult.output}`); + continue; } - const manifestResult = readPluginManifestAtLocator(repoDir, locator, normalizedPluginPath); + if (refResult.status === "infra_error") { + hasInfraError = true; + messages.push(`- ${locator}: ${refResult.output}`); + continue; + } + + const manifestResult = readPluginManifestAtLocator(repoDir, refResult.readRef, locator, normalizedPluginPath); if (manifestResult.kind === "not_found" || manifestResult.kind === "invalid") { hasFailure = true; messages.push(`- ${locator}: ${manifestResult.message}`); @@ -474,14 +486,14 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { }; } -function checkPathExistsAtLocator(repoDir, locator, repoPath, expectedType) { - const result = runCommand("git", ["cat-file", "-e", `${locator}:${repoPath}`], { cwd: repoDir }); +function checkPathExistsAtLocator(repoDir, readRef, locator, repoPath, expectedType) { + const result = runCommand("git", ["cat-file", "-e", `${readRef}:${repoPath}`], { cwd: repoDir }); if (result.exitCode === 0) { if (!expectedType) { return { exists: true, output: "" }; } - const typeResult = runCommand("git", ["cat-file", "-t", `${locator}:${repoPath}`], { cwd: repoDir }); + const typeResult = runCommand("git", ["cat-file", "-t", `${readRef}:${repoPath}`], { cwd: repoDir }); if (typeResult.exitCode !== 0) { return { exists: false, @@ -546,22 +558,22 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { const messages = []; for (const locator of locators) { - if (locator !== primaryFetchSpec) { - const fetchResult = fetchLocatorIntoRepo(repoDir, locator); - if (fetchResult.status === "fail") { - hasFailure = true; - messages.push(`- ${locator}: ${fetchResult.output}`); - continue; - } - - if (fetchResult.status === "infra_error") { - hasInfraError = true; - messages.push(`- ${locator}: ${fetchResult.output}`); - continue; - } + const refResult = resolveLocatorReadRef(repoDir, locator, primaryFetchSpec); + if (refResult.status === "fail") { + hasFailure = true; + messages.push(`- ${locator}: ${refResult.output}`); + continue; } - const extensionDirCheck = checkPathExistsAtLocator(repoDir, locator, extensionsDir, "tree"); + if (refResult.status === "infra_error") { + hasInfraError = true; + messages.push(`- ${locator}: ${refResult.output}`); + continue; + } + + const readRef = refResult.readRef; + + const extensionDirCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionsDir, "tree"); if (extensionDirCheck.output) { hasInfraError = true; messages.push(`- ${locator}: ${extensionDirCheck.output}`); @@ -577,7 +589,7 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { continue; } - const extensionEntryCheck = checkPathExistsAtLocator(repoDir, locator, extensionEntryPoint, "blob"); + const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob"); if (extensionEntryCheck.output) { hasInfraError = true; messages.push(`- ${locator}: ${extensionEntryCheck.output}`); diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index 5f619fae..04eb27e7 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -4,7 +4,7 @@ import os from "os"; import path from "path"; import { spawnSync } from "child_process"; import { after, test } from "node:test"; -import { runCanvasStructureGate } from "./external-plugin-quality-gates.mjs"; +import { runCanvasStructureGate, runVersionMatchGate } from "./external-plugin-quality-gates.mjs"; const tempDirs = []; @@ -99,3 +99,118 @@ test("runCanvasStructureGate fails when extension entrypoint path is a directory assert.equal(result.status, "fail"); assert.match(result.output, /"extensions\/extension\.mjs" must be a file/); }); + +// Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be +// readable by the version-match and canvas-structure gates. `git fetch origin ` +// only updates FETCH_HEAD and never creates a local `refs/tags/`, so reading via +// `git show :...` used to die with "fatal: invalid object name" and roll up to a +// bogus infra_error/fail even though the referenced content was valid. + +function initRemoteRepo() { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-plugin-quality-remote-")); + tempDirs.push(repoDir); + runGit(repoDir, "init", "-q"); + runGit(repoDir, "config", "user.name", "Copilot Test"); + runGit(repoDir, "config", "user.email", "copilot@example.com"); + // Mirror github.com: allow the submission repo to shallow-fetch an arbitrary SHA. + runGit(repoDir, "config", "uploadpack.allowAnySHA1InWant", "true"); + return repoDir; +} + +function writeValidPluginContent(repoDir) { + fs.mkdirSync(path.join(repoDir, ".github", "plugin"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, ".github", "plugin", "plugin.json"), + `${JSON.stringify({ name: "tag-plugin", version: "1.0.0" }, null, 2)}\n`, + ); + fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n"); +} + +// Mirrors cloneSubmissionRepository in external-plugin-quality-gates.mjs: fetch only the +// primary locator and detach HEAD onto it. The tag ref is deliberately never created +// locally, reproducing the CI environment where `git show :...` fails. +function cloneSubmissionRepo(remoteDir, primaryFetchSpec) { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-plugin-quality-sub-")); + tempDirs.push(repoDir); + runGit(repoDir, "init", "-q"); + runGit(repoDir, "remote", "add", "origin", remoteDir); + runGit(repoDir, "fetch", "--depth=1", "origin", primaryFetchSpec); + runGit(repoDir, "checkout", "--detach", "FETCH_HEAD"); + return repoDir; +} + +test("runVersionMatchGate passes for a tag ref alongside a sha", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + const sha = commitAll(remoteDir, "Add plugin manifest"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "tag-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + // Both the tag ref and the sha must be verified. + assert.match(result.output, /- v1\.0\.0: matched version "1\.0\.0"/); + assert.match(result.output, new RegExp(`- ${sha}: matched version "1\\.0\\.0"`)); +}); + +test("runCanvasStructureGate passes for a tag ref alongside a sha", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + const sha = commitAll(remoteDir, "Add canvas extension container"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "tag-plugin", + keywords: ["canvas"], + source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /- v1\.0\.0: found "extensions"/); + assert.match(result.output, new RegExp(`- ${sha}: found "extensions"`)); +}); + +test("runVersionMatchGate passes when the primary locator is a tag ref", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + commitAll(remoteDir, "Add plugin manifest"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + + const repoDir = cloneSubmissionRepo(remoteDir, "v1.0.0"); + const plugin = { + name: "tag-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", ref: "v1.0.0" }, + }; + + const result = runVersionMatchGate(repoDir, plugin, "v1.0.0"); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /- v1\.0\.0: matched version "1\.0\.0"/); +}); + +test("runCanvasStructureGate passes when the primary locator is a tag ref", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + commitAll(remoteDir, "Add canvas extension container"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + + const repoDir = cloneSubmissionRepo(remoteDir, "v1.0.0"); + const plugin = { + name: "tag-plugin", + keywords: ["canvas"], + source: { source: "github", repo: "owner/repo", ref: "v1.0.0" }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, "v1.0.0"); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /- v1\.0\.0: found "extensions"/); +}); diff --git a/extensions/backrooms-canvas/.github/plugin/plugin.json b/extensions/backrooms-canvas/.github/plugin/plugin.json new file mode 100644 index 00000000..8fdabd85 --- /dev/null +++ b/extensions/backrooms-canvas/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "backrooms-canvas", + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "version": "1.0.0", + "author": { + "name": "John Haugabook", + "url": "https://github.com/jhauga" + }, + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ], + "logo": "assets/preview.png", + "extensions": "extensions" +} diff --git a/extensions/backrooms-canvas/assets/preview.png b/extensions/backrooms-canvas/assets/preview.png new file mode 100644 index 00000000..d8ba572e Binary files /dev/null and b/extensions/backrooms-canvas/assets/preview.png differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/README.md b/extensions/backrooms-canvas/extensions/backrooms-canvas/README.md new file mode 100644 index 00000000..122a81a8 --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/README.md @@ -0,0 +1,90 @@ +# BackRooms Canvas + +A GitHub Copilot canvas that opens an endless first-person backrooms in the side panel. Yellow-wallpapered halls under humming fluorescent panels, drop-tile ceilings, and worn office carpet, filmed through the shake and grain of a 1990-era handheld camcorder. Somewhere past the fog, something else walks the same halls. + +It is a canvas port of the [BackViews VSCode extension](https://github.com/isocialPractice/vscode-backviews). The world is powered by [cmd-backedges](https://github.com/isocialPractice/cmd-backedges), a procedural infinite maze engine, so every wall, room, and prop is a pure function of the seed: the same seed always rebuilds the same rooms, forever, in every direction. + +While an agent works in the session, the halls start writing back. Its status and streaming responses are scrawled onto the walls in a messy ink script, and a camcorder-style token counter runs under the HUD battery. + +## Files + +- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions. +- `game/` — the prebuilt game bundle (`webview.js`) and the `index.html` host shim served inside the canvas. +- `materials/` — photo textures (`wallpaper.jpg`, `ceiling.jpg`, `carpet.jpg`) tiled over the procedural atlas. +- `assets/` — app icon and `preview.png` for the extensions gallery. +- `package.json` — declares the Copilot SDK dependency and ESM entry point. +- `copilot-extension.json` — Copilot extension name/version metadata. + +## Prerequisites + +- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`. +- A WebGL-capable canvas surface (the renderer is raw WebGL). +- The GitHub Copilot app canvas / UI-extensions experiment enabled. + +## Install + +Drop this folder at `~/.copilot/extensions/backrooms-canvas/` for user scope, or in a repository at `.github/extensions/backrooms-canvas/` for project scope. Then install dependencies from inside the copied folder: + +```sh +# User scope +cd ~/.copilot/extensions/backrooms-canvas + +# Or project scope, from the repository root +cd .github/extensions/backrooms-canvas + +npm install +``` + +Reload extensions in the GitHub Copilot app, then open the `backrooms-canvas` canvas. Click the view to capture the mouse and start walking. + +The canvas accepts optional open inputs: + +| Input | Type | Description | +| --- | --- | --- | +| `seed` | number | Maze seed. The same seed always rebuilds the same halls. `0` rolls a random seed. | +| `materialPreset` | string | Wall material set: `classic`, `office`, `pool`, `concrete`, or `panel`. | +| `monsterEnabled` | boolean | Whether something else walks the halls. | + +## Controls + +| Input | Action | +| --- | --- | +| `W` / `S` or `Up` / `Down` | Walk forward / back | +| `A` / `D` | Strafe left / right | +| `Left` / `Right` or `Q` / `E` | Turn | +| `Shift` | Hurry | +| Mouse (after clicking the view) | Look around | +| `M` or `Esc` | Open the in-game menu | + +The in-game menu has Resume, Relocate, Settings, and Help, plus live stats. Settings changed there persist in the canvas via `localStorage`, so your choices survive a reload. + +## Agent actions + +The agent drives the game and feeds the ghost-writer through three actions. They are the canvas equivalent of the original extension's `backviews_reportJob` tool and chat-session mirror. + +- `report_job { status, tokens?, done? }` — report the current job step. The status text is scrawled on the walls and the token count drives the HUD counter. Call it when work starts, again on each new step, and once more with `done: true` when finished. +- `ghost_write { text, tokens?, done? }` — ghost-write a block of text onto the wall ahead, character by character as it grows, like a streaming response. Send the growing text on each call, then once with `done: true` to settle it in place. +- `relocate` — drop the wanderer into a fresh random seed, wiping any writing already on the walls. + +To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request. + +## How the port works + +The game itself is unchanged: `game/webview.js` is the same self-contained bundle the VSCode extension ships (WebGL renderer, maze engine, camcorder overlay, and wall-writing subsystem in one esbuild IIFE). The bundle was written to talk to a VSCode webview host through `acquireVsCodeApi()` and `window.postMessage`. + +`game/index.html` shims that host: + +- `acquireVsCodeApi()` is backed by `localStorage` for state (seed and player position) and settings persistence. +- The canvas server (`extension.mjs`) runs a loopback HTTP server that serves the bundle and streams agent activity over Server-Sent Events at `/events`. The shim translates those events into the exact `jobStatus`, `chatSession`, and `relocate` messages the bundle already listens for. +- Agent actions broadcast onto that SSE stream, so `report_job` writes on the walls and `ghost_write` streams text just as the VSCode chat mirror did. + +This mirrors the [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions. + +## Credits + +- Maze generation: [cmd-backedges](https://github.com/isocialPractice/cmd-backedges) by John Haugabook. +- Original extension: [vscode-backviews](https://github.com/isocialPractice/vscode-backviews). + +## License + +MIT diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png b/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png new file mode 100644 index 00000000..6297eaaa Binary files /dev/null and b/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png b/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png new file mode 100644 index 00000000..d8ba572e Binary files /dev/null and b/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json b/extensions/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json new file mode 100644 index 00000000..56cd2c10 --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "backrooms-canvas", + "version": 1 +} diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/extension.mjs b/extensions/backrooms-canvas/extensions/backrooms-canvas/extension.mjs new file mode 100644 index 00000000..0cddfa0d --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/extension.mjs @@ -0,0 +1,472 @@ +/** + * Copilot canvas entry point for BackRooms. + * + * The game itself is the prebuilt browser bundle at game/webview.js (an + * esbuild IIFE that already carries the WebGL renderer, the procedural maze + * engine, and the whole first-person world). This file is only the host: a + * tiny local HTTP server that serves that bundle plus its photo materials, + * and a canvas registration that lets an agent drive the game through actions. + * + * The bundle was written for a VSCode webview and talks to its host through + * `acquireVsCodeApi()` + `window.postMessage`. game/index.html shims that API + * against this server's Server-Sent Events stream, so the same bundle runs + * unchanged inside the canvas. The message shapes below match the bundle's + * expectations exactly (see the original src/shared/settings.ts): + * + * host -> game : { type: 'config', settings } + * { type: 'jobStatus', job } // CopilotJob + * { type: 'chatSession', session } // ChatSessionSnapshot + * { type: 'relocate' } + * game -> host : { type: 'ready' } // handled in the shim + * { type: 'updateSetting', key, value } // handled in the shim + */ +import { createReadStream } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const gameRoot = path.join(__dirname, "game"); +const materialsRoot = path.join(__dirname, "materials"); +const assetsRoot = path.join(__dirname, "assets"); +const indexPath = path.join(gameRoot, "index.html"); + +/** + * Default settings, mirrored from the bundle's own DEFAULT_SETTINGS. The shim + * merges any per-open overrides and the player's saved menu choices on top of + * these before handing the game its first `config` message. + */ +const DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true, +}; + +const MATERIAL_PRESETS = ["classic", "office", "pool", "concrete", "panel"]; +const MONSTER_FORMS = ["spider", "humanoid", "cloud", "random"]; + +/** Idle Copilot job; the walls stay quiet and the HUD counter fades out. */ +const IDLE_JOB = { working: false, status: "", tokens: 0 }; + +const servers = new Map(); + +function contentType(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".map": + return "application/json; charset=utf-8"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +/** Resolves a request path under a root, refusing anything that escapes it. */ +function resolveUnder(root, requestPath) { + const resolved = path.resolve(root, `.${requestPath}`); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new CanvasError("invalid_path", "Requested path is outside the backrooms assets."); + } + return resolved; +} + +function sendJson(res, value) { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + res.end(JSON.stringify(value)); +} + +function sendNotFound(res) { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + res.end("Not found"); +} + +function sendSse(res, event, data) { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function broadcast(entry, event, data) { + for (const client of entry.clients) { + sendSse(client, event, data); + } +} + +function randomSeed() { + return Math.floor(Math.random() * 999_999) + 1; +} + +/** Coerces one enum-ish value, falling back to the default when unknown. */ +function pickEnum(value, allowed, fallback) { + return typeof value === "string" && allowed.includes(value) ? value : fallback; +} + +/** Coerces a finite number into [min, max], or returns the fallback. */ +function clampNumber(value, min, max, fallback) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, value)); +} + +/** + * Turns arbitrary open-input into a partial settings override the shim can + * merge over the defaults. Only the settings that make sense to preset from an + * agent are honored; everything else stays on its default or saved value. + */ +function normalizeOverrides(input) { + const overrides = {}; + if (!input || typeof input !== "object") { + return overrides; + } +if (input.seed !== undefined) { + const seed = Math.trunc(clampNumber(input.seed, 0, Number.MAX_SAFE_INTEGER, 0)); + overrides.seed = seed === 0 ? randomSeed() : seed; + } + if (input.materialPreset !== undefined) { + overrides.materialPreset = pickEnum(input.materialPreset, MATERIAL_PRESETS, "classic"); + } + if (input.monsterEnabled !== undefined) { + overrides.monsterEnabled = input.monsterEnabled === true; + } + if (input.monsterForm !== undefined) { + overrides.monsterForm = pickEnum(input.monsterForm, MONSTER_FORMS, "random"); + } + if (input.copilotGhostWriter !== undefined) { + overrides.copilotGhostWriter = input.copilotGhostWriter !== false; + } + return overrides; +} + +/** Coerces report_job input into a CopilotJob the game understands. */ +function normalizeJob(input) { + if (!input || typeof input !== "object") { + return { ...IDLE_JOB }; + } + const done = input.done === true; + return { + working: !done, + status: typeof input.status === "string" ? input.status.slice(0, 120) : "", + tokens: + typeof input.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : 0, + }; +} + +/** + * Coerces ghost_write input into a ChatSessionSnapshot. The game ghost-writes + * `current` onto the wall ahead as it grows, and the token count drives the + * HUD odometer. `done` settles the writing in place and stops the counter. + */ +function normalizeSession(input) { + const text = typeof input?.text === "string" ? input.text : ""; + const done = input?.done === true; + const tokens = + typeof input?.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : Math.ceil(text.length / 4); + return { working: !done && text.length > 0, history: [], current: text, tokens }; +} + +async function renderIndex(entry) { + const html = await readFile(indexPath, "utf8"); + // Handed to the shim so it can build the game's first `config` message and + // replay any job that is already running when the panel opens. + const init = { + defaults: DEFAULT_SETTINGS, + overrides: entry.overrides, + materials: { + wallpaper: "/materials/wallpaper.jpg", + ceiling: "/materials/ceiling.jpg", + carpet: "/materials/carpet.jpg", + }, + job: entry.job, + session: entry.session, + }; +return html.replace("__BACKROOMS_INIT__", JSON.stringify(init).replace(/ undefined); + if (!fileStat?.isFile()) { + sendNotFound(res); + return; + } + res.writeHead(200, { + "content-type": contentType(filePath), + "cache-control": "no-cache", + }); + const stream = createReadStream(filePath); + stream.on("error", () => { + if (!res.headersSent) { + sendNotFound(res); + } else { + res.destroy(); + } + }); + stream.pipe(res); +} + +async function handleRequest(entry, req, res) { + const url = new URL(req.url ?? "/", entry.url); + + if (url.pathname === "/events") { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + entry.clients.add(res); + // Catch a fresh (or reconnecting) client up to the live state. + sendSse(res, "jobStatus", { job: entry.job }); + if (entry.session) { + sendSse(res, "chatSession", { session: entry.session }); + } + req.on("close", () => entry.clients.delete(res)); + return; + } + + // The shim reports explicit setting changes (menu edits, relocate): the + // per-open override for that key stops applying, so a reload keeps the + // player's choice instead of replaying the stale override. + if (req.method === "DELETE" && url.pathname.startsWith("/override/")) { + delete entry.overrides[decodeURIComponent(url.pathname.slice("/override/".length))]; + res.writeHead(204); + res.end(); + return; + } + + if (url.pathname === "/favicon.ico") { + await streamFile(res, path.join(assetsRoot, "icon.png")); + return; + } + + try { + if (url.pathname === "/" || url.pathname === "/index.html") { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-cache", + }); + res.end(await renderIndex(entry)); + return; + } + + const staticPath = url.pathname.startsWith("/materials/") + ? resolveUnder(materialsRoot, url.pathname.slice("/materials".length)) + : url.pathname.startsWith("/assets/") + ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) + : resolveUnder(gameRoot, url.pathname); + await streamFile(res, staticPath); + } catch (error) { + if (error instanceof CanvasError) { + res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); + res.end(error.message); + return; + } + throw error; + } +} + +async function startServer(instanceId, overrides) { + const entry = { + clients: new Set(), + overrides, + job: { ...IDLE_JOB }, + session: null, + server: undefined, + url: undefined, + }; + const server = createServer((req, res) => { + handleRequest(entry, req, res).catch((error) => { + res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + res.end(error instanceof Error ? error.message : "Backrooms canvas server error"); + }); + }); + entry.server = server; + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.url = `http://127.0.0.1:${port}/`; + servers.set(instanceId, entry); + return entry; +} + +function getOpenEntry(instanceId) { + const entry = servers.get(instanceId); + if (!entry) { + throw new CanvasError("backrooms_not_open", "Open the Backrooms canvas before invoking this action."); + } + return entry; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "backrooms-canvas", + displayName: "BackRooms", + description: + "An endless first-person backrooms to wander while agents work. The agent's status ghost-writes on the walls.", + inputSchema: { + type: "object", + properties: { + seed: { + type: "number", + description: "Maze seed. The same seed always rebuilds the same halls. 0 rolls a random seed.", + }, + materialPreset: { + type: "string", + enum: MATERIAL_PRESETS, + description: "Wall material set to start with.", + }, + monsterEnabled: { + type: "boolean", + description: "Whether something else walks the halls.", + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "report_job", + description: + "Report your current job status to the backrooms. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status text is scrawled on the walls and the token count drives a camcorder-style HUD counter.", + inputSchema: { + type: "object", + properties: { + status: { + type: "string", + description: + "Short status text to scrawl on the walls (e.g. 'reading the codebase', 'rewriting the parser').", + }, + tokens: { + type: "number", + description: "Approximate tokens consumed by the current job so far.", + }, + done: { + type: "boolean", + description: "Set true when the job is finished; the walls stop updating and the counter fades out.", + }, + }, + required: ["status"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.job = normalizeJob(ctx.input); + broadcast(entry, "jobStatus", { job: entry.job }); + return { job: entry.job }; + }, + }, + { + name: "ghost_write", + description: + "Ghost-write a block of text onto the walls ahead, character by character as it grows, like a streaming chat response. Call repeatedly with the full text so far, then once with done=true to settle it in place.", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "The full response text so far. Send the growing text on each call; the walls reveal the new characters.", + }, + tokens: { + type: "number", + description: "Approximate tokens of the response so far. Defaults to text length / 4.", + }, + done: { + type: "boolean", + description: "Set true when the response is complete; the writing settles onto the wall and the counter stops.", + }, + }, + required: ["text"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.session = normalizeSession(ctx.input); + broadcast(entry, "chatSession", { session: entry.session }); + return { working: entry.session.working, tokens: entry.session.tokens }; + }, + }, + { + name: "relocate", + description: "Drop the wanderer into a fresh random seed, wiping any writing already on the walls.", + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + const seed = randomSeed(); + // The walls are wiped on relocate, so drop the job and + // session snapshots too or a reload would replay them + // onto the fresh maze. + entry.job = { ...IDLE_JOB }; + entry.session = null; + broadcast(entry, "jobStatus", { job: entry.job }); + broadcast(entry, "relocate", { seed }); + return { seed }; + }, + }, + ], + open: async (ctx) => { + const overrides = normalizeOverrides(ctx.input); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, overrides); + } else { + entry.overrides = { ...entry.overrides, ...overrides }; + } + return { + title: "BackRooms", + status: entry.job.working && entry.job.status ? entry.job.status : "Wandering", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return; + servers.delete(ctx.instanceId); + for (const client of entry.clients) { + client.end(); + } + await new Promise((resolve) => entry.server.close(() => resolve())); + }, + }), + ], +}); diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/game/index.html b/extensions/backrooms-canvas/extensions/backrooms-canvas/game/index.html new file mode 100644 index 00000000..29031550 --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/game/index.html @@ -0,0 +1,125 @@ + + + + + + + BackRooms + + + +
+ + + + diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/game/webview.js b/extensions/backrooms-canvas/extensions/backrooms-canvas/game/webview.js new file mode 100644 index 00000000..b13598f5 --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/game/webview.js @@ -0,0 +1,4013 @@ +"use strict"; +(() => { + // src/shared/settings.ts + var IDLE_JOB = { working: false, status: "", tokens: 0 }; + var DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true + }; + + // src/webview/film.ts + var GRAIN_TILE = 160; + var GRAIN_VARIANTS = 5; + var GRAIN_FPS = 18; + var JOB_LINGER_MS = 6e3; + var FilmOverlay = class { + constructor(canvas) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D overlay context unavailable"); + } + this.ctx = ctx; + for (let v = 0; v < GRAIN_VARIANTS; v++) { + this.grainTiles.push(makeGrainTile(v)); + } + } + grainEnabled = true; + hudEnabled = true; + tokenCounterEnabled = true; + ctx; + grainTiles = []; + lastGrainAt = 0; + grainIndex = 0; + tearY = -1; + tearUntil = 0; + burstUntil = 0; + startedAt = Date.now(); + job = { ...IDLE_JOB }; + jobLingerUntil = 0; + shownTokens = 0; + lastHudAt = 0; + /** Cuts the picture to heavy static for a moment (the catch effect). */ + burst(now, durationMs = 1300) { + this.burstUntil = now + durationMs; + } + /** + * Feeds the HUD the latest Copilot job snapshot. The token count drives the + * HUD counter, which rolls toward the target value and lingers after the + * job finishes. + */ + setJob(job) { + this.job = job; + } + render(now) { + const canvas = this.canvas; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) { + return; + } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + const ctx = this.ctx; + ctx.clearRect(0, 0, w, h); + if (this.burstUntil > now) { + ctx.globalAlpha = 0.94; + for (let y = 0; y < h; y += GRAIN_TILE) { + for (let x = 0; x < w; x += GRAIN_TILE) { + ctx.drawImage(this.grainTiles[Math.floor(Math.random() * GRAIN_VARIANTS)], x, y); + } + } + ctx.globalAlpha = 1; + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "rgba(20, 20, 20, 0.85)"; + ctx.fillRect(w / 2 - 130, h / 2 - 28, 260, 56); + ctx.fillStyle = "#f0f2f3"; + ctx.fillText("SIGNAL LOST", w / 2, h / 2); + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + return; + } + if (!this.grainEnabled && !this.hudEnabled) { + return; + } + if (this.grainEnabled) { + if (now - this.lastGrainAt > 1e3 / GRAIN_FPS) { + this.lastGrainAt = now; + this.grainIndex = Math.floor(Math.random() * GRAIN_VARIANTS); + if (this.tearUntil < now && Math.random() < 0.03) { + this.tearY = Math.random() * h; + this.tearUntil = now + 90 + Math.random() * 160; + } + } + const tile = this.grainTiles[this.grainIndex]; + ctx.globalAlpha = 0.11; + const ox = Math.floor(Math.random() * GRAIN_TILE); + const oy = Math.floor(Math.random() * GRAIN_TILE); + for (let y = -oy; y < h; y += GRAIN_TILE) { + for (let x = -ox; x < w; x += GRAIN_TILE) { + ctx.drawImage(tile, x, y); + } + } + ctx.globalAlpha = 1; + const grad = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.max(w, h) * 0.72); + grad.addColorStop(0, "rgba(0, 0, 0, 0)"); + grad.addColorStop(1, "rgba(0, 0, 0, 0.42)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + const bandY = now / 34 % (h + 160) - 160; + const band = ctx.createLinearGradient(0, bandY, 0, bandY + 160); + band.addColorStop(0, "rgba(255, 255, 255, 0)"); + band.addColorStop(0.5, "rgba(255, 255, 255, 0.025)"); + band.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = band; + ctx.fillRect(0, bandY, w, 160); + if (this.tearUntil > now && this.tearY >= 0) { + ctx.fillStyle = "rgba(220, 220, 210, 0.10)"; + ctx.fillRect(0, this.tearY, w, 3); + ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; + ctx.fillRect(0, this.tearY + 3, w, 2); + } + } + if (this.hudEnabled) { + this.renderHud(now, w, h); + } + } + renderHud(now, w, h) { + const ctx = this.ctx; + const pad = Math.round(Math.min(w, h) * 0.045) + 8; + ctx.font = '16px "Courier New", monospace'; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; + ctx.shadowBlur = 3; + if (Math.floor(now / 700) % 2 === 0) { + ctx.fillStyle = "rgba(255, 70, 60, 0.95)"; + ctx.beginPath(); + ctx.arc(pad + 7, pad + 8, 6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText("REC", pad + 22, pad); + const elapsed = Math.floor((Date.now() - this.startedAt) / 1e3); + const counter = `${String(Math.floor(elapsed / 3600)).padStart(1, "0")}:${String( + Math.floor(elapsed / 60 % 60) + ).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; + ctx.textAlign = "right"; + ctx.fillText(`SP ${counter}`, w - pad, pad); + ctx.strokeStyle = "rgba(235, 235, 225, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(w - pad - 34, pad + 24, 30, 12); + ctx.fillRect(w - pad - 3, pad + 27, 3, 6); + ctx.fillRect(w - pad - 32, pad + 26, 8, 8); + ctx.fillRect(w - pad - 22, pad + 26, 8, 8); + this.renderTokenCounter(now, w, pad); + ctx.textAlign = "left"; + const stamp = /* @__PURE__ */ new Date(); + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const hr = stamp.getHours() % 12 === 0 ? 12 : stamp.getHours() % 12; + const ampm = stamp.getHours() < 12 ? "AM" : "PM"; + const text = `${months[stamp.getMonth()]} ${String(stamp.getDate()).padStart(2, "0")} 1990 ${ampm} ${hr}:${String( + stamp.getMinutes() + ).padStart(2, "0")}`; + ctx.fillStyle = "rgba(240, 214, 130, 0.92)"; + ctx.fillText(text, pad, h - pad - 18); + ctx.shadowBlur = 0; + ctx.textAlign = "left"; + } + /** + * Dynamic token counter for the current Copilot job, tucked under the + * battery icon like one more line of a 1990s Sony camcorder's on-screen + * display: blocky monospace digits that roll toward the live count, with a + * blinking access mark while the job is running. + */ + renderTokenCounter(now, w, pad) { + if (!this.tokenCounterEnabled) { + return; + } + if (this.job.working) { + this.jobLingerUntil = now + JOB_LINGER_MS; + } else if (now > this.jobLingerUntil) { + this.shownTokens = 0; + return; + } + const dt = Math.min(0.2, (now - this.lastHudAt) / 1e3 || 0.016); + this.lastHudAt = now; + const target = this.job.tokens; + const gap = target - this.shownTokens; + this.shownTokens = Math.abs(gap) < 1 ? target : this.shownTokens + gap * Math.min(1, dt * 4); + const ctx = this.ctx; + const digits = String(Math.min(999999, Math.round(this.shownTokens))).padStart(6, "0"); + ctx.font = '16px "Courier New", monospace'; + ctx.textAlign = "right"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText(`TKN ${digits}`, w - pad, pad + 44); + if (this.job.working && Math.floor(now / 450) % 2 === 0) { + ctx.fillRect(w - pad - 108, pad + 46, 8, 11); + } + ctx.textAlign = "left"; + } + }; + function makeGrainTile(seed) { + const tile = document.createElement("canvas"); + tile.width = tile.height = GRAIN_TILE; + const ctx = tile.getContext("2d"); + const image = ctx.createImageData(GRAIN_TILE, GRAIN_TILE); + let state = 2654435769 ^ seed * 2246822507; + const next = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 4294967295; + }; + for (let i = 0; i < image.data.length; i += 4) { + const v = Math.floor(next() * 255); + image.data[i] = v; + image.data[i + 1] = v; + image.data[i + 2] = v; + image.data[i + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return tile; + } + + // ../cmd-backedges/src/rng.ts + function mulberry32(seed) { + let state = seed >>> 0; + return { + next() { + state = state + 1831565813 | 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + }; + } + var defaultRngFactory = mulberry32; + function mix32(value) { + let h = value | 0; + h ^= h >>> 16; + h = Math.imul(h, 2246822507); + h ^= h >>> 13; + h = Math.imul(h, 3266489909); + h ^= h >>> 16; + return h >>> 0; + } + function hashCombine(hash, value) { + let h = (hash ^ mix32(value | 0)) >>> 0; + h = Math.imul(h, 2654435761) >>> 0; + h = (h << 13 | h >>> 19) >>> 0; + return h >>> 0; + } + function hashCoords(seed, x, y, salt = 0) { + let h = mix32(seed | 0); + h = hashCombine(h, x); + h = hashCombine(h, y); + h = hashCombine(h, salt); + return h >>> 0; + } + function unitFromHash(hash) { + return (hash >>> 0) / 4294967296; + } + + // ../cmd-backedges/src/config.ts + var DEFAULT_CONTROLS = { + moveNorth: ["w", "ArrowUp"], + moveSouth: ["s", "ArrowDown"], + moveEast: ["d", "ArrowRight"], + moveWest: ["a", "ArrowLeft"], + exit: ["Ctrl+C"] + }; + var DEFAULT_CONFIG = { + seed: 1, + cellSize: 1, + width: { min: 0.6, max: 1 }, + height: { min: 0.6, max: 1 }, + depth: { min: 2.5, max: 4 }, + passageDensity: 0.34, + minConnections: 2, + roomFrequency: 0.09, + hallFrequency: 11e-4, + hallSize: { min: 2, max: 4 }, + propFrequency: 0.082, + atriumFrequency: 14e-5, + rng: defaultRngFactory, + controls: DEFAULT_CONTROLS, + cacheLimit: 4096 + }; + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + function normalizeRange(range, fallback) { + if (!range) { + return { ...fallback }; + } + const min = Number.isFinite(range.min) ? range.min : fallback.min; + const max = Number.isFinite(range.max) ? range.max : fallback.max; + return min <= max ? { min, max } : { min: max, max: min }; + } + function normalizeIntRange(range, fallback, lowerBound) { + const base = normalizeRange(range, fallback); + const min = Math.max(lowerBound, Math.trunc(base.min)); + const max = Math.max(min, Math.trunc(base.max)); + return { min, max }; + } + function resolveConfig(partial = {}) { + const seed = Number.isFinite(partial.seed) ? Math.trunc(partial.seed) : DEFAULT_CONFIG.seed; + const cellSize = Number.isFinite(partial.cellSize) && partial.cellSize > 0 ? partial.cellSize : DEFAULT_CONFIG.cellSize; + return Object.freeze({ + seed, + cellSize, + width: normalizeRange(partial.width, DEFAULT_CONFIG.width), + height: normalizeRange(partial.height, DEFAULT_CONFIG.height), + depth: normalizeRange(partial.depth, DEFAULT_CONFIG.depth), + passageDensity: Number.isFinite(partial.passageDensity) ? clamp(partial.passageDensity, 0, 1) : DEFAULT_CONFIG.passageDensity, + minConnections: Number.isFinite(partial.minConnections) ? Math.trunc(clamp(partial.minConnections, 0, 4)) : DEFAULT_CONFIG.minConnections, + roomFrequency: Number.isFinite(partial.roomFrequency) ? clamp(partial.roomFrequency, 0, 1) : DEFAULT_CONFIG.roomFrequency, + hallFrequency: Number.isFinite(partial.hallFrequency) ? clamp(partial.hallFrequency, 0, 1) : DEFAULT_CONFIG.hallFrequency, + hallSize: normalizeIntRange(partial.hallSize, DEFAULT_CONFIG.hallSize, 1), + propFrequency: Number.isFinite(partial.propFrequency) ? clamp(partial.propFrequency, 0, 1) : DEFAULT_CONFIG.propFrequency, + atriumFrequency: Number.isFinite(partial.atriumFrequency) ? clamp(partial.atriumFrequency, 0, 1) : DEFAULT_CONFIG.atriumFrequency, + rng: typeof partial.rng === "function" ? partial.rng : DEFAULT_CONFIG.rng, + controls: { ...DEFAULT_CONTROLS, ...partial.controls ?? {} }, + cacheLimit: Number.isFinite(partial.cacheLimit) && partial.cacheLimit >= 0 ? Math.trunc(partial.cacheLimit) : DEFAULT_CONFIG.cacheLimit + }); + } + + // ../cmd-backedges/src/types.ts + var DIRECTIONS = ["north", "east", "south", "west"]; + var SURFACE_TYPES = [ + "drywall", + "wallpaper", + "paneling", + "concrete", + "tile", + "carpet" + ]; + var FEATURE_KINDS = ["column", "furniture"]; + function opposite(direction) { + switch (direction) { + case "north": + return "south"; + case "south": + return "north"; + case "east": + return "west"; + case "west": + return "east"; + } + } + function step(direction) { + switch (direction) { + case "north": + return { dx: 0, dy: -1 }; + case "south": + return { dx: 0, dy: 1 }; + case "east": + return { dx: 1, dy: 0 }; + case "west": + return { dx: -1, dy: 0 }; + } + } + + // ../cmd-backedges/src/generator.ts + var SALT = { + /** Openness of the vertical edge east of a cell. */ + edgeVertical: 1, + /** Openness of the horizontal edge south of a cell. */ + edgeHorizontal: 2, + /** Coarse room/corridor value-noise lattice. */ + roomNoise: 3, + width: 16, + height: 17, + depth: 18, + /** Whether a cell anchors a rectangular hall. */ + hallAnchor: 48, + /** Hall width in cells. */ + hallWidth: 49, + /** Hall height in cells. */ + hallHeight: 50, + /** Whether a cell anchors a fully open atrium. */ + atriumAnchor: 80, + /** Atrium base width draw. */ + atriumWidth: 81, + /** Atrium base height draw. */ + atriumHeight: 82, + /** Atrium width variance draw. */ + atriumVarWidth: 83, + /** Atrium height variance draw. */ + atriumVarHeight: 84, + /** Whether an open cell holds an interior prop. */ + prop: 64, + /** Which kind of prop a cell holds. */ + propKind: 65, + /** Prop position within its cell. */ + propPositionX: 66, + propPositionY: 67, + /** Prop footprint size. */ + propSize: 68, + /** Stable per-prop cosmetic seed. */ + propVariant: 69, + /** Seed channel for cosmetic edge metadata. */ + material: 32 + }; + var ROOM_LATTICE = 3; + var ATRIUM_SIZE_FACTOR = 1.05; + var ATRIUM_SIZE_VARIANCE = 0.1; + function smoothstep(t) { + return t * t * (3 - 2 * t); + } + function lerp(a, b, t) { + return a + (b - a) * t; + } + function edgeKey(cx, cy, direction) { + switch (direction) { + case "east": + return { ex: cx, ey: cy, salt: SALT.edgeVertical }; + case "west": + return { ex: cx - 1, ey: cy, salt: SALT.edgeVertical }; + case "south": + return { ex: cx, ey: cy, salt: SALT.edgeHorizontal }; + case "north": + return { ex: cx, ey: cy - 1, salt: SALT.edgeHorizontal }; + } + } + var MazeGenerator = class _MazeGenerator { + /** + * @param config - Partial configuration; omitted fields take their defaults. + */ + constructor(config = {}) { + this.cache = /* @__PURE__ */ new Map(); + this.hits = 0; + this.misses = 0; + this.evictions = 0; + this.config = resolveConfig(config); + } + /** String key for the cache and lookups. */ + static key(cx, cy) { + return `${cx},${cy}`; + } + /** + * Returns the cell at the given integer coordinates, computing it on first + * access and serving it from the LRU cache thereafter. Repeated calls return + * structurally identical cells regardless of cache state. + */ + getCell(cx, cy) { + const key = _MazeGenerator.key(cx, cy); + const cached = this.cache.get(key); + if (cached) { + this.hits++; + this.cache.delete(key); + this.cache.set(key, cached); + return cached; + } + this.misses++; + const cell = this.computeCell(cx, cy); + this.cache.set(key, cell); + this.evictIfNeeded(); + return cell; + } + /** Returns every cell whose coordinates fall in the inclusive rectangle. */ + getRegion(minCx, minCy, maxCx, maxCy) { + const cells = []; + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + cells.push(this.getCell(cx, cy)); + } + } + return cells; + } + /** + * Whether the player may cross from `(cx, cy)` in `direction`. + * + * This is the authoritative movement test and is symmetric: crossing an edge + * from either side yields the same answer. An edge is open when any of the + * following holds, all of which both adjacent cells compute identically: + * + * - both cells belong to the same atrium (its fully open interior); + * - both cells belong to the same rectangular hall (its open interior); + * - both cells are rooms (interior of a merged blob-shaped open area); + * - the edge is open by base density; or + * - either cell braids the edge open to reach {@link MazeConfig.minConnections}. + */ + isPassable(cx, cy, direction) { + const { dx, dy } = step(direction); + const nx = cx + dx; + const ny = cy + dy; + const atrium = this.atriumIdOf(cx, cy); + if (atrium !== null && atrium === this.atriumIdOf(nx, ny)) { + return true; + } + if (atrium === null && this.atriumIdOf(nx, ny) === null) { + const hall = this.hallIdOf(cx, cy); + if (hall !== null && hall === this.hallIdOf(nx, ny)) { + return true; + } + if (this.isRoom(cx, cy) && this.isRoom(nx, ny)) { + return true; + } + } + if (this.baseOpen(cx, cy, direction)) { + return true; + } + if (this.forcedDirections(cx, cy).includes(direction)) { + return true; + } + return this.forcedDirections(nx, ny).includes(opposite(direction)); + } + /** Whether a cell belongs to a blob-shaped room. */ + isRoom(cx, cy) { + return this.classify(cx, cy) === "room"; + } + /** Whether a cell belongs to a prop-bearing rectangular hall. */ + isHall(cx, cy) { + return this.classify(cx, cy) === "hall"; + } + /** Whether a cell belongs to a fully open atrium. */ + isAtrium(cx, cy) { + return this.classify(cx, cy) === "atrium"; + } + /** Empties the cache without affecting determinism of future cells. */ + clearCache() { + this.cache.clear(); + } + /** Returns a snapshot of cache behavior. */ + stats() { + return { + size: this.cache.size, + limit: this.config.cacheLimit, + hits: this.hits, + misses: this.misses, + evictions: this.evictions + }; + } + // --- Structural generation (seed-only, no pluggable RNG) ----------------- + /** Raw openness of the shared edge before braiding is applied. */ + baseOpen(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return unitFromHash(hashCoords(this.config.seed, ex, ey, salt)) < this.config.passageDensity; + } + /** Raw hash of a cell's edge in a direction, used to order braid choices. */ + edgeHash(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return hashCoords(this.config.seed, ex, ey, salt); + } + /** + * Directions a cell forcibly opens to reach {@link MazeConfig.minConnections} + * passages. The lowest-hash sealed edges are chosen first. Depends only on the + * cell's own four edges, so both sides of any edge agree on the result, which + * keeps passability symmetric. + */ + forcedDirections(cx, cy) { + const min = this.config.minConnections; + if (min <= 0) { + return []; + } + let openCount = 0; + const sealed = []; + for (const direction of DIRECTIONS) { + if (this.baseOpen(cx, cy, direction)) { + openCount++; + } else { + sealed.push({ direction, hash: this.edgeHash(cx, cy, direction) }); + } + } + const need = min - openCount; + if (need <= 0) { + return []; + } + sealed.sort((a, b) => a.hash - b.hash); + return sealed.slice(0, need).map((entry) => entry.direction); + } + /** Coarse, smoothed value-noise field driving room clustering. */ + roomNoise(cx, cy) { + const gx = Math.floor(cx / ROOM_LATTICE); + const gy = Math.floor(cy / ROOM_LATTICE); + const fx = smoothstep(cx / ROOM_LATTICE - gx); + const fy = smoothstep(cy / ROOM_LATTICE - gy); + const corner = (ix, iy) => unitFromHash(hashCoords(this.config.seed, ix, iy, SALT.roomNoise)); + const top = lerp(corner(gx, gy), corner(gx + 1, gy), fx); + const bottom = lerp(corner(gx, gy + 1), corner(gx + 1, gy + 1), fx); + return lerp(top, bottom, fy); + } + /** + * Width and height (in cells) of the hall anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Pure function of the anchor coordinates. + */ + hallSpawn(ax, ay) { + if (this.config.hallFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.hallAnchor)) >= this.config.hallFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const pick = (salt) => min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, salt)) * span)); + return { w: pick(SALT.hallWidth), h: pick(SALT.hallHeight) }; + } + /** + * Canonical identifier of the hall covering a cell, or `null` when the cell is + * not in a hall. Only anchors within {@link MazeConfig.hallSize}`.max` of the + * cell can possibly cover it, so the scan is bounded and stateless. When + * rectangles overlap, the lowest-hash anchor wins, so both sides of any edge + * agree on hall membership. + */ + hallIdOf(cx, cy) { + const maxSide = this.config.hallSize.max; + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.hallSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.hallAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Largest possible atrium side length in cells, bounding the anchor scan. */ + atriumMaxSide() { + return Math.max(2, Math.round(this.config.hallSize.max * ATRIUM_SIZE_FACTOR * (1 + ATRIUM_SIZE_VARIANCE))); + } + /** + * Width and height (in cells) of the atrium anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Each side is drawn like a hall side and + * then scaled up by {@link ATRIUM_SIZE_FACTOR} with a per-side + * {@link ATRIUM_SIZE_VARIANCE}, so atriums run a little larger than halls. + */ + atriumSpawn(ax, ay) { + if (this.config.atriumFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor)) >= this.config.atriumFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const side = (sizeSalt, varianceSalt) => { + const baseCells = min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, sizeSalt)) * span)); + const variance = (unitFromHash(hashCoords(this.config.seed, ax, ay, varianceSalt)) * 2 - 1) * ATRIUM_SIZE_VARIANCE; + return Math.max(2, Math.round(baseCells * ATRIUM_SIZE_FACTOR * (1 + variance))); + }; + return { + w: side(SALT.atriumWidth, SALT.atriumVarWidth), + h: side(SALT.atriumHeight, SALT.atriumVarHeight) + }; + } + /** + * Canonical identifier of the atrium covering a cell, or `null`. Mirrors + * {@link hallIdOf}: bounded, stateless, and lowest-hash-anchor canonical so + * both sides of any edge agree on membership. + */ + atriumIdOf(cx, cy) { + const maxSide = this.atriumMaxSide(); + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.atriumSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Resolves the open-character classification of a cell. */ + classify(cx, cy) { + if (this.atriumIdOf(cx, cy) !== null) { + return "atrium"; + } + if (this.hallIdOf(cx, cy) !== null) { + return "hall"; + } + if (this.roomNoise(cx, cy) < this.config.roomFrequency) { + return "room"; + } + return "corridor"; + } + /** Footprint and extrusion dimensions for a cell. */ + dimensions(cx, cy, kind) { + const { width, height, depth } = this.config; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + if (kind === "atrium") { + return { width: width.max, height: height.max, depth: depth.max }; + } + if (kind === "hall") { + return { + width: width.max, + height: height.max, + depth: lerp((depth.min + depth.max) / 2, depth.max, draw(SALT.depth)) + }; + } + if (kind === "room") { + return { + width: lerp(width.min, width.max, draw(SALT.width)), + height: lerp(height.min, height.max, draw(SALT.height)), + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + return { + width: width.max, + height: height.max, + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + /** + * A deterministic interior prop for an open cell, or `null`. Props occur only + * in rooms and halls and are gated by {@link MazeConfig.propFrequency}. + */ + feature(cx, cy, kind) { + if (kind === "corridor" || kind === "atrium" || this.config.propFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, cx, cy, SALT.prop)) >= this.config.propFrequency) { + return null; + } + const size = this.config.cellSize; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + const featureKind = FEATURE_KINDS[Math.min(FEATURE_KINDS.length - 1, Math.floor(draw(SALT.propKind) * FEATURE_KINDS.length))] ?? FEATURE_KINDS[0]; + const fx = 0.3 + 0.4 * draw(SALT.propPositionX); + const fy = 0.3 + 0.4 * draw(SALT.propPositionY); + return { + kind: featureKind, + position: { x: cx * size + fx * size, y: cy * size + fy * size }, + size: lerp(0.08, 0.22, draw(SALT.propSize)) * size, + variantSeed: hashCoords(this.config.seed, cx, cy, SALT.propVariant) + }; + } + /** Builds the full cell from its structural inputs. */ + computeCell(cx, cy) { + const kind = this.classify(cx, cy); + const size = this.config.cellSize; + const x0 = cx * size; + const y0 = cy * size; + const x1 = x0 + size; + const y1 = y0 + size; + const corners = { + north: { start: { x: x0, y: y0 }, end: { x: x1, y: y0 } }, + south: { start: { x: x0, y: y1 }, end: { x: x1, y: y1 } }, + west: { start: { x: x0, y: y0 }, end: { x: x0, y: y1 } }, + east: { start: { x: x1, y: y0 }, end: { x: x1, y: y1 } } + }; + const edges = {}; + for (const direction of DIRECTIONS) { + const solid = !this.isPassable(cx, cy, direction); + edges[direction] = { + direction, + solid, + start: corners[direction].start, + end: corners[direction].end, + metadata: this.edgeMetadata(cx, cy, direction, kind) + }; + } + return { + cx, + cy, + kind, + bounds: { min: { x: x0, y: y0 }, max: { x: x1, y: y1 } }, + dimensions: this.dimensions(cx, cy, kind), + edges, + feature: this.feature(cx, cy, kind) + }; + } + // --- Cosmetic metadata (pluggable RNG) ----------------------------------- + /** + * Builds per-edge extrusion metadata. The material seed is a structural hash + * (stable across RNG swaps), but every cosmetic value is drawn from the + * configured RNG factory, so changing the factory changes only this output. + */ + edgeMetadata(cx, cy, direction, classification) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + const materialSeed = hashCoords(this.config.seed, ex, ey, salt + SALT.material); + const rng = this.config.rng(materialSeed); + const { depth } = this.config; + const heightBase = lerp(depth.min, depth.max, rng.next()); + const heightVariance = lerp(0, (depth.max - depth.min) * 0.5, rng.next()); + const thickness = lerp(0.05, 0.2, rng.next()); + const surfaceType = SURFACE_TYPES[Math.min(SURFACE_TYPES.length - 1, Math.floor(rng.next() * SURFACE_TYPES.length))] ?? SURFACE_TYPES[0]; + return { + heightBase, + heightVariance, + thickness, + surfaceType, + materialSeed, + classification + }; + } + /** Evicts least-recently-used cells when the cache exceeds its limit. */ + evictIfNeeded() { + const limit = this.config.cacheLimit; + if (limit <= 0) { + return; + } + while (this.cache.size > limit) { + const oldest = this.cache.keys().next().value; + if (oldest === void 0) { + break; + } + this.cache.delete(oldest); + this.evictions++; + } + } + }; + + // ../cmd-backedges/src/events.ts + var EventEmitter = class { + constructor() { + this.listeners = /* @__PURE__ */ new Map(); + } + /** + * Registers a listener for an event. + * + * @returns A function that unregisters the listener. + */ + on(event, listener) { + let set = this.listeners.get(event); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.listeners.set(event, set); + } + set.add(listener); + return () => this.off(event, listener); + } + /** Unregisters a previously registered listener. */ + off(event, listener) { + this.listeners.get(event)?.delete(listener); + } + /** Emits an event to all current listeners. */ + emit(event, payload) { + const set = this.listeners.get(event); + if (!set) { + return; + } + for (const listener of [...set]) { + listener(payload); + } + } + /** Removes all listeners, or all listeners for a single event. */ + clear(event) { + if (event === void 0) { + this.listeners.clear(); + } else { + this.listeners.delete(event); + } + } + }; + + // ../cmd-backedges/src/player.ts + var MazeSession = class { + /** + * @param generator - The maze to traverse. + * @param start - Optional starting cell (defaults to the origin). + */ + constructor(generator, start = {}) { + this.emitter = new EventEmitter(); + this.visited = /* @__PURE__ */ new Set(); + this.generator = generator; + this.state = { + cx: start.cx ?? 0, + cy: start.cy ?? 0, + facing: "south" + }; + this.markVisited(this.state.cx, this.state.cy); + } + /** Returns a copy of the current player state. */ + get player() { + return { ...this.state }; + } + /** The cell the player currently occupies. */ + get currentCell() { + return this.generator.getCell(this.state.cx, this.state.cy); + } + /** + * Attempts to move the player one cell in a direction. + * + * Always updates {@link PlayerState.facing}. On success the player advances + * and a `move` event (plus an `enterCell` event for first visits) fires; on + * failure a `blocked` event fires and the position is unchanged. + * + * @returns `true` if the player moved, `false` if blocked by a wall. + */ + move(direction) { + this.state.facing = direction; + const { cx, cy } = this.state; + if (!this.generator.isPassable(cx, cy, direction)) { + this.emitter.emit("blocked", { at: { cx, cy }, direction }); + return false; + } + const { dx, dy } = step(direction); + const to = { cx: cx + dx, cy: cy + dy }; + this.state.cx = to.cx; + this.state.cy = to.cy; + const cell = this.generator.getCell(to.cx, to.cy); + this.emitter.emit("move", { from: { cx, cy }, to, direction, cell }); + if (this.markVisited(to.cx, to.cy)) { + this.emitter.emit("enterCell", cell); + } + return true; + } + /** Teleports the player to an arbitrary cell, bypassing wall checks. */ + warpTo(cx, cy) { + this.state.cx = cx; + this.state.cy = cy; + const cell = this.generator.getCell(cx, cy); + if (this.markVisited(cx, cy)) { + this.emitter.emit("enterCell", cell); + } + } + /** Registers a listener for a session event; returns an unsubscribe fn. */ + on(event, listener) { + return this.emitter.on(event, listener); + } + /** Records a visit; returns `true` only on the first visit to the cell. */ + markVisited(cx, cy) { + const key = `${cx},${cy}`; + if (this.visited.has(key)) { + return false; + } + this.visited.add(key); + return true; + } + }; + + // src/webview/textures.ts + var ATLAS_GRID = 4; + var TILE_PX = 256; + var TILE = { + wallpaperA: 0, + wallpaperB: 1, + ceiling: 2, + lightPanel: 3, + carpet: 4, + concrete: 5, + paneling: 6, + drywall: 7, + ceramic: 8, + fabric: 9, + metal: 10, + wood: 11, + cardboard: 12 + }; + function applyMaterialImages(canvas, images) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const blit = (tile, image, after) => { + const x = tile % ATLAS_GRID * TILE_PX; + const y = Math.floor(tile / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + ctx.drawImage(image, 0, 0, TILE_PX, TILE_PX); + after?.(ctx); + ctx.restore(); + }; + if (images.wallpaper) { + blit(TILE.wallpaperA, images.wallpaper); + blit(TILE.wallpaperB, images.wallpaper, (c) => { + c.fillStyle = "rgba(96, 78, 30, 0.28)"; + c.fillRect(0, 0, TILE_PX, TILE_PX); + }); + } + if (images.ceiling) { + blit(TILE.ceiling, images.ceiling, (c) => { + c.strokeStyle = "rgba(140, 134, 116, 0.85)"; + c.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += TILE_PX / 2) { + c.beginPath(); + c.moveTo(p, 0); + c.lineTo(p, TILE_PX); + c.moveTo(0, p); + c.lineTo(TILE_PX, p); + c.stroke(); + } + }); + } + if (images.carpet) { + blit(TILE.carpet, images.carpet); + } + } + function buildAtlas() { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = ATLAS_GRID * TILE_PX; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D canvas context unavailable; cannot build texture atlas"); + } + const painters = { + [TILE.wallpaperA]: paintWallpaperA, + [TILE.wallpaperB]: paintWallpaperB, + [TILE.ceiling]: paintCeiling, + [TILE.lightPanel]: paintLightPanel, + [TILE.carpet]: paintCarpet, + [TILE.concrete]: paintConcrete, + [TILE.paneling]: paintPaneling, + [TILE.drywall]: paintDrywall, + [TILE.ceramic]: paintCeramic, + [TILE.fabric]: paintFabric, + [TILE.metal]: paintMetal, + [TILE.wood]: paintWood, + [TILE.cardboard]: paintCardboard + }; + for (const [index, paint] of Object.entries(painters)) { + const i = Number(index); + const x = i % ATLAS_GRID * TILE_PX; + const y = Math.floor(i / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + paint(ctx); + ctx.restore(); + } + return canvas; + } + function grain(ctx, seed, amount) { + const image = ctx.getImageData(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(seed); + const data = image.data; + for (let i = 0; i < data.length; i += 4) { + const n = (rng.next() * 2 - 1) * amount; + data[i] = clampByte(data[i] + n); + data[i + 1] = clampByte(data[i + 1] + n); + data[i + 2] = clampByte(data[i + 2] + n); + } + const off = document.createElement("canvas"); + off.width = off.height = TILE_PX; + off.getContext("2d").putImageData(image, 0, 0); + ctx.drawImage(off, 0, 0); + } + function clampByte(v) { + return v < 0 ? 0 : v > 255 ? 255 : v; + } + function stains(ctx, seed, color, count) { + const rng = mulberry32(seed); + ctx.fillStyle = color; + for (let i = 0; i < count; i++) { + const cx = rng.next() * TILE_PX; + const cy = rng.next() * TILE_PX; + const r = 12 + rng.next() * 46; + ctx.globalAlpha = 0.04 + rng.next() * 0.07; + ctx.beginPath(); + ctx.ellipse(cx, cy, r, r * (0.5 + rng.next() * 0.8), rng.next() * Math.PI, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + function paintWallpaperA(ctx) { + ctx.fillStyle = "#c9b765"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#b7a352"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x, 0, 14, TILE_PX); + } + ctx.fillStyle = "#a3914a"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x + 13, 0, 2, TILE_PX); + } + stains(ctx, 11, "#6f5f2c", 9); + grain(ctx, 12, 7); + } + function paintWallpaperB(ctx) { + ctx.fillStyle = "#c4b268"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#ab984f"; + for (let y = 0; y < TILE_PX; y += 32) { + for (let x = 0; x < TILE_PX; x += 32) { + const ox = Math.floor(y / 32) % 2 * 16; + diamond(ctx, x + ox + 16, y + 16, 7); + } + } + stains(ctx, 21, "#6f5f2c", 7); + grain(ctx, 22, 6); + } + function diamond(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + } + function paintCeiling(ctx) { + ctx.fillStyle = "#d8d3c2"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(31); + ctx.fillStyle = "#b9b4a1"; + for (let i = 0; i < 2600; i++) { + ctx.fillRect(rng.next() * TILE_PX, rng.next() * TILE_PX, 1.5, 1.5); + } + ctx.strokeStyle = "#a09a87"; + ctx.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += 128) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + stains(ctx, 32, "#7c7452", 5); + grain(ctx, 33, 5); + } + function paintLightPanel(ctx) { + const g = ctx.createRadialGradient(128, 128, 20, 128, 128, 190); + g.addColorStop(0, "#fefadd"); + g.addColorStop(0.7, "#f8eeb4"); + g.addColorStop(1, "#e4d78d"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.strokeStyle = "rgba(190, 176, 110, 0.35)"; + ctx.lineWidth = 2; + for (let p = 0; p <= TILE_PX; p += 32) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + grain(ctx, 41, 3); + } + function paintCarpet(ctx) { + ctx.fillStyle = "#c2b47a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(51); + for (let i = 0; i < 5200; i++) { + const x = rng.next() * TILE_PX; + const y = rng.next() * TILE_PX; + const shade = 150 + Math.floor(rng.next() * 60); + ctx.strokeStyle = `rgb(${shade}, ${shade - 14}, ${Math.floor(shade * 0.62)})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (rng.next() * 4 - 2), y + (rng.next() * 4 - 2)); + ctx.stroke(); + } + stains(ctx, 52, "#5d5228", 8); + grain(ctx, 53, 6); + } + function paintConcrete(ctx) { + ctx.fillStyle = "#9a958a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(61); + ctx.strokeStyle = "rgba(70, 66, 58, 0.5)"; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + let x = rng.next() * TILE_PX; + let y = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(x, y); + for (let s = 0; s < 6; s++) { + x += rng.next() * 40 - 20; + y += rng.next() * 40 - 10; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + stains(ctx, 62, "#4c483e", 6); + grain(ctx, 63, 10); + } + function paintPaneling(ctx) { + ctx.fillStyle = "#a8905e"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(71); + for (let x = 0; x < TILE_PX; x += 42) { + ctx.fillStyle = "#7d6741"; + ctx.fillRect(x, 0, 3, TILE_PX); + for (let i = 0; i < 22; i++) { + ctx.strokeStyle = `rgba(110, 88, 52, ${0.15 + rng.next() * 0.2})`; + const gx = x + 5 + rng.next() * 34; + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.bezierCurveTo(gx + 4, 80, gx - 4, 170, gx + 2, TILE_PX); + ctx.stroke(); + } + } + grain(ctx, 72, 6); + } + function paintDrywall(ctx) { + ctx.fillStyle = "#cfc7ad"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + stains(ctx, 81, "#8d8465", 6); + grain(ctx, 82, 6); + } + function paintCeramic(ctx) { + ctx.fillStyle = "#b8b2a0"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#d7d2c2"; + for (let y = 0; y < TILE_PX; y += 64) { + for (let x = 0; x < TILE_PX; x += 64) { + ctx.fillRect(x + 3, y + 3, 58, 58); + } + } + stains(ctx, 91, "#6d6752", 5); + grain(ctx, 92, 5); + } + function paintFabric(ctx) { + ctx.fillStyle = "#7a6f52"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + grain(ctx, 101, 12); + } + function paintMetal(ctx) { + ctx.fillStyle = "#8e9296"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(111); + for (let x = 0; x < TILE_PX; x += 2) { + ctx.fillStyle = `rgba(255, 255, 255, ${rng.next() * 0.06})`; + ctx.fillRect(x, 0, 1, TILE_PX); + } + grain(ctx, 112, 5); + } + function paintWood(ctx) { + ctx.fillStyle = "#8b6b43"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(121); + for (let i = 0; i < 30; i++) { + ctx.strokeStyle = `rgba(70, 50, 26, ${0.15 + rng.next() * 0.25})`; + const gy = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.bezierCurveTo(80, gy + 6, 170, gy - 6, TILE_PX, gy + 3); + ctx.stroke(); + } + grain(ctx, 122, 6); + } + function paintCardboard(ctx) { + ctx.fillStyle = "#b59a6b"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "rgba(214, 205, 175, 0.8)"; + ctx.fillRect(0, 108, TILE_PX, 40); + ctx.strokeStyle = "rgba(90, 72, 44, 0.6)"; + ctx.lineWidth = 2; + ctx.strokeRect(6, 6, TILE_PX - 12, TILE_PX - 12); + grain(ctx, 131, 8); + } + + // src/webview/renderer.ts + var FLOATS_PER_VERTEX = 10; + var EMISSIVE_SHADE = 2; + var VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aTile; +attribute vec3 aTint; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vTile = aTile; + vTint = aTint; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uAtlas; +uniform vec3 uFogColor; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +const float GRID = ${ATLAS_GRID.toFixed(1)}; + +void main() { + float tile = floor(vTile + 0.5); + vec2 cell = vec2(mod(tile, GRID), floor(tile / GRID)); + // Half-texel inset keeps repeated tiles from bleeding across atlas seams. + vec2 local = fract(vUv) * (1.0 - 2.0 / 256.0) + 1.0 / 256.0; + vec4 tex = texture2D(uAtlas, (cell + local) / GRID); + + if (vShade >= ${EMISSIVE_SHADE.toFixed(1)} - 0.25) { + // Emissive light panel: flicker, no fog fade. + vec3 lit = tex.rgb * vTint * uFlicker * (vShade - 1.0); + float glowFog = 1.0 - exp(-vDist * vDist * uFogDensity * 0.35); + gl_FragColor = vec4(mix(lit, uFogColor, glowFog), 1.0); + return; + } + + vec3 color = tex.rgb * vTint * vShade * uFlicker; + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + gl_FragColor = vec4(mix(color, uFogColor, fog), 1.0); +} +`; + var FLOATS_PER_DECAL_VERTEX = 6; + var DECAL_VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var DECAL_FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uTexture; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vec4 tex = texture2D(uTexture, vUv); + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + // Ink dims with the wall lighting and dissolves into the fog. + gl_FragColor = vec4(tex.rgb * vShade * uFlicker, tex.a * (1.0 - fog)); +} +`; + var Renderer = class { + gl; + program; + uniforms; + attribs; + canvas; + fogColor = [0.055, 0.048, 0.02]; + fogDensity = 0.012; + // One rewritable mesh for animated geometry (the monster), rebuilt per frame. + dynVbo = null; + dynIbo = null; + dynCount = 0; + atlasCanvas; + atlasTexture; + // Wall-writing decals: separate program, texture, and mesh, rebuilt only + // when a new line is scrawled. + decalProgram; + decalUniforms; + decalAttribs; + decalTexture = null; + decalVbo = null; + decalIbo = null; + decalCount = 0; + constructor(canvas) { + this.canvas = canvas; + const gl = canvas.getContext("webgl", { antialias: true, alpha: false }); + if (!gl) { + throw new Error("WebGL is not available in this webview"); + } + this.gl = gl; + this.program = buildProgram(gl, VERTEX_SRC, FRAGMENT_SRC); + this.attribs = { + aPosition: gl.getAttribLocation(this.program, "aPosition"), + aUv: gl.getAttribLocation(this.program, "aUv"), + aTile: gl.getAttribLocation(this.program, "aTile"), + aTint: gl.getAttribLocation(this.program, "aTint"), + aShade: gl.getAttribLocation(this.program, "aShade") + }; + this.uniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uAtlas", "uFogColor", "uFogDensity", "uFlicker"]) { + this.uniforms[name] = gl.getUniformLocation(this.program, name); + } + this.decalProgram = buildProgram(gl, DECAL_VERTEX_SRC, DECAL_FRAGMENT_SRC); + this.decalAttribs = { + aPosition: gl.getAttribLocation(this.decalProgram, "aPosition"), + aUv: gl.getAttribLocation(this.decalProgram, "aUv"), + aShade: gl.getAttribLocation(this.decalProgram, "aShade") + }; + this.decalUniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uTexture", "uFogDensity", "uFlicker"]) { + this.decalUniforms[name] = gl.getUniformLocation(this.decalProgram, name); + } + this.atlasCanvas = buildAtlas(); + this.atlasTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.CULL_FACE); + gl.cullFace(gl.BACK); + } + /** Patches photo materials over the procedural atlas and re-uploads it. */ + applyMaterialImages(images) { + applyMaterialImages(this.atlasCanvas, images); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + } + uploadChunk(vertices, indices) { + const gl = this.gl; + const vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + const ibo = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + return { vbo, ibo, indexCount: indices.length }; + } + disposeChunk(mesh) { + this.gl.deleteBuffer(mesh.vbo); + this.gl.deleteBuffer(mesh.ibo); + } + /** Replaces the dynamic mesh drawn after the chunks this frame. */ + setDynamicMesh(vertices, indices) { + const gl = this.gl; + this.dynVbo ??= gl.createBuffer(); + this.dynIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.dynVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.dynIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.dynCount = indices.length; + } + clearDynamicMesh() { + this.dynCount = 0; + } + /** + * Uploads (or re-uploads) the wall-writing texture from a canvas. + * Uses texture unit 1 to avoid disturbing the wall atlas on unit 0. + */ + setDecalTexture(source) { + const gl = this.gl; + this.decalTexture ??= gl.createTexture(); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.activeTexture(gl.TEXTURE0); + } + /** + * Replaces the decal mesh (vertex layout: position(3), uv(2), shade(1)). + * The decal pass draws wall writings as alpha-blended quads over the walls. + */ + setDecalMesh(vertices, indices) { + const gl = this.gl; + this.decalVbo ??= gl.createBuffer(); + this.decalIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.decalCount = indices.length; + } + clearDecalMesh() { + this.decalCount = 0; + } + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.floor(this.canvas.clientWidth * dpr); + const h = Math.floor(this.canvas.clientHeight * dpr); + if (w > 0 && h > 0 && (this.canvas.width !== w || this.canvas.height !== h)) { + this.canvas.width = w; + this.canvas.height = h; + } + } + draw(chunks, camera, flicker) { + const gl = this.gl; + this.resize(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(this.fogColor[0], this.fogColor[1], this.fogColor[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(this.program); + const aspect = this.canvas.width / Math.max(1, this.canvas.height); + const viewProj = mat4Multiply( + mat4Perspective(camera.fovY, aspect, 0.02, 80), + mat4View(camera) + ); + gl.uniformMatrix4fv(this.uniforms.uViewProj, false, viewProj); + gl.uniform3f(this.uniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform3f(this.uniforms.uFogColor, this.fogColor[0], this.fogColor[1], this.fogColor[2]); + gl.uniform1f(this.uniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.uniforms.uFlicker, flicker); + gl.uniform1i(this.uniforms.uAtlas, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + for (const mesh of chunks) { + this.drawMesh(mesh.vbo, mesh.ibo, mesh.indexCount); + } + if (this.dynCount > 0 && this.dynVbo && this.dynIbo) { + this.drawMesh(this.dynVbo, this.dynIbo, this.dynCount); + } + this.drawDecals(viewProj, camera, flicker); + } + /** Alpha-blended wall-writing pass, drawn over the opaque geometry. */ + drawDecals(viewProj, camera, flicker) { + if (this.decalCount === 0 || !this.decalVbo || !this.decalIbo || !this.decalTexture) { + return; + } + const gl = this.gl; + gl.useProgram(this.decalProgram); + gl.uniformMatrix4fv(this.decalUniforms.uViewProj, false, viewProj); + gl.uniform3f(this.decalUniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform1f(this.decalUniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.decalUniforms.uFlicker, flicker); + gl.uniform1i(this.decalUniforms.uTexture, 1); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.activeTexture(gl.TEXTURE0); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.depthMask(false); + for (const a of Object.values(this.attribs)) { + gl.disableVertexAttribArray(a); + } + const stride = FLOATS_PER_DECAL_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.vertexAttribPointer(this.decalAttribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.decalAttribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.decalAttribs.aShade, 1, gl.FLOAT, false, stride, 20); + for (const a of Object.values(this.decalAttribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, this.decalCount, gl.UNSIGNED_SHORT, 0); + for (const a of Object.values(this.decalAttribs)) { + gl.disableVertexAttribArray(a); + } + gl.depthMask(true); + gl.disable(gl.BLEND); + } + drawMesh(vbo, ibo, indexCount) { + const gl = this.gl; + const stride = FLOATS_PER_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.vertexAttribPointer(this.attribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.attribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.attribs.aTile, 1, gl.FLOAT, false, stride, 20); + gl.vertexAttribPointer(this.attribs.aTint, 3, gl.FLOAT, false, stride, 24); + gl.vertexAttribPointer(this.attribs.aShade, 1, gl.FLOAT, false, stride, 36); + for (const a of Object.values(this.attribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0); + } + }; + function buildProgram(gl, vsSrc, fsSrc) { + const compile = (type, src) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`Shader compile failed: ${gl.getShaderInfoLog(shader) ?? "unknown"}`); + } + return shader; + }; + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program) ?? "unknown"}`); + } + return program; + } + function mat4Perspective(fovY, aspect, near, far) { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + const out = new Float32Array(16); + out[0] = f / aspect; + out[5] = f; + out[10] = (far + near) * nf; + out[11] = -1; + out[14] = 2 * far * near * nf; + return out; + } + function mat4View(camera) { + const rot = mat4Multiply( + mat4RotateZ(-camera.roll), + mat4Multiply(mat4RotateX(-camera.pitch), mat4RotateY(-camera.yaw)) + ); + const trans = mat4Identity(); + trans[12] = -camera.x; + trans[13] = -camera.y; + trans[14] = -camera.z; + return mat4Multiply(rot, trans); + } + function mat4Identity() { + const out = new Float32Array(16); + out[0] = out[5] = out[10] = out[15] = 1; + return out; + } + function mat4RotateX(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[5] = c; + out[6] = s; + out[9] = -s; + out[10] = c; + return out; + } + function mat4RotateY(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[2] = -s; + out[8] = s; + out[10] = c; + return out; + } + function mat4RotateZ(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[1] = s; + out[4] = -s; + out[5] = c; + return out; + } + function mat4Multiply(a, b) { + const out = new Float32Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + out[col * 4 + row] = sum; + } + } + return out; + } + + // src/webview/world.ts + var DEPTH_RANGE = { min: 1.1, max: 1.6 }; + var PLAYER_RADIUS = 0.24; + var WALL_HALF_DEPTH = 0.06; + var DOOR_TOP_FRACTION = 0.84; + var CHUNK_SIZE = 4; + var LIGHT_LATTICE = 3; + var ZONE_LATTICE = 12; + var SALT2 = { + lightState: 7001, + zoneAnchor: 7101, + zoneCenterX: 7102, + zoneCenterY: 7103, + zoneRadius: 7104, + zonePhase1: 7105, + zonePhase2: 7106, + zonePalette: 7107, + zoneVariant: 7108 + }; + var WALL_TILE_BY_SURFACE = { + drywall: TILE.drywall, + wallpaper: TILE.wallpaperA, + paneling: TILE.paneling, + concrete: TILE.concrete, + tile: TILE.ceramic, + carpet: TILE.carpet + }; + var MATERIAL_PRESETS = { + classic: { tile: null, tint: [1, 1, 1] }, + office: { tile: TILE.drywall, tint: [1, 1, 1] }, + pool: { tile: TILE.ceramic, tint: [0.88, 1, 1.06] }, + concrete: { tile: TILE.concrete, tint: [1, 1, 1] }, + panel: { tile: TILE.paneling, tint: [1, 1, 1] } + }; + var ZONE_PALETTES = [ + [1.1, 0.6, 0.52], + // faded rose + [0.58, 0.95, 0.38], + // mossy green + [0.5, 0.72, 1.1], + // dusty blue + [0.72, 0.5, 0.26], + // deep sepia + [1.35, 1.32, 1.12] + // bleached bone + ]; + var WHITE = [1, 1, 1]; + var World = class { + generator; + session; + seed; + furnitureEnabled = true; + // Off by default, matching DEFAULT_SETTINGS.wallpaperShifts. + wallpaperShiftsEnabled = false; + /** + * When a photo wallpaper is loaded, the classic preset papers every wall + * with it instead of the generator's mixed surface types; per-edge wear and + * wallpaper zones still provide the variation. + */ + uniformWallpaper = false; + wallOverrideTile = null; + wallBaseTint = [1, 1, 1]; + cellsVisited = 1; + zoneCache = /* @__PURE__ */ new Map(); + chunks = /* @__PURE__ */ new Map(); + constructor(seed) { + this.seed = seed; + this.generator = new MazeGenerator({ + seed, + depth: DEPTH_RANGE, + propFrequency: 0.14 + }); + this.session = new MazeSession(this.generator); + this.session.on("enterCell", () => { + this.cellsVisited++; + }); + } + /** + * Applies a wall material preset plus its adjustable elements (hue rotation + * in degrees and a brightness multiplier). Call invalidateChunks afterwards + * so existing meshes pick the change up. + * + * The preset determines the base tile and tint for all walls. Hue rotation + * lets players shift the wallpaper color, and brightness scales the overall + * material lightness without destroying the wear variation. + */ + setMaterial(preset, hueShiftDeg, brightness) { + const base = MATERIAL_PRESETS[preset] ?? MATERIAL_PRESETS.classic; + this.wallOverrideTile = base.tile; + this.wallBaseTint = hueRotate(base.tint, hueShiftDeg).map( + (v) => Math.max(0, v * brightness) + ); + } + /** Public light sample for decorations drawn outside the chunk mesher. */ + lightAt(x, y) { + return this.lightLevelAt(x, y); + } + stats() { + return { + seed: this.seed, + cellsVisited: this.cellsVisited, + cacheSize: this.generator.stats().size + }; + } + /** + * Keeps the discrete MazeSession in step with the continuous player + * position, so its move/enterCell events stay meaningful. + * + * When the player crosses a cell boundary, this tries to move the session + * in the matching direction. If that fails (shouldn't happen - walls block + * both), it warps the session to match reality. + */ + syncSession(px, py) { + const cx = Math.floor(px); + const cy = Math.floor(py); + const at = this.session.player; + if (at.cx === cx && at.cy === cy) { + return; + } + const dx = cx - at.cx; + const dy = cy - at.cy; + if (Math.abs(dx) + Math.abs(dy) === 1) { + const direction = dx === 1 ? "east" : dx === -1 ? "west" : dy === 1 ? "south" : "north"; + if (!this.session.move(direction)) { + this.session.warpTo(cx, cy); + } + } else { + this.session.warpTo(cx, cy); + } + } + // --- Movement ------------------------------------------------------------ + /** + * Moves the player from (px, py) toward (px+dx, py+dy) in plane coordinates, + * resolving collisions per axis so the player slides along walls. + * + * This implements a sweep-and-clamp collision resolver: each axis is tested + * independently. If the desired position is blocked, the coordinate is + * clamped to the nearest wall surface (within the motion delta to avoid + * teleport-like jumps). This creates smooth wall-sliding behavior common in + * first-person games. + */ + moveResolved(px, py, dx, dy) { + let x = px; + let y = py; + const withinStep = (from, to, v) => v >= Math.min(from, to) - 1e-9 && v <= Math.max(from, to) + 1e-9; + const tryAxis = (nx, ny, axis) => { + if (this.canOccupy(nx, ny)) { + x = nx; + y = ny; + return; + } + if (axis === "x") { + const cx = Math.floor(x); + const clamped = nx > x ? cx + 1 - PLAYER_RADIUS - 1e-4 : cx + PLAYER_RADIUS + 1e-4; + if (withinStep(x, nx, clamped) && this.canOccupy(clamped, ny)) { + x = clamped; + y = ny; + } + } else { + const cy = Math.floor(y); + const clamped = ny > y ? cy + 1 - PLAYER_RADIUS - 1e-4 : cy + PLAYER_RADIUS + 1e-4; + if (withinStep(y, ny, clamped) && this.canOccupy(nx, clamped)) { + x = nx; + y = clamped; + } + } + }; + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / PLAYER_RADIUS)); + for (let i = 0; i < steps; i++) { + tryAxis(x + dx / steps, y, "x"); + tryAxis(x, y + dy / steps, "y"); + } + return { x, y }; + } + /** + * Whether a player disc at (x, y) fits: each corner of its bounding square + * must be reachable from the center cell through open edges only. + * + * This implements circle-vs-grid collision by checking the four corners of + * the circle's bounding box. Each corner cell must be connected to the + * player's center cell via a valid path of open edges (either directly if + * they're adjacent, or through an intermediate cell at a diagonal). + */ + canOccupy(x, y) { + const cx = Math.floor(x); + const cy = Math.floor(y); + for (const ox of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + for (const oy of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + const ccx = Math.floor(x + ox); + const ccy = Math.floor(y + oy); + if (ccx === cx && ccy === cy) { + continue; + } + const dirX = ccx > cx ? "east" : ccx < cx ? "west" : null; + const dirY = ccy > cy ? "south" : ccy < cy ? "north" : null; + if (dirX && !dirY) { + if (!this.generator.isPassable(cx, cy, dirX)) { + return false; + } + } else if (dirY && !dirX) { + if (!this.generator.isPassable(cx, cy, dirY)) { + return false; + } + } else if (dirX && dirY) { + const viaX = this.generator.isPassable(cx, cy, dirX) && this.generator.isPassable(ccx, cy, dirY); + const viaY = this.generator.isPassable(cx, cy, dirY) && this.generator.isPassable(cx, ccy, dirX); + if (!viaX && !viaY) { + return false; + } + } + } + } + return true; + } + // --- Lights ---------------------------------------------------------------- + /** + * Determines light state for a cell: on, dead, or flickering. + * Lights live on a 3-cell lattice (offset to [1,1] within the pattern). + * Returns null for cells that have no light fixture. + * + * Dead lights (12% chance) never illuminate. Flickering lights (8% chance) + * pulse erratically. The rest stay on with the global flicker hum. + */ + lightState(cx, cy) { + const mod = (n, m) => (n % m + m) % m; + if (mod(cx, LIGHT_LATTICE) !== 1 || mod(cy, LIGHT_LATTICE) !== 1) { + return null; + } + const u = unitFromHash(hashCoords(this.seed, cx, cy, SALT2.lightState)); + if (u < 0.12) { + return "dead"; + } + if (u < 0.2) { + return "flicker"; + } + return "on"; + } + /** Summed light contribution at a plane point, in [0, 1]. */ + lightLevelAt(x, y) { + const reach = 3.2; + let level = 0; + const minGx = Math.floor((x - reach) / LIGHT_LATTICE); + const maxGx = Math.floor((x + reach) / LIGHT_LATTICE); + const minGy = Math.floor((y - reach) / LIGHT_LATTICE); + const maxGy = Math.floor((y + reach) / LIGHT_LATTICE); + for (let gy = minGy; gy <= maxGy; gy++) { + for (let gx = minGx; gx <= maxGx; gx++) { + const lcx = gx * LIGHT_LATTICE + 1; + const lcy = gy * LIGHT_LATTICE + 1; + const state = this.lightState(lcx, lcy); + if (state !== "on" && state !== "flicker") { + continue; + } + const dx = x - (lcx + 0.5); + const dy = y - (lcy + 0.5); + const d = Math.sqrt(dx * dx + dy * dy); + if (d < reach) { + const fall = 1 - d / reach; + level += fall * fall * (state === "flicker" ? 0.55 : 1); + } + } + } + return Math.min(1, level); + } + // --- Wallpaper zones ------------------------------------------------------- + /** + * Random enclosed shapes: anchors on a coarse lattice each spawn a wobbled + * closed radial blob (radius modulated by two sine harmonics with hashed + * phases). A cell inside a blob adopts that zone's palette; the innermost + * blob wins where blobs overlap. + */ + zoneAt(cx, cy) { + if (!this.wallpaperShiftsEnabled) { + return null; + } + const key = `${cx},${cy}`; + const cached = this.zoneCache.get(key); + if (cached !== void 0) { + return cached; + } + if (this.zoneCache.size > 2e4) { + this.zoneCache.clear(); + } + const gx0 = Math.floor(cx / ZONE_LATTICE); + const gy0 = Math.floor(cy / ZONE_LATTICE); + let best = null; + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + const draw = (salt) => unitFromHash(hashCoords(this.seed, gx, gy, salt)); + if (draw(SALT2.zoneAnchor) >= 0.45) { + continue; + } + const centerX = (gx + draw(SALT2.zoneCenterX)) * ZONE_LATTICE; + const centerY = (gy + draw(SALT2.zoneCenterY)) * ZONE_LATTICE; + const base = 3 + draw(SALT2.zoneRadius) * 5; + const dx = cx + 0.5 - centerX; + const dy = cy + 0.5 - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + const theta = Math.atan2(dy, dx); + const p1 = draw(SALT2.zonePhase1) * Math.PI * 2; + const p2 = draw(SALT2.zonePhase2) * Math.PI * 2; + const radius = base * (1 + 0.3 * Math.sin(3 * theta + p1) + 0.18 * Math.sin(5 * theta + p2)); + if (dist >= radius) { + continue; + } + const depth = dist / radius; + if (!best || depth < best.depth) { + const palette = ZONE_PALETTES[Math.floor(draw(SALT2.zonePalette) * ZONE_PALETTES.length)] ?? WHITE; + best = { + depth, + zone: { tint: [...palette], variant: draw(SALT2.zoneVariant) < 0.5 } + }; + } + } + } + const zone = best?.zone ?? null; + this.zoneCache.set(key, zone); + return zone; + } + // --- Chunk streaming ------------------------------------------------------- + /** + * Ensures every chunk within the render distance is meshed and uploaded, + * dropping chunks that fell out of range. Returns the drawable set. + */ + updateChunks(px, py, renderDistance, renderer) { + const range = renderDistance + CHUNK_SIZE; + const minCx = Math.floor((px - range) / CHUNK_SIZE); + const maxCx = Math.floor((px + range) / CHUNK_SIZE); + const minCy = Math.floor((py - range) / CHUNK_SIZE); + const maxCy = Math.floor((py + range) / CHUNK_SIZE); + const wanted = /* @__PURE__ */ new Set(); + for (let gy = minCy; gy <= maxCy; gy++) { + for (let gx = minCx; gx <= maxCx; gx++) { + const centerX = (gx + 0.5) * CHUNK_SIZE; + const centerY = (gy + 0.5) * CHUNK_SIZE; + const dist = Math.hypot(centerX - px, centerY - py); + if (dist > renderDistance + CHUNK_SIZE) { + continue; + } + const key = `${gx},${gy}`; + wanted.add(key); + if (!this.chunks.has(key)) { + this.chunks.set(key, this.buildChunk(gx, gy, renderer)); + } + } + } + for (const [key, mesh] of this.chunks) { + if (!wanted.has(key)) { + renderer.disposeChunk(mesh); + this.chunks.delete(key); + } + } + return this.chunks.values(); + } + /** Drops all uploaded chunks (e.g. when toggling furniture or zones). */ + invalidateChunks(renderer) { + for (const mesh of this.chunks.values()) { + renderer.disposeChunk(mesh); + } + this.chunks.clear(); + this.zoneCache.clear(); + } + buildChunk(gx, gy, renderer) { + const builder = new MeshBuilder(); + for (let cy = gy * CHUNK_SIZE; cy < (gy + 1) * CHUNK_SIZE; cy++) { + for (let cx = gx * CHUNK_SIZE; cx < (gx + 1) * CHUNK_SIZE; cx++) { + this.emitCell(builder, this.generator.getCell(cx, cy)); + } + } + return renderer.uploadChunk(builder.vertices(), builder.indices()); + } + emitCell(b, cell) { + const { cx, cy } = cell; + const x0 = cell.bounds.min.x; + const y0 = cell.bounds.min.y; + const x1 = cell.bounds.max.x; + const y1 = cell.bounds.max.y; + const h = cell.dimensions.depth; + const zone = this.zoneAt(cx, cy); + const shadeAt = (x, y, base, span) => base + span * this.lightLevelAt(x, y); + const floorTint = zone ? [mix(1, zone.tint[0], 0.25), mix(1, zone.tint[1], 0.25), mix(1, zone.tint[2], 0.25)] : WHITE; + b.quad( + [x0, 0, y0], + [x0, 0, y1], + [x1, 0, y1], + [x1, 0, y0], + [x0, y0], + [x0, y1], + [x1, y1], + [x1, y0], + TILE.carpet, + floorTint, + [ + shadeAt(x0, y0, 0.5, 0.55), + shadeAt(x0, y1, 0.5, 0.55), + shadeAt(x1, y1, 0.5, 0.55), + shadeAt(x1, y0, 0.5, 0.55) + ] + ); + b.quad( + [x0, h, y0], + [x1, h, y0], + [x1, h, y1], + [x0, h, y1], + [x0 * 2, y0 * 2], + [x1 * 2, y0 * 2], + [x1 * 2, y1 * 2], + [x0 * 2, y1 * 2], + TILE.ceiling, + WHITE, + [ + shadeAt(x0, y0, 0.38, 0.4), + shadeAt(x1, y0, 0.38, 0.4), + shadeAt(x1, y1, 0.38, 0.4), + shadeAt(x0, y1, 0.38, 0.4) + ] + ); + const light = this.lightState(cx, cy); + if (light) { + const inset = 0.24; + const drop = 0.02; + const lx0 = x0 + inset; + const lx1 = x1 - inset; + const ly0 = y0 + inset; + const ly1 = y1 - inset; + const py = h - drop; + const shade = light === "dead" ? 0.32 : light === "flicker" ? EMISSIVE_SHADE + 0.35 : EMISSIVE_SHADE + 0.6; + b.quad( + [lx0, py, ly0], + [lx1, py, ly0], + [lx1, py, ly1], + [lx0, py, ly1], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + WHITE, + [shade, shade, shade, shade] + ); + } + for (const direction of DIRECTIONS) { + if (cell.edges[direction].solid) { + this.emitWallSlab(b, cell, direction, zone); + } else { + this.emitDoorHeader(b, cell, direction, zone); + } + } + if (cell.feature && this.furnitureEnabled) { + this.emitFeature(b, cell, cell.feature); + } + } + /** Resolves the tile, tint, and wear factor for one wall edge. */ + wallMaterial(edge, zone) { + let tile; + if (this.wallOverrideTile !== null) { + tile = this.wallOverrideTile; + } else if (this.uniformWallpaper) { + tile = TILE.wallpaperA; + } else { + tile = WALL_TILE_BY_SURFACE[edge.metadata.surfaceType]; + } + let tint = this.wallBaseTint; + if (zone && (this.wallOverrideTile !== null || tile === TILE.wallpaperA)) { + tint = [tint[0] * zone.tint[0], tint[1] * zone.tint[1], tint[2] * zone.tint[2]]; + if (zone.variant && tile === TILE.wallpaperA) { + tile = TILE.wallpaperB; + } + } + const wear = 0.92 + unitFromHash(edge.metadata.materialSeed) * 0.12; + return { tile, tint, wear }; + } + /** + * A solid edge as a slab: the inward face is inset by WALL_HALF_DEPTH (the + * neighbor emits the matching opposite face), and any end where the wall + * line stops at an open passage gets a jamb cap sealing the slab depth. + */ + emitWallSlab(b, cell, direction, zone) { + const h = cell.dimensions.depth; + this.emitWallFace(b, cell, direction, 0, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + if (this.generator.isPassable(cell.cx + ox, cell.cy + oy, direction)) { + this.emitJamb(b, end, { ox, oy }, inward, 0, h, mat); + } + } + } + /** + * A vertical cap strip sealing this cell's half of a wall slab at a wall + * end, facing out of the wall along `out`. + */ + emitJamb(b, at, out, inward, yBottom, yTop, mat) { + let ax = at.x; + let az = at.y; + let bx = at.x + inward.dx * WALL_HALF_DEPTH; + let bz = at.y + inward.dy * WALL_HALF_DEPTH; + if (-(bz - az) * out.ox + (bx - ax) * out.oy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const shade = (0.42 + 0.5 * this.lightLevelAt(at.x + out.ox * 0.2, at.y + out.oy * 0.2)) * mat.wear * 0.82; + const u0 = ax + az; + const u1 = bx + bz; + b.quad( + [ax, yBottom, az], + [bx, yBottom, bz], + [bx, yTop, bz], + [ax, yTop, az], + [u0, yBottom / 1.6], + [u1, yBottom / 1.6], + [u1, yTop / 1.6], + [u0, yTop / 1.6], + mat.tile, + mat.tint, + [shade, shade, shade * 0.92, shade * 0.92] + ); + } + /** + * Header over an open edge. An opening whose wall line is solid on both + * flanks reads as a doorway punched through a wall, so it gets a lintel: + * face down to DOOR_TOP_FRACTION of the lower ceiling, a soffit underside, + * and end caps. Interior edges of merged open areas (room/hall/atrium + * pairs) only get a soffit band where the neighbor's ceiling steps down. + */ + emitDoorHeader(b, cell, direction, zone) { + const h = cell.dimensions.depth; + const { dx, dy } = step(direction); + const ncx = cell.cx + dx; + const ncy = cell.cy + dy; + const nh = this.generator.getCell(ncx, ncy).dimensions.depth; + const gen = this.generator; + const interior = gen.isRoom(cell.cx, cell.cy) && gen.isRoom(ncx, ncy) || gen.isHall(cell.cx, cell.cy) && gen.isHall(ncx, ncy) || gen.isAtrium(cell.cx, cell.cy) && gen.isAtrium(ncx, ncy); + let bottom = null; + if (!interior) { + const edge2 = cell.edges[direction]; + const flanks = [edge2.start, edge2.end].map((end) => { + const other = end === edge2.start ? edge2.end : edge2.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + return !gen.isPassable(cell.cx + ox, cell.cy + oy, direction); + }); + if (flanks[0] && flanks[1]) { + bottom = Math.min(h, nh) * DOOR_TOP_FRACTION; + } + } + if (bottom === null && nh < h - 0.01) { + bottom = nh; + } + if (bottom === null || bottom >= h - 5e-3) { + return; + } + this.emitWallFace(b, cell, direction, bottom, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x; + let sz = edge.start.y; + let ex = edge.end.x; + let ez = edge.end.y; + if ((ez - sz) * inward.dx - (ex - sx) * inward.dy > 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const soffitShade = (0.36 + 0.4 * this.lightLevelAt((sx + ex) / 2, (sz + ez) / 2)) * mat.wear; + b.quad( + [sx, bottom, sz], + [ex, bottom, ez], + [ex + inward.dx * T, bottom, ez + inward.dy * T], + [sx + inward.dx * T, bottom, sz + inward.dy * T], + [sx + sz, 0], + [ex + ez, 0], + [ex + ez, T / 1.6], + [sx + sz, T / 1.6], + mat.tile, + mat.tint, + [soffitShade, soffitShade, soffitShade, soffitShade] + ); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + this.emitJamb(b, end, { ox, oy }, inward, bottom, h, mat); + } + } + /** + * The inward-facing wall face for an edge, inset WALL_HALF_DEPTH into the + * cell so the slab has visible extrusion depth at openings. + */ + emitWallFace(b, cell, direction, yBottom, yTop, zone) { + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x + inward.dx * T; + let sz = edge.start.y + inward.dy * T; + let ex = edge.end.x + inward.dx * T; + let ez = edge.end.y + inward.dy * T; + const normalX = -(ez - sz); + const normalZ = ex - sx; + if (normalX * inward.dx + normalZ * inward.dy < 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const { tile, tint, wear } = this.wallMaterial(edge, zone); + const u0 = sx + sz; + const u1 = ex + ez; + const v0 = yBottom / 1.6; + const v1 = yTop / 1.6; + const sample = (x, z) => (0.42 + 0.5 * this.lightLevelAt(x, z)) * wear; + const sS = sample(sx + inward.dx * 0.2, sz + inward.dy * 0.2); + const sE = sample(ex + inward.dx * 0.2, ez + inward.dy * 0.2); + b.quad( + [sx, yBottom, sz], + [ex, yBottom, ez], + [ex, yTop, ez], + [sx, yTop, sz], + [u0, v0], + [u1, v0], + [u1, v1], + [u0, v1], + tile, + tint, + [sS, sE, sE * 0.92, sS * 0.92] + ); + } + emitFeature(b, cell, feature) { + const fx = feature.position.x; + const fz = feature.position.y; + const h = cell.dimensions.depth; + const light = 0.4 + 0.5 * this.lightLevelAt(fx, fz); + if (feature.kind === "column") { + const r = Math.max(0.09, feature.size); + emitBox(b, fx - r, fx + r, 0, h, fz - r, fz + r, TILE.drywall, WHITE, light); + return; + } + const rng = mulberry32(feature.variantSeed); + const scale = clamp2(feature.size / 0.15, 0.75, 1.35); + const archetype = Math.floor(rng.next() * 4); + const s = (v) => v * scale; + switch (archetype) { + case 0: { + emitBox(b, fx - s(0.14), fx + s(0.14), 0, s(0.52), fz - s(0.12), fz + s(0.12), TILE.metal, WHITE, light); + break; + } + case 1: { + const w = s(0.32); + const d = s(0.2); + const top = s(0.3); + emitBox(b, fx - w, fx - w + s(0.04), 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx + w - s(0.04), fx + w, 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx - w, fx + w, top, top + s(0.04), fz - d, fz + d, TILE.wood, WHITE, light); + break; + } + case 2: { + const w = s(0.36); + const d = s(0.17); + emitBox(b, fx - w, fx + w, 0, s(0.18), fz - d, fz + d, TILE.fabric, WHITE, light); + emitBox(b, fx - w, fx + w, s(0.18), s(0.4), fz + d - s(0.07), fz + d, TILE.fabric, WHITE, light * 0.95); + break; + } + default: { + const r = s(0.17); + emitBox(b, fx - r, fx + r, 0, s(0.26), fz - r, fz + r, TILE.cardboard, WHITE, light); + const r2 = s(0.12); + const ox = (rng.next() - 0.5) * s(0.08); + const oz = (rng.next() - 0.5) * s(0.08); + emitBox(b, fx - r2 + ox, fx + r2 + ox, s(0.26), s(0.46), fz - r2 + oz, fz + r2 + oz, TILE.cardboard, WHITE, light * 1.05); + break; + } + } + } + }; + function emitBox(b, x0, x1, y0, y1, z0, z1, tile, tint, shade) { + const sides = shade * 0.85; + const uw = (x1 - x0) * 2; + const ud = (z1 - z0) * 2; + const vh = (y1 - y0) * 2; + b.quad( + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z0], + [x0, y0, z0], + [x0, y1, z0], + [x1, y1, z0], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z1], + [x1, y0, z0], + [x1, y1, z0], + [x1, y1, z1], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y0, z0], + [x0, y0, z1], + [x0, y1, z1], + [x0, y1, z0], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y1, z0], + [x0, y1, z1], + [x1, y1, z1], + [x1, y1, z0], + [0, 0], + [0, ud], + [uw, ud], + [uw, 0], + tile, + tint, + [shade, shade, shade, shade] + ); + } + function mix(a, b, t) { + return a + (b - a) * t; + } + function hueRotate(rgb, degrees) { + const rad = degrees * Math.PI / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + const [r, g, b] = rgb; + return [ + (0.213 + c * 0.787 - s * 0.213) * r + (0.715 - c * 0.715 - s * 0.715) * g + (0.072 - c * 0.072 + s * 0.928) * b, + (0.213 - c * 0.213 + s * 0.143) * r + (0.715 + c * 0.285 + s * 0.14) * g + (0.072 - c * 0.072 - s * 0.283) * b, + (0.213 - c * 0.213 - s * 0.787) * r + (0.715 - c * 0.715 + s * 0.715) * g + (0.072 + c * 0.928 + s * 0.072) * b + ]; + } + function clamp2(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var MeshBuilder = class { + verts = []; + idx = []; + count = 0; + quad(p0, p1, p2, p3, t0, t1, t2, t3, tile, tint, shades) { + const points = [p0, p1, p2, p3]; + const uvs = [t0, t1, t2, t3]; + for (let i = 0; i < 4; i++) { + const p = points[i]; + const t = uvs[i]; + this.verts.push(p[0], p[1], p[2], t[0], t[1], tile, tint[0], tint[1], tint[2], shades[i]); + } + const base = this.count; + this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3); + this.count += 4; + } + vertices() { + return new Float32Array(this.verts); + } + indices() { + if (this.count > 65535) { + throw new Error(`Chunk exceeds 16-bit index range: ${this.count} vertices`); + } + return new Uint16Array(this.idx); + } + }; + + // src/webview/graffiti.ts + var ATLAS_SIZE = 1024; + var SLOT_W = 512; + var SLOT_H = 256; + var SLOT_COLS = 2; + var STATIC_SLOTS = 7; + var LIVE_SLOT = 7; + var MAX_WRITE_DISTANCE = 6; + var RESTAMP_DISTANCE = 4; + var STAMP_COOLDOWN_MS = 2500; + var HISTORY_COOLDOWN_MS = 1200; + var REVEAL_CPS = 22; + var LIVE_REDRAW_MS = 90; + var GHOST_TAIL = 3; + var SEGMENT_CAP = 120; + var MIN_COMMIT_CHARS = 12; + var WALL_SWITCH_MS = 700; + var WRITE_Y0 = 0.55; + var WRITE_Y1 = 1.05; + var INK_FONT = 'Chiller, Creepster, "Segoe Script", "Comic Sans MS", cursive'; + var RAY_OFFSETS = [0, 0.5, -0.5, 1, -1, 1.6, -1.6, 2.4, -2.4, Math.PI]; + var WallWriting = class { + enabled = true; + job = { ...IDLE_JOB }; + session = null; + stamps = []; + nextSlot = 0; + lastWrittenStatus = ""; + lastStampX = Number.NaN; + lastStampY = Number.NaN; + lastStampAt = 0; + stampedHistory = /* @__PURE__ */ new Set(); + lastHistoryStampAt = 0; + // Live ghost writing state. The response is written in segments: the + // segment being revealed lives on LIVE_SLOT on the wall the player faces; + // finished segments are committed onto static slots and the text flows on. + live = null; + liveHit = null; + liveText = ""; + liveShown = 0; + liveConsumed = 0; + lastLiveTick = 0; + lastLiveDraw = 0; + lastLiveKey = ""; + lastWallSwitchAt = 0; + atlas; + ctx; + constructor() { + this.atlas = document.createElement("canvas"); + this.atlas.width = this.atlas.height = ATLAS_SIZE; + const ctx = this.atlas.getContext("2d"); + if (!ctx) { + throw new Error("2D context unavailable for wall writing"); + } + this.ctx = ctx; + } + setJob(job) { + this.job = job; + } + setSession(session) { + this.session = session; + } + /** Clears every writing, e.g. on relocate or when the setting turns off. */ + reset(renderer) { + this.job = { ...IDLE_JOB }; + this.session = null; + this.stamps = []; + this.nextSlot = 0; + this.lastWrittenStatus = ""; + this.lastStampX = Number.NaN; + this.lastStampY = Number.NaN; + this.stampedHistory.clear(); + this.live = null; + this.liveHit = null; + this.liveText = ""; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + renderer.clearDecalMesh(); + } + /** Called once per frame; adds or advances writings as needed. */ + update(now, world, px, py, yaw, renderer) { + if (!this.enabled) { + return; + } + let changed = this.updateJobStamp(now, world, px, py, yaw); + changed = this.updateHistoryStamps(now, world, px, py, yaw) || changed; + changed = this.updateLive(now, world, px, py, yaw) || changed; + if (changed) { + renderer.setDecalTexture(this.atlas); + this.uploadMesh(renderer); + } + } + // --- Job-status stamps (tool / command / status file route) --------------- + updateJobStamp(now, world, px, py, yaw) { + if (!this.job.working || this.job.status.length === 0) { + return false; + } + if (now - this.lastStampAt < STAMP_COOLDOWN_MS) { + return false; + } + const statusChanged = this.job.status !== this.lastWrittenStatus; + const moved = Number.isNaN(this.lastStampX) || Math.hypot(px - this.lastStampX, py - this.lastStampY) >= RESTAMP_DISTANCE; + if (!statusChanged && !moved) { + return false; + } + const hit = findWallAhead(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const existing = this.stamps.findIndex((s) => s.edgeKey === edgeKey2); + if (existing !== -1 && !statusChanged || this.live?.edgeKey === edgeKey2) { + return false; + } + const slot = existing !== -1 ? this.stamps[existing].slot : this.claimStaticSlot(); + if (existing !== -1) { + this.stamps.splice(existing, 1); + } + if (!this.stampStatic(world, hit, slot, edgeKey2, this.job.status)) { + return false; + } + this.lastWrittenStatus = this.job.status; + this.lastStampX = px; + this.lastStampY = py; + this.lastStampAt = now; + return true; + } + // --- Session history stamps ------------------------------------------------ + updateHistoryStamps(now, world, px, py, yaw) { + if (!this.session || now - this.lastHistoryStampAt < HISTORY_COOLDOWN_MS) { + return false; + } + if (this.stampedHistory.size > 200) { + this.stampedHistory.clear(); + } + for (const exchange of this.session.history) { + const text = exchange.response || exchange.prompt; + if (!text) { + continue; + } + const key = hashText(exchange.prompt + "\0" + exchange.response); + if (this.stampedHistory.has(key)) { + continue; + } + const hit = this.findFreeWall(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const slot = this.claimStaticSlot(); + if (this.stampStatic(world, hit, slot, edgeKey2, text)) { + this.stampedHistory.add(key); + this.lastHistoryStampAt = now; + return true; + } + } + return false; + } + // --- Live ghost writing ------------------------------------------------------ + updateLive(now, world, px, py, yaw) { + const target = this.session?.current ?? ""; + if (target.length === 0) { + return false; + } + let changed = false; + if (!target.startsWith(this.liveText)) { + changed = this.commitLive(world, Number.MAX_SAFE_INTEGER) || changed; + this.live = null; + this.liveHit = null; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + } + this.liveText = target; + let segment = target.slice(this.liveConsumed); + if (segment.length === 0 && !this.live) { + return changed; + } + const faced = findWallAhead(world, px, py, yaw); + const facedKey = faced ? `${faced.cx},${faced.cy},${faced.direction}` : null; + if (this.live && faced && facedKey !== this.live.edgeKey && this.isFreeWall(facedKey) && now - this.lastWallSwitchAt > WALL_SWITCH_MS) { + const shownInSegment2 = Math.max(0, Math.floor(this.liveShown) - this.liveConsumed); + if (shownInSegment2 >= MIN_COMMIT_CHARS) { + changed = this.commitLive(world, shownInSegment2) || changed; + } + changed = this.startLive(world, faced) || changed; + this.lastWallSwitchAt = now; + segment = target.slice(this.liveConsumed); + } + if (!this.live) { + const hit = faced && this.isFreeWall(facedKey) ? faced : this.findFreeWall(world, px, py, yaw); + if (!hit || !this.startLive(world, hit)) { + return changed; + } + changed = true; + this.lastWallSwitchAt = now; + } + const dt = Math.min(0.2, (now - this.lastLiveTick) / 1e3 || 0); + this.lastLiveTick = now; + this.liveShown = Math.min(target.length, this.liveShown + dt * REVEAL_CPS); + const shownInSegment = Math.min(segment.length, Math.max(0, Math.floor(this.liveShown) - this.liveConsumed)); + if (shownInSegment >= SEGMENT_CAP) { + changed = this.commitLive(world, shownInSegment) || changed; + return changed; + } + const done = this.session ? !this.session.working : true; + if (done && this.liveConsumed + shownInSegment >= target.length && this.live) { + changed = this.commitLive(world, segment.length) || changed; + return changed; + } + const key = `${shownInSegment}|${segment.length}|${this.live.edgeKey}`; + if (key === this.lastLiveKey || now - this.lastLiveDraw < LIVE_REDRAW_MS) { + return changed; + } + this.lastLiveKey = key; + this.lastLiveDraw = now; + const stillRevealing = this.liveConsumed + shownInSegment < target.length; + this.drawSlot( + LIVE_SLOT, + segment.slice(0, shownInSegment), + stillRevealing ? GHOST_TAIL : 0, + `${this.live.edgeKey}#${this.liveConsumed}` + ); + return true; + } + /** Places (or moves) the live quad onto the given wall, cleared. */ + startLive(world, hit) { + const vertices = buildWallQuad(world, hit, LIVE_SLOT); + if (!vertices) { + return false; + } + this.live = { edgeKey: `${hit.cx},${hit.cy},${hit.direction}`, slot: LIVE_SLOT, vertices }; + this.liveHit = hit; + this.lastLiveKey = ""; + this.drawSlot(LIVE_SLOT, "", 0); + return true; + } + /** + * Freezes up to `maxChars` of the live segment onto a static slot on the + * wall it was written on (cut at a word boundary), advances the consumed + * counter, and frees the live quad. Returns whether anything was drawn. + */ + commitLive(world, maxChars) { + const hit = this.liveHit; + const live = this.live; + this.live = null; + this.liveHit = null; + this.lastLiveKey = ""; + if (!hit || !live) { + return false; + } + const segment = this.liveText.slice(this.liveConsumed); + let cut = Math.min(segment.length, maxChars); + if (cut < segment.length) { + const space = segment.lastIndexOf(" ", cut); + if (space > cut * 0.4) { + cut = space + 1; + } + } + const committed = segment.slice(0, cut).trim(); + this.liveConsumed += cut; + this.liveShown = Math.max(this.liveShown, this.liveConsumed); + if (committed.length === 0) { + return false; + } + const slot = this.claimStaticSlot(); + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, committed, 0); + this.stamps.push({ edgeKey: live.edgeKey, slot, vertices }); + return true; + } + isFreeWall(edgeKey2) { + return this.live?.edgeKey !== edgeKey2 && !this.stamps.some((s) => s.edgeKey === edgeKey2); + } + // --- Shared helpers ---------------------------------------------------------- + /** Next rotating static slot, evicting whatever writing used it before. */ + claimStaticSlot() { + const slot = this.nextSlot % STATIC_SLOTS; + this.nextSlot++; + this.stamps = this.stamps.filter((s) => s.slot !== slot); + return slot; + } + stampStatic(world, hit, slot, edgeKey2, text) { + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, text, 0); + this.stamps.push({ edgeKey: edgeKey2, slot, vertices }); + return true; + } + /** First unclaimed wall found by fanning rays around the player's gaze. */ + findFreeWall(world, px, py, yaw) { + for (const offset of RAY_OFFSETS) { + const hit = findWallAhead(world, px, py, yaw + offset); + if (hit && this.isFreeWall(`${hit.cx},${hit.cy},${hit.direction}`)) { + return hit; + } + } + return null; + } + uploadMesh(renderer) { + const vertices = []; + const indices = []; + const all = this.live ? [...this.stamps, this.live] : this.stamps; + for (const stamp of all) { + const base = vertices.length / 6; + vertices.push(...stamp.vertices); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + renderer.setDecalMesh(new Float32Array(vertices), new Uint16Array(indices)); + } + /** + * Draws one writing into an atlas slot with the marker treatment: glyphs + * jittered like handwriting, outline copies extruded ~3px toward the wall, + * black fill under a very subtle drop shadow, splatter, and finally a + * low-opacity gradient overlay (angle and stops seeded per text) that + * shifts across the strokes like uneven marker ink. + * + * `ghostTail` renders the last N characters progressively fainter, for the + * live response materializing onto the wall. + */ + drawSlot(slot, text, ghostTail, seedText) { + const ctx = this.ctx; + const ox = slot % SLOT_COLS * SLOT_W; + const oy = Math.floor(slot / SLOT_COLS) * SLOT_H; + ctx.save(); + ctx.clearRect(ox, oy, SLOT_W, SLOT_H); + if (text.trim().length === 0) { + ctx.restore(); + return; + } + ctx.beginPath(); + ctx.rect(ox, oy, SLOT_W, SLOT_H); + ctx.clip(); + const rng = mulberry(hashCode(seedText ?? text) || 1); + let size = 80; + let lines = [text.trim()]; + for (; size > 26; size -= 4) { + ctx.font = `bold ${size}px ${INK_FONT}`; + lines = wrapToLines(ctx, text, SLOT_W - 70); + if (lines.length * size * 1.12 <= SLOT_H - 36) { + break; + } + } + const maxLines = Math.max(1, Math.floor((SLOT_H - 36) / (size * 1.12))); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + lines[maxLines - 1] += "\u2026"; + } + const ink = (alpha) => `rgba(16, 14, 13, ${alpha.toFixed(3)})`; + const extrude = (alpha) => `rgba(34, 30, 27, ${alpha.toFixed(3)})`; + ctx.textBaseline = "alphabetic"; + const lineGap = size * 1.12; + const blockH = lineGap * (lines.length - 1); + const baseY = oy + SLOT_H / 2 - blockH / 2 + size * 0.34; + const totalChars = lines.reduce((n, line) => n + line.length, 0); + let drawnChars = 0; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + ctx.font = `bold ${size}px ${INK_FONT}`; + const lineW = ctx.measureText(line).width; + let x = ox + (SLOT_W - lineW) / 2 + (rng() - 0.5) * 16; + const y = baseY + li * lineGap + (rng() - 0.5) * 8; + for (const char of line) { + const w = ctx.measureText(char).width; + const fromEnd = totalChars - drawnChars; + const ghost = ghostTail > 0 && fromEnd <= ghostTail ? fromEnd / (ghostTail + 1) : 0; + const alpha = (0.82 + rng() * 0.14) * (1 - ghost * 0.75); + ctx.save(); + ctx.translate(x + w / 2, y + (rng() - 0.5) * size * 0.09); + ctx.rotate((rng() - 0.5) * 0.14); + ctx.shadowColor = "transparent"; + for (const depth of [3, 2, 1]) { + ctx.fillStyle = extrude(0.2 * (1 - ghost)); + ctx.fillText(char, -w / 2 + depth, depth * 0.8); + } + ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1.5; + ctx.fillStyle = ink(alpha); + ctx.fillText(char, -w / 2, 0); + ctx.restore(); + x += w * (0.94 + rng() * 0.06); + drawnChars++; + if (rng() < 0.07 && ghost === 0) { + const dripLen = 16 + rng() * 56; + const dripX = x - w / 2 + (rng() - 0.5) * 6; + const grad2 = ctx.createLinearGradient(0, y, 0, y + dripLen); + grad2.addColorStop(0, ink(0.55)); + grad2.addColorStop(1, ink(0)); + ctx.fillStyle = grad2; + ctx.fillRect(dripX, y - 2, 1.4 + rng() * 1.6, dripLen); + } + } + } + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + const splats = 5 + Math.floor(rng() * 6); + for (let i = 0; i < splats; i++) { + const sx = ox + 30 + rng() * (SLOT_W - 60); + const sy = oy + 30 + rng() * (SLOT_H - 60); + ctx.fillStyle = ink(0.2 + rng() * 0.4); + ctx.beginPath(); + ctx.arc(sx, sy, 0.8 + rng() * rng() * 5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = "source-atop"; + const theta = rng() * Math.PI * 2; + const r = Math.max(SLOT_W, SLOT_H) / 2; + const cxm = ox + SLOT_W / 2; + const cym = oy + SLOT_H / 2; + const grad = ctx.createLinearGradient( + cxm - Math.cos(theta) * r, + cym - Math.sin(theta) * r, + cxm + Math.cos(theta) * r, + cym + Math.sin(theta) * r + ); + let pos = 0; + let bright = rng() < 0.5; + while (pos < 1) { + const alpha = 0.04 + rng() * 0.08; + grad.addColorStop(pos, bright ? `rgba(255, 255, 255, ${alpha.toFixed(3)})` : `rgba(30, 30, 30, ${alpha.toFixed(3)})`); + pos += 0.12 + rng() * 0.2; + bright = !bright; + } + ctx.fillStyle = grad; + ctx.fillRect(ox, oy, SLOT_W, SLOT_H); + ctx.globalCompositeOperation = "source-over"; + ctx.restore(); + } + }; + function wrapToLines(ctx, text, maxWidth) { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + return [""]; + } + const lines = []; + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + lines.push(line); + return lines; + } + function hashCode(text) { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + function hashText(text) { + return hashCode(text).toString(36); + } + function mulberry(seed) { + let state = seed >>> 0; + return () => { + state = state + 1831565813 >>> 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function findWallAhead(world, px, py, yaw) { + const fx = -Math.sin(yaw); + const fy = -Math.cos(yaw); + let cx = Math.floor(px); + let cy = Math.floor(py); + const stepX = fx > 0 ? 1 : -1; + const stepY = fy > 0 ? 1 : -1; + const tDeltaX = fx !== 0 ? 1 / Math.abs(fx) : Infinity; + const tDeltaY = fy !== 0 ? 1 / Math.abs(fy) : Infinity; + let tMaxX = fx !== 0 ? (fx > 0 ? cx + 1 - px : px - cx) / Math.abs(fx) : Infinity; + let tMaxY = fy !== 0 ? (fy > 0 ? cy + 1 - py : py - cy) / Math.abs(fy) : Infinity; + for (let i = 0; i < MAX_WRITE_DISTANCE * 2; i++) { + let direction; + let t; + if (tMaxX < tMaxY) { + direction = stepX > 0 ? "east" : "west"; + t = tMaxX; + tMaxX += tDeltaX; + } else { + direction = stepY > 0 ? "south" : "north"; + t = tMaxY; + tMaxY += tDeltaY; + } + if (t > MAX_WRITE_DISTANCE) { + return null; + } + if (!world.generator.isPassable(cx, cy, direction)) { + return { cx, cy, direction, hitX: px + fx * t, hitY: py + fy * t }; + } + const d = step(direction); + cx += d.dx; + cy += d.dy; + } + return null; + } + function buildWallQuad(world, hit, slot) { + const cell = world.generator.getCell(hit.cx, hit.cy); + const edge = cell.edges[hit.direction]; + const inward = step(opposite(hit.direction)); + const off = WALL_HALF_DEPTH + 8e-3; + let ax = edge.start.x + inward.dx * off; + let az = edge.start.y + inward.dy * off; + let bx = edge.end.x + inward.dx * off; + let bz = edge.end.y + inward.dy * off; + if (-(bz - az) * inward.dx + (bx - ax) * inward.dy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const len = Math.hypot(bx - ax, bz - az); + const width = Math.min(1.5, len - 0.24); + if (width < 0.6) { + return null; + } + const ux = (bx - ax) / len; + const uz = (bz - az) / len; + const hitU = (hit.hitX - ax) * ux + (hit.hitY - az) * uz; + const center2 = Math.min(len - 0.12 - width / 2, Math.max(0.12 + width / 2, hitU)); + const x0 = ax + ux * (center2 - width / 2); + const z0 = az + uz * (center2 - width / 2); + const x1 = ax + ux * (center2 + width / 2); + const z1 = az + uz * (center2 + width / 2); + const shade = 0.42 + 0.5 * world.lightAt((x0 + x1) / 2 + inward.dx * 0.2, (z0 + z1) / 2 + inward.dy * 0.2); + const u0 = slot % SLOT_COLS * SLOT_W / ATLAS_SIZE; + const v0 = Math.floor(slot / SLOT_COLS) * SLOT_H / ATLAS_SIZE; + const u1 = u0 + SLOT_W / ATLAS_SIZE; + const v1 = v0 + SLOT_H / ATLAS_SIZE; + return [ + x0, + WRITE_Y0, + z0, + u0, + v1, + shade, + x1, + WRITE_Y0, + z1, + u1, + v1, + shade, + x1, + WRITE_Y1, + z1, + u1, + v0, + shade, + x0, + WRITE_Y1, + z0, + u0, + v0, + shade + ]; + } + + // src/webview/input.ts + var Input = class { + constructor(surface) { + this.surface = surface; + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.onMenuToggle?.(); + return; + } + const target = e.target; + if (target instanceof HTMLElement && (target.matches("input, select, textarea, button") || target.isContentEditable)) { + return; + } + if (e.key.toLowerCase() === "m") { + this.onMenuToggle?.(); + return; + } + this.held.add(normalize(e.key)); + if (isGameKey(e.key)) { + e.preventDefault(); + } + }); + window.addEventListener("keyup", (e) => this.held.delete(normalize(e.key))); + window.addEventListener("blur", () => this.held.clear()); + surface.addEventListener("click", () => { + if (this.mouseLookEnabled && document.pointerLockElement !== surface) { + surface.requestPointerLock(); + } + }); + document.addEventListener("mousemove", (e) => { + if (document.pointerLockElement === this.surface) { + this.lookDx += e.movementX; + this.lookDy += e.movementY; + } + }); + } + held = /* @__PURE__ */ new Set(); + lookDx = 0; + mouseLookEnabled = true; + onMenuToggle = null; + lookDy = 0; + releasePointer() { + if (document.pointerLockElement === this.surface) { + document.exitPointerLock(); + } + } + /** Accumulated mouse-look delta since the last call, in pixels. */ + consumeLook() { + const out = { dx: this.lookDx, dy: this.lookDy }; + this.lookDx = 0; + this.lookDy = 0; + return out; + } + state() { + const has = (...keys) => keys.some((k) => this.held.has(k)); + return { + forward: (has("w", "arrowup") ? 1 : 0) - (has("s", "arrowdown") ? 1 : 0), + strafe: (has("d") ? 1 : 0) - (has("a") ? 1 : 0), + turn: (has("arrowright", "e") ? 1 : 0) - (has("arrowleft", "q") ? 1 : 0), + running: has("shift") + }; + } + }; + function normalize(key) { + return key.toLowerCase(); + } + function isGameKey(key) { + return ["w", "a", "s", "d", "q", "e", "shift"].includes(key.toLowerCase()) || key.startsWith("Arrow"); + } + + // src/webview/monster.ts + var BODY_FORMS = ["spider", "humanoid", "cloud"]; + var CATCH_DISTANCE = 0.45; + var REPATH_MS = 600; + var PATH_NODE_CAP = 900; + var DARK = [1, 1, 1]; + var BODY_SHADE = 0.16; + var EYE_TINT = [1, 0.16, 0.1]; + var Monster = class { + constructor(world, config, now) { + this.world = world; + this.config = config; + this.arm(now); + } + form = "spider"; + x = 0; + y = 0; + config; + stalking = false; + spawnAt = 0; + path = []; + lastPathAt = 0; + phase = 0; + heading = 0; + get isStalking() { + return this.stalking; + } + /** Applies new tuning live; the spawn window only affects future arms. */ + configure(config) { + const formChanged = config.form !== this.config.form; + this.config = config; + if (formChanged && config.form !== "random") { + this.form = config.form; + } + } + /** Returns to dormant and schedules the next appearance. */ + arm(now) { + this.stalking = false; + this.path = []; + const min = Math.min(this.config.spawnMinMs, this.config.spawnMaxMs); + const max = Math.max(this.config.spawnMinMs, this.config.spawnMaxMs); + this.spawnAt = now + min + Math.random() * (max - min); + } + update(now, dt, px, py) { + if (!this.stalking) { + if (now >= this.spawnAt) { + this.spawn(px, py); + return "spawned"; + } + return null; + } + const distToPlayer = Math.hypot(px - this.x, py - this.y); + if (distToPlayer < CATCH_DISTANCE) { + this.arm(now); + return "caught"; + } + if (now - this.lastPathAt > REPATH_MS) { + this.lastPathAt = now; + this.path = this.findPath(Math.floor(this.x), Math.floor(this.y), Math.floor(px), Math.floor(py)); + } + let target = this.path[0] ? center(this.path[0]) : this.greedyStep(px, py); + if (this.path.length <= 1 && distToPlayer < 1.4) { + target = { x: px, y: py }; + } + const dx = target.x - this.x; + const dy = target.y - this.y; + const dist = Math.hypot(dx, dy); + const travel = this.config.speed * dt; + if (dist > 1e-4) { + const t = Math.min(1, travel / dist); + this.x += dx * t; + this.y += dy * t; + this.heading = Math.atan2(dy, dx); + this.phase += travel * 4.4; + } + if (this.path[0] && Math.hypot(center(this.path[0]).x - this.x, center(this.path[0]).y - this.y) < 0.08) { + this.path.shift(); + } + return null; + } + spawn(px, py) { + if (this.config.form === "random") { + this.form = BODY_FORMS[Math.floor(Math.random() * BODY_FORMS.length)]; + } else { + this.form = this.config.form; + } + const angle = Math.random() * Math.PI * 2; + const dist = 9 + Math.random() * 5; + this.x = Math.floor(px + Math.cos(angle) * dist) + 0.5; + this.y = Math.floor(py + Math.sin(angle) * dist) + 0.5; + this.path = []; + this.lastPathAt = 0; + this.stalking = true; + } + /** Breadth-first search through open edges, capped for the infinite grid. */ + findPath(fromCx, fromCy, toCx, toCy) { + if (fromCx === toCx && fromCy === toCy) { + return []; + } + const key = (cx, cy) => `${cx},${cy}`; + const parents = /* @__PURE__ */ new Map(); + parents.set(key(fromCx, fromCy), null); + const queue = [{ cx: fromCx, cy: fromCy }]; + let found = false; + while (queue.length > 0 && parents.size < PATH_NODE_CAP) { + const node = queue.shift(); + if (node.cx === toCx && node.cy === toCy) { + found = true; + break; + } + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(node.cx, node.cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const next = { cx: node.cx + dx, cy: node.cy + dy }; + const nextKey = key(next.cx, next.cy); + if (!parents.has(nextKey)) { + parents.set(nextKey, key(node.cx, node.cy)); + queue.push(next); + } + } + } + if (!found) { + return []; + } + const path = []; + let cursor = key(toCx, toCy); + while (cursor && cursor !== key(fromCx, fromCy)) { + const [cx, cy] = cursor.split(",").map(Number); + path.unshift({ cx, cy }); + cursor = parents.get(cursor) ?? null; + } + return path; + } + /** No path known: shuffle toward the player through any open edge. */ + greedyStep(px, py) { + const cx = Math.floor(this.x); + const cy = Math.floor(this.y); + let best = { x: this.x, y: this.y }; + let bestDist = Number.POSITIVE_INFINITY; + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(cx, cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const candidate = center({ cx: cx + dx, cy: cy + dy }); + const dist = Math.hypot(px - candidate.x, py - candidate.y); + if (dist < bestDist) { + bestDist = dist; + best = candidate; + } + } + return best; + } + /** Emits this frame's world-space geometry. */ + buildMesh(now, px, py) { + const b = new MeshBuilder(); + switch (this.form) { + case "spider": + this.buildSpider(b); + break; + case "humanoid": + this.buildHumanoid(b); + break; + case "cloud": + this.buildCloud(b, now); + break; + } + this.buildEyes(b, px, py); + return { vertices: b.vertices(), indices: b.indices() }; + } + buildSpider(b) { + const { x, y } = this; + emitBox(b, x - 0.19, x + 0.19, 0.2, 0.42, y - 0.23, y + 0.23, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.09, x + 0.09, 0.26, 0.4, y - 0.34, y - 0.2, TILE.fabric, DARK, BODY_SHADE * 1.2); + for (let i = 0; i < 8; i++) { + const side = i < 4 ? -1 : 1; + const spread = (i % 4 - 1.5) * 0.5 + this.heading; + const lift = Math.max(0, Math.sin(this.phase + i * (Math.PI / 2))) * 0.1; + const hip = [x + side * 0.17, 0.34, y + Math.sin(spread) * 0.15]; + const foot = [ + x + side * (0.5 + 0.1 * Math.sin(i * 2.1)), + lift, + y + Math.sin(spread) * 0.42 + Math.cos(spread) * side * 0.12 + ]; + this.limb(b, hip, foot, 0.045); + } + } + buildHumanoid(b) { + const { x, y } = this; + const sway = Math.sin(this.phase * 0.5) * 0.03; + emitBox(b, x - 0.11 + sway, x + 0.11 + sway, 0.52, 1.06, y - 0.07, y + 0.07, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.06 + sway, x + 0.06 + sway, 1.06, 1.2, y - 0.06, y + 0.06, TILE.fabric, DARK, BODY_SHADE * 1.15); + const strideX = Math.cos(this.heading) * 0.16; + const strideY = Math.sin(this.heading) * 0.16; + const gait = Math.sin(this.phase); + this.limb(b, [x - 0.06, 0.55, y], [x - 0.06 + strideX * gait, 0, y + strideY * gait], 0.05); + this.limb(b, [x + 0.06, 0.55, y], [x + 0.06 - strideX * gait, 0, y - strideY * gait], 0.05); + this.limb(b, [x - 0.13 + sway, 1, y], [x - 0.13 + sway - strideX * gait * 0.5, 0.5, y - strideY * gait * 0.5], 0.04); + this.limb(b, [x + 0.13 + sway, 1, y], [x + 0.13 + sway + strideX * gait * 0.5, 0.5, y + strideY * gait * 0.5], 0.04); + } + buildCloud(b, now) { + const { x, y } = this; + const t = now / 1e3; + for (let i = 0; i < 10; i++) { + const jx = Math.sin(t * 1.3 + i * 2.4) * 0.14; + const jy = Math.sin(t * 1.7 + i * 1.9) * 0.1; + const jz = Math.cos(t * 1.1 + i * 3.2) * 0.14; + const size = 0.12 + i * 37 % 10 * 0.02; + const cx = x + Math.sin(i * 2.4) * 0.2 + jx; + const cy = 0.45 + Math.sin(i * 1.6) * 0.3 + jy; + const cz = y + Math.cos(i * 2.9) * 0.2 + jz; + emitBox(b, cx - size, cx + size, cy - size, cy + size, cz - size, cz + size, TILE.concrete, DARK, BODY_SHADE * (0.8 + i % 3 * 0.2)); + } + } + /** Two small emissive eyes billboarded toward the player. */ + buildEyes(b, px, py) { + const eyeHeight = this.form === "spider" ? 0.36 : this.form === "humanoid" ? 1.13 : 0.7; + const toPlayerX = px - this.x; + const toPlayerY = py - this.y; + const len = Math.hypot(toPlayerX, toPlayerY) || 1; + const fx = toPlayerX / len; + const fy = toPlayerY / len; + const rx = -fy; + const ry = fx; + const ex = this.x + fx * 0.2; + const ey = this.y + fy * 0.2; + const r = 0.03; + for (const side of [-1, 1]) { + const cx = ex + rx * side * 0.06; + const cy = ey + ry * side * 0.06; + b.quad( + [cx - rx * r, eyeHeight - r, cy - ry * r], + [cx + rx * r, eyeHeight - r, cy + ry * r], + [cx + rx * r, eyeHeight + r, cy + ry * r], + [cx - rx * r, eyeHeight + r, cy - ry * r], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + EYE_TINT, + [EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8] + ); + } + } + /** A thin double-sided crossed-quad limb between two points. */ + limb(b, from, to, width) { + const shades = [BODY_SHADE, BODY_SHADE, BODY_SHADE, BODY_SHADE]; + const uv = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ]; + const planes = [ + [width, 0, 0], + [0, 0, width] + ]; + for (const offset of planes) { + const a0 = [from[0] - offset[0], from[1] - offset[1], from[2] - offset[2]]; + const a1 = [from[0] + offset[0], from[1] + offset[1], from[2] + offset[2]]; + const b1 = [to[0] + offset[0], to[1] + offset[1], to[2] + offset[2]]; + const b0 = [to[0] - offset[0], to[1] - offset[1], to[2] - offset[2]]; + b.quad(a0, a1, b1, b0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + b.quad(b0, b1, a1, a0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + } + } + }; + function center(cell) { + return { x: cell.cx + 0.5, y: cell.cy + 0.5 }; + } + + // src/webview/menu.ts + var STYLE = ` +.bv-menu { + position: absolute; inset: 0; display: none; z-index: 30; + align-items: center; justify-content: center; + background: rgba(16, 17, 19, 0.72); + font-family: "Segoe UI", system-ui, sans-serif; + color: #f0f2f3; +} +.bv-menu.open { display: flex; } +.bv-card { + background: linear-gradient(#22252a, #1c1f23); + border: 1px solid #3a3f46; border-radius: 10px; + min-width: 300px; max-width: 380px; max-height: 82%; overflow-y: auto; + padding: 22px 26px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); +} +.bv-card h1 { margin: 0 0 2px; font-size: 20px; letter-spacing: 3px; font-weight: 600; } +.bv-card .bv-sub { margin: 0 0 18px; font-size: 12px; color: #9aa3ad; } +.bv-menu button { + display: block; width: 100%; margin: 8px 0; padding: 10px 14px; + background: #2b3036; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 6px; font-size: 14px; cursor: pointer; text-align: left; +} +.bv-menu button:hover { background: #343a42; } +.bv-menu button.bv-accent { background: #6b4b12; border-color: #93691c; } +.bv-menu button.bv-accent:hover { background: #7d5915; } +.bv-row { display: flex; align-items: center; justify-content: space-between; margin: 10px 0; font-size: 13px; gap: 12px; } +.bv-row label { flex: 1; color: #cfd6dc; } +.bv-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #93691c; } +.bv-row input[type="range"] { width: 130px; accent-color: #93691c; } +.bv-row input[type="number"] { + width: 110px; background: #15171a; color: #f0f2f3; + border: 1px solid #454c55; border-radius: 4px; padding: 5px 7px; font-size: 13px; +} +.bv-row .bv-val { width: 34px; text-align: right; color: #9aa3ad; font-variant-numeric: tabular-nums; } +.bv-row select { + background: #15171a; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 4px; padding: 5px 7px; font-size: 13px; min-width: 150px; +} +.bv-h { + margin: 16px 0 4px; font-size: 11px; letter-spacing: 2px; + color: #8a93a0; text-transform: uppercase; +} +.bv-h:first-of-type { margin-top: 8px; } +.bv-stats { margin-top: 14px; padding-top: 12px; border-top: 1px solid #33383f; font-size: 12px; color: #9aa3ad; line-height: 1.7; } +.bv-help { font-size: 13px; color: #cfd6dc; line-height: 1.9; } +.bv-help kbd { + background: #15171a; border: 1px solid #454c55; border-radius: 4px; + padding: 1px 6px; font-family: inherit; font-size: 12px; +} +.bv-back { margin-top: 16px !important; } +`; + var Menu = class { + constructor(parent, callbacks) { + this.callbacks = callbacks; + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + this.root = document.createElement("div"); + this.root.className = "bv-menu"; + parent.appendChild(this.root); + this.views = { + main: this.buildMain(), + settings: this.buildSettings(), + help: this.buildHelp() + }; + for (const view of Object.values(this.views)) { + this.root.appendChild(view); + } + this.show("main"); + } + root; + views; + statsEl = null; + settings = null; + openFlag = false; + lastFocused = null; + get isOpen() { + return this.openFlag; + } + open() { + this.openFlag = true; + this.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + this.root.classList.add("open"); + this.show("main"); + } + close() { + this.openFlag = false; + this.root.classList.remove("open"); + if (this.lastFocused?.isConnected) { + this.lastFocused.focus(); + } + this.lastFocused = null; + } + syncSettings(settings) { + this.settings = settings; + for (const input of this.root.querySelectorAll("[data-key]")) { + const key = input.dataset.key; + const value = settings[key]; + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else { + input.value = String(value); + const label = input.parentElement?.querySelector(".bv-val"); + if (label) { + label.textContent = String(value); + } + } + } + } + syncStats(stats) { + if (this.statsEl) { + this.statsEl.innerHTML = `seed ${stats.seed}
cells visited ${stats.cellsVisited}
cells cached ${stats.cacheSize}`; + } + } + show(name) { + for (const [key, view] of Object.entries(this.views)) { + view.style.display = key === name ? "block" : "none"; + } + // Only the visible card is a live dialog; hand it focus so assistive + // tech announces the context change instead of staying on the game. + if (this.openFlag) { + const view = this.views[name]; + const target = view.querySelector("button, input, select") ?? view; + target.focus(); + } + } + card() { + const card = document.createElement("div"); + card.className = "bv-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-label", "BackRooms pause menu"); + card.tabIndex = -1; + card.innerHTML = '

BACKROOMS

noclipped into a Copilot canvas

'; + return card; + } + button(label, onClick, accent = false) { + const button = document.createElement("button"); + button.textContent = label; + if (accent) { + button.className = "bv-accent"; + } + button.addEventListener("click", onClick); + return button; + } + buildMain() { + const card = this.card(); + card.appendChild(this.button("Resume", () => this.callbacks.onResume())); + card.appendChild(this.button("Relocate (new seed)", () => this.callbacks.onRelocate(), true)); + card.appendChild(this.button("Settings", () => this.show("settings"))); + card.appendChild(this.button("Help", () => this.show("help"))); + this.statsEl = document.createElement("div"); + this.statsEl.className = "bv-stats"; + card.appendChild(this.statsEl); + return card; + } + toggleRow(label, key) { + const row = document.createElement("div"); + row.className = "bv-row"; +const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("change", () => this.callbacks.onSettingChange(key, input.checked)); + const text = document.createElement("label"); + text.textContent = label; + row.append(text, input); + return row; + } + sliderRow(label, key, min, max, stepSize) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; + const value = document.createElement("span"); + value.className = "bv-val"; +const input = document.createElement("input"); + input.type = "range"; + input.min = String(min); + input.max = String(max); + input.step = String(stepSize); + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("input", () => { + value.textContent = input.value; + this.callbacks.onSettingChange(key, Number(input.value)); + }); + row.append(text, input, value); + return row; + } + heading(text) { + const h = document.createElement("div"); + h.className = "bv-h"; + h.textContent = text; + return h; + } + selectRow(label, key, options) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; +const select = document.createElement("select"); + select.dataset.key = key; + select.setAttribute("aria-label", label); + for (const option of options) { + const el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + } + select.addEventListener("change", () => this.callbacks.onSettingChange(key, select.value)); + row.append(text, select); + return row; + } + buildSettings() { + const card = this.card(); + card.appendChild(this.heading("Camera")); + card.appendChild(this.toggleRow("Camera shake", "cameraShake")); + card.appendChild(this.toggleRow("Film grain", "filmGrain")); + card.appendChild(this.toggleRow("Camcorder HUD", "vhsHud")); + card.appendChild(this.heading("Controls")); + card.appendChild(this.toggleRow("Mouse look", "mouseLook")); + card.appendChild(this.toggleRow("Invert turn", "invertTurn")); + card.appendChild(this.toggleRow("Invert strafe", "invertStrafe")); + card.appendChild(this.toggleRow("Invert forward/back", "invertForward")); + card.appendChild(this.sliderRow("Walk speed", "moveSpeed", 0.5, 6, 0.1)); + card.appendChild(this.heading("Materials")); + card.appendChild( + this.selectRow("Wall material", "materialPreset", [ + { value: "classic", label: "Classic wallpaper mix" }, + { value: "office", label: "Plain drywall" }, + { value: "pool", label: "Ceramic tile" }, + { value: "concrete", label: "Bare concrete" }, + { value: "panel", label: "Wood paneling" } + ]) + ); + card.appendChild(this.sliderRow("Hue shift", "materialHueShift", -180, 180, 5)); + card.appendChild(this.sliderRow("Brightness", "materialBrightness", 0.6, 1.4, 0.05)); + card.appendChild(this.heading("Monster")); + card.appendChild(this.toggleRow("Monster", "monsterEnabled")); + card.appendChild( + this.selectRow("Form", "monsterForm", [ + { value: "spider", label: "Spider-like" }, + { value: "humanoid", label: "Human-like" }, + { value: "cloud", label: "Cloud-like" }, + { value: "random", label: "Random each spawn" } + ]) + ); + card.appendChild(this.sliderRow("Speed", "monsterSpeed", 0.5, 5, 0.1)); + card.appendChild(this.sliderRow("Spawn after (min)", "monsterSpawnMin", 0.1, 10, 0.1)); + card.appendChild(this.sliderRow("Spawn before (min)", "monsterSpawnMax", 0.5, 15, 0.5)); + card.appendChild(this.heading("World")); + card.appendChild(this.toggleRow("Furniture", "furniture")); + card.appendChild(this.toggleRow("Wallpaper shifts", "wallpaperShifts")); + card.appendChild(this.sliderRow("Render distance", "renderDistance", 6, 28, 1)); + card.appendChild(this.heading("Copilot")); + card.appendChild(this.toggleRow("Ghost-writer on the walls", "copilotGhostWriter")); + const seedRow = document.createElement("div"); + seedRow.className = "bv-row"; + const seedLabel = document.createElement("label"); + seedLabel.textContent = "Seed (0 = random)"; +const seedInput = document.createElement("input"); + seedInput.type = "number"; + seedInput.dataset.key = "seed"; + seedInput.setAttribute("aria-label", "Seed (0 = random)"); + seedInput.addEventListener("change", () => { + const seed = Math.trunc(Number(seedInput.value)) || 0; + this.callbacks.onSettingChange("seed", seed); + this.callbacks.onReseed(seed); + }); + seedRow.append(seedLabel, seedInput); + card.appendChild(seedRow); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + buildHelp() { + const card = this.card(); + const help = document.createElement("div"); + help.className = "bv-help"; + help.innerHTML = "W/S or / walk
A/D strafe
/ or Q/E turn
Shift hurry
click the view for mouse look
M or Esc open this menu

The maze is infinite and deterministic: the same seed always rebuilds the same rooms."; + card.appendChild(help); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + }; + + // src/webview/main.ts + var EYE_HEIGHT = 0.78; + var vscode = acquireVsCodeApi(); + var Game = class { + settings = { ...DEFAULT_SETTINGS }; + renderer; + film; + graffiti = new WallWriting(); + input; + menu; + toast; + world; + monster = null; + uniformWallpaper = false; + // Player, in plane coordinates. + px = 0.5; + py = 0.5; + yaw = 0; + pitch = 0; + bobPhase = 0; + lastFrame = 0; + lastPersist = 0; + flickerDipUntil = 0; + toastTimer; + constructor(root) { + const glCanvas = document.createElement("canvas"); + const filmCanvas = document.createElement("canvas"); + for (const [canvas, z] of [[glCanvas, "1"], [filmCanvas, "2"]]) { + canvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;z-index:${z};`; + } + filmCanvas.style.pointerEvents = "none"; + root.append(glCanvas, filmCanvas); + this.renderer = new Renderer(glCanvas); + this.film = new FilmOverlay(filmCanvas); + this.input = new Input(glCanvas); + this.input.onMenuToggle = () => this.toggleMenu(); + this.menu = new Menu(root, { + onResume: () => this.toggleMenu(), + onRelocate: () => this.relocate(), + onSettingChange: (key, value) => this.changeSetting(key, value), + // A nonzero seed already rebuilds through applySettings; 0 means "roll one now". + onReseed: (seed2) => { + if (seed2 === 0) { + this.rebuildWorld(randomSeed()); + } + } + }); + this.toast = document.createElement("div"); + this.toast.setAttribute("role", "status"); + this.toast.style.cssText = 'position:absolute;left:50%;bottom:9%;transform:translateX(-50%);z-index:20;background:rgba(22,24,27,0.85);color:#f0f2f3;border:1px solid #454c55;border-radius:6px;padding:8px 16px;font:13px "Segoe UI",system-ui,sans-serif;opacity:0;transition:opacity .4s;pointer-events:none;'; + root.appendChild(this.toast); + const restored = vscode.getState(); + const seed = restored?.seed ?? (this.settings.seed !== 0 ? this.settings.seed : randomSeed()); + this.world = new World(seed); + if (restored) { + this.px = restored.px; + this.py = restored.py; + this.yaw = restored.yaw; + this.world.session.warpTo(Math.floor(this.px), Math.floor(this.py)); + } + window.addEventListener("message", (event) => { + if (event.source !== window || !event.data || typeof event.data !== "object") { + return; + } + const message = event.data; + if (message.type === "config") { + this.applySettings(message.settings); + } else if (message.type === "relocate") { + this.relocate(message.seed); + } else if (message.type === "jobStatus") { + this.film.setJob(message.job); + this.graffiti.setJob(message.job); + } else if (message.type === "chatSession") { + this.film.setJob({ + working: message.session.working, + status: "", + tokens: message.session.tokens + }); + this.graffiti.setSession(message.session); + } + }); + vscode.postMessage({ type: "ready" }); + this.showToast(`seed ${seed} - walk with WASD or arrows, M for menu`); + this.syncMonster(); + void this.loadMaterialImages(); + requestAnimationFrame((t) => this.frame(t)); + } + /** + * Loads the photo materials shipped under materials/ and patches them over + * the procedural atlas. Any file that is missing or fails to decode leaves + * its procedural tile in place. + */ + async loadMaterialImages() { + const uris = window.__BACKROOMS_MATERIALS__ ?? {}; + const load = (uri) => new Promise((resolve) => { + if (!uri) { + resolve(void 0); + return; + } + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(void 0); + image.src = uri; + }); + const [wallpaper, ceiling, carpet] = await Promise.all([ + load(uris.wallpaper), + load(uris.ceiling), + load(uris.carpet) + ]); + if (!wallpaper && !ceiling && !carpet) { + return; + } + this.renderer.applyMaterialImages({ wallpaper, ceiling, carpet }); + if (wallpaper) { + this.world.uniformWallpaper = true; + this.uniformWallpaper = true; + this.world.invalidateChunks(this.renderer); + } + } + toggleMenu() { + if (this.menu.isOpen) { + this.menu.close(); + } else { + this.input.releasePointer(); + this.menu.syncSettings(this.settings); + this.menu.syncStats(this.world.stats()); + this.menu.open(); + } + } + // The host may pick the seed (so its action response stays accurate); + // anything invalid or absent rolls a fresh one here. + relocate(seed) { + const next = typeof seed === "number" && Number.isFinite(seed) && seed > 0 ? Math.floor(seed) : randomSeed(); + this.rebuildWorld(next); + // Adopt the new seed as the effective setting; otherwise the config + // replayed on a reload still carries the old seed and immediately + // rebuilds away from the relocated world. + this.changeSetting("seed", next); + this.menu.close(); + this.showToast(`relocated to seed ${next}`); + } + rebuildWorld(seed) { + this.world.invalidateChunks(this.renderer); + this.graffiti.reset(this.renderer); + this.world = new World(seed); + this.world.furnitureEnabled = this.settings.furniture; + this.world.wallpaperShiftsEnabled = this.settings.wallpaperShifts; + this.world.setMaterial( + this.settings.materialPreset, + this.settings.materialHueShift, + this.settings.materialBrightness + ); + this.world.uniformWallpaper = this.uniformWallpaper; + this.px = 0.5; + this.py = 0.5; + this.syncMonster(true); + this.persist(); + this.menu.syncStats(this.world.stats()); + } + monsterConfig() { + return { + speed: this.settings.monsterSpeed, + spawnMinMs: this.settings.monsterSpawnMin * 6e4, + spawnMaxMs: this.settings.monsterSpawnMax * 6e4, + form: this.settings.monsterForm + }; + } + /** Creates, retunes, or removes the monster to match current settings. */ + syncMonster(rearm = false) { + if (!this.settings.monsterEnabled) { + this.monster = null; + this.renderer.clearDynamicMesh(); + return; + } + if (!this.monster || rearm) { + this.monster = new Monster(this.world, this.monsterConfig(), performance.now()); + } else { + this.monster.configure(this.monsterConfig()); + } + } + changeSetting(key, value) { + this.applySettings({ ...this.settings, [key]: value }); + vscode.postMessage({ type: "updateSetting", key, value }); + } + applySettings(settings) { + const previous = this.settings; + this.settings = settings; + this.film.grainEnabled = settings.filmGrain; + this.film.hudEnabled = settings.vhsHud; + this.film.tokenCounterEnabled = settings.copilotGhostWriter; + if (this.graffiti.enabled && !settings.copilotGhostWriter) { + this.graffiti.reset(this.renderer); + } + this.graffiti.enabled = settings.copilotGhostWriter; + this.input.mouseLookEnabled = settings.mouseLook; + this.renderer.fogDensity = 2.6 / (settings.renderDistance * settings.renderDistance); + if (previous.furniture !== settings.furniture || previous.wallpaperShifts !== settings.wallpaperShifts || previous.materialPreset !== settings.materialPreset || previous.materialHueShift !== settings.materialHueShift || previous.materialBrightness !== settings.materialBrightness) { + this.world.furnitureEnabled = settings.furniture; + this.world.wallpaperShiftsEnabled = settings.wallpaperShifts; + this.world.setMaterial(settings.materialPreset, settings.materialHueShift, settings.materialBrightness); + this.world.invalidateChunks(this.renderer); + } + this.syncMonster(previous.monsterEnabled !== settings.monsterEnabled); + if (settings.seed !== previous.seed && settings.seed !== 0 && settings.seed !== this.world.seed) { + this.rebuildWorld(settings.seed); + } + this.menu.syncSettings(settings); + } + showToast(text) { + this.toast.textContent = text; + this.toast.style.opacity = "1"; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toast.style.opacity = "0"; + }, 4200); + } + persist() { + vscode.setState({ seed: this.world.seed, px: this.px, py: this.py, yaw: this.yaw }); + } + frame(now) { + const dt = Math.min(0.05, (now - this.lastFrame) / 1e3 || 0.016); + this.lastFrame = now; + const t = now / 1e3; + let speed = 0; + if (!this.menu.isOpen) { + speed = this.step(dt); + if (this.monster) { + const event = this.monster.update(now, dt, this.px, this.py); + if (event === "spawned") { + this.showToast("the air changes. something else is in the halls."); + } else if (event === "caught") { + this.film.burst(now); + this.px = 0.5; + this.py = 0.5; + this.world.session.warpTo(0, 0); + this.persist(); + this.showToast("tape resumes somewhere familiar. it is still out there."); + } + } + } + if (this.monster?.isStalking) { + const mesh = this.monster.buildMesh(now, this.px, this.py); + this.renderer.setDynamicMesh(mesh.vertices, mesh.indices); + } else { + this.renderer.clearDynamicMesh(); + } + let shakeYaw = 0; + let shakePitch = 0; + let shakeRoll = 0; + let shakeUp = 0; + if (this.settings.cameraShake) { + const drift = 1 + speed * 1.6; + shakeYaw = (Math.sin(t * 0.9) * 6e-3 + Math.sin(t * 2.3 + 1.7) * 3e-3) * drift; + shakePitch = (Math.sin(t * 1.3 + 0.6) * 4e-3 + Math.sin(t * 3.1) * 2e-3) * drift; + shakeRoll = Math.sin(t * 0.7 + 2.1) * 4e-3 * drift + Math.sin(this.bobPhase) * 6e-3 * speed; + shakeUp = Math.sin(this.bobPhase * 2) * 0.014 * speed; + } + if (Math.random() < 15e-4 && this.flickerDipUntil < now) { + this.flickerDipUntil = now + 60 + Math.random() * 120; + } + let flicker = 1 + Math.sin(t * 11) * 0.012 + Math.sin(t * 47) * 8e-3; + if (this.flickerDipUntil > now) { + flicker *= 0.82; + } + const camera = { + x: this.px, + y: EYE_HEIGHT + shakeUp, + z: this.py, + yaw: this.yaw + shakeYaw, + pitch: this.pitch + shakePitch, + roll: shakeRoll, + fovY: 72 * Math.PI / 180 + }; + this.graffiti.update(now, this.world, this.px, this.py, this.yaw, this.renderer); + const chunks = this.world.updateChunks(this.px, this.py, this.settings.renderDistance, this.renderer); + this.renderer.draw(chunks, camera, flicker); + this.film.render(now); + if (now - this.lastPersist > 1500) { + this.lastPersist = now; + this.persist(); + } + requestAnimationFrame((next) => this.frame(next)); + } + /** Applies input to the player; returns normalized speed for bob effects. */ + step(dt) { + const input = this.input.state(); + const look = this.input.consumeLook(); + const turnSign = this.settings.invertTurn ? -1 : 1; + this.yaw -= (look.dx * 26e-4 + input.turn * 1.9 * dt) * turnSign; + this.pitch = clamp3(this.pitch - look.dy * 22e-4, -1.25, 1.25); + const rate = this.settings.moveSpeed * (input.running ? 1.7 : 1); + const forward = input.forward * (this.settings.invertForward ? -1 : 1); + const strafe = input.strafe * (this.settings.invertStrafe ? -1 : 1); + const fx = -Math.sin(this.yaw); + const fy = -Math.cos(this.yaw); + const rx = Math.cos(this.yaw); + const ry = -Math.sin(this.yaw); + const dx = (fx * forward + rx * strafe) * rate * dt; + const dy = (fy * forward + ry * strafe) * rate * dt; + if (dx === 0 && dy === 0) { + return 0; + } + const moved = this.world.moveResolved(this.px, this.py, dx, dy); + const actual = Math.hypot(moved.x - this.px, moved.y - this.py); + this.px = moved.x; + this.py = moved.y; + this.world.syncSession(this.px, this.py); + this.bobPhase += actual * 5.6; + return Math.min(1, actual / (rate * dt + 1e-6)); + } + }; + function randomSeed() { + return Math.floor(Math.random() * 999999) + 1; + } + function clamp3(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var app = document.getElementById("app"); + if (app) { + new Game(app); + } +})(); diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg new file mode 100644 index 00000000..0a631716 Binary files /dev/null and b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg new file mode 100644 index 00000000..8d314ec3 Binary files /dev/null and b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg new file mode 100644 index 00000000..9b0fd07d Binary files /dev/null and b/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/package-lock.json b/extensions/backrooms-canvas/extensions/backrooms-canvas/package-lock.json new file mode 100644 index 00000000..f7bfc585 --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/package-lock.json @@ -0,0 +1,446 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backrooms-canvas", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7.tgz", + "integrity": "sha512-dgCFCPfxWUkrgclQbrm7WCFzTf5RnJHsK1Lqsc3KjPBbDLPutJT0qIGg3xJ0ZELLyX0icg3TOmVczhR4HdwHxw==", + "dependencies": { + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", + "integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz", + "integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz", + "integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz", + "integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz", + "integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz", + "integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz", + "integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz", + "integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz", + "integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz", + "integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz", + "integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz", + "integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz", + "integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz", + "integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz", + "integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/koffi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", + "integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==", + "hasInstallScript": true, + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.1", + "@koromix/koffi-darwin-x64": "3.1.1", + "@koromix/koffi-freebsd-arm64": "3.1.1", + "@koromix/koffi-freebsd-ia32": "3.1.1", + "@koromix/koffi-freebsd-x64": "3.1.1", + "@koromix/koffi-linux-arm64": "3.1.1", + "@koromix/koffi-linux-ia32": "3.1.1", + "@koromix/koffi-linux-loong64": "3.1.1", + "@koromix/koffi-linux-riscv64": "3.1.1", + "@koromix/koffi-linux-x64": "3.1.1", + "@koromix/koffi-openbsd-ia32": "3.1.1", + "@koromix/koffi-openbsd-x64": "3.1.1", + "@koromix/koffi-win32-arm64": "3.1.1", + "@koromix/koffi-win32-ia32": "3.1.1", + "@koromix/koffi-win32-x64": "3.1.1" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/package.json b/extensions/backrooms-canvas/extensions/backrooms-canvas/package.json new file mode 100644 index 00000000..abb1d48d --- /dev/null +++ b/extensions/backrooms-canvas/extensions/backrooms-canvas/package.json @@ -0,0 +1,20 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "main": "extension.mjs", + "author": "John Haugabook", + "license": "MIT", + "type": "module", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ] +} diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/README.md index 43e7bc99..66c53489 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/README.md +++ b/extensions/connector-namespaces/extensions/connector-namespaces/README.md @@ -16,8 +16,8 @@ and connected-server management into the Copilot side panel. - **My MCPs** - see which servers are connected and ready to add to Copilot. - **Namespace playground** - open any connected server in the Connector Namespace playground with **Sandbox**. -- **Persistent setup** - retain the selected namespace and restore Azure sign-in - securely across app restarts. +- **Persistent namespace selection** - retain the selected namespace while Azure + tokens remain in memory and sign-in is requested again after a restart. ## Install @@ -36,10 +36,6 @@ and select **Install in GitHub Copilot app**. - Permission to view the namespace and create its connections and hosted MCP server configurations. - A browser for Microsoft Entra sign-in and connector consent. -- An operating-system secure credential store. Windows and macOS provide one by - default. Linux and WSL require a Secret Service-compatible keyring, such as - GNOME Keyring, with `libsecret` available. Unencrypted token storage is - intentionally disabled. Connector Namespace is currently an Azure preview service and availability can vary by region. @@ -64,13 +60,13 @@ playground. Use **Change namespace** to switch subscriptions or namespaces. Azure sign-in and connector sign-in are separate: - **Azure sign-in** lets the canvas discover and manage Connector Namespace - resources. Access and refresh tokens are stored in the operating system's - encrypted credential store. To select that encrypted cache entry after an app - restart, the extension separately saves a non-secret authentication record - containing the authority, client ID, account ID, tenant ID, and username under - `~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`, - with user-only permissions where supported. Raw tokens are never written to - extension files. + resources. Access and refresh tokens remain in the extension process and are + never written to extension files. Reloading the extension or restarting the + app requires Azure sign-in again. The selected namespace coordinates are + retained in + `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so + the canvas can explain that the namespace is still linked and return directly + to its connectors after sign-in. - **Connector sign-in** grants an individual MCP server access to its backing service. The resulting connection is managed by Connector Namespace. diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs index 0b72597b..c0eb5fd6 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs @@ -2,16 +2,9 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { - deserializeAuthenticationRecord, - InteractiveBrowserCredential, - serializeAuthenticationRecord, - useIdentityPlugin, -} from "@azure/identity"; -import { cachePersistencePlugin } from "@azure/identity-cache-persistence"; +import { InteractiveBrowserCredential } from "@azure/identity"; export const ARM_SCOPE = "https://management.azure.com/.default"; -export const TOKEN_CACHE_NAME = "github-copilot-connector-namespaces"; const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000; @@ -24,50 +17,20 @@ const AUTH_STORAGE_DIR = join( const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json"); const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json"); -let legacyAuthCacheRemoved = false; +let legacyAuthArtifactsRemoved = false; -useIdentityPlugin(cachePersistencePlugin); - -export async function loadAuthenticationRecord({ - readFile = fs.readFile, - deserialize = deserializeAuthenticationRecord, - authRecordFile = AUTH_RECORD_FILE, -} = {}) { - let serialized; - try { - serialized = await readFile(authRecordFile, "utf-8"); - } catch (error) { - if (error?.code === "ENOENT") return undefined; - throw error; - } - try { - return deserialize(serialized); - } catch { - return undefined; - } -} - -async function saveAuthenticationRecord(record) { - await fs.mkdir(AUTH_STORAGE_DIR, { recursive: true, mode: 0o700 }); - await fs.chmod(AUTH_STORAGE_DIR, 0o700); - await fs.writeFile( - AUTH_RECORD_FILE, - serializeAuthenticationRecord(record), - { encoding: "utf-8", mode: 0o600 }, - ); - await fs.chmod(AUTH_RECORD_FILE, 0o600); -} - -async function removeLegacyAuthCache() { - if (legacyAuthCacheRemoved) return; - try { - await fs.unlink(LEGACY_AUTH_CACHE); - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); +async function removeLegacyAuthArtifacts() { + if (legacyAuthArtifactsRemoved) return; + for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) { + try { + await fs.unlink(path); + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`); + } } } - legacyAuthCacheRemoved = true; + legacyAuthArtifactsRemoved = true; } export class ConnectorAuthenticationRequiredError extends Error { @@ -103,20 +66,14 @@ export class InteractiveAuthBroker { createCredential = credentialFactory, createSessionId = randomUUID, cleanupLegacyCredentials = async () => {}, - loadAuthRecord = async () => undefined, - saveAuthRecord = async () => {}, now = Date.now, scope = ARM_SCOPE, - tokenCacheName = TOKEN_CACHE_NAME, } = {}) { this.createCredential = createCredential; this.createSessionId = createSessionId; this.cleanupLegacyCredentials = cleanupLegacyCredentials; - this.loadAuthRecord = loadAuthRecord; - this.saveAuthRecord = saveAuthRecord; this.now = now; this.scope = scope; - this.tokenCacheName = tokenCacheName; this.credential = null; this.accessToken = null; this.cleanupInFlight = null; @@ -124,15 +81,10 @@ export class InteractiveAuthBroker { this.sessions = new Map(); } - createInteractiveCredential(authenticationRecord) { + createInteractiveCredential() { return this.createCredential({ redirectUri: "http://localhost", disableAutomaticAuthentication: true, - tokenCachePersistenceOptions: { - enabled: true, - name: this.tokenCacheName, - }, - ...(authenticationRecord ? { authenticationRecord } : {}), }); } @@ -188,7 +140,6 @@ export class InteractiveAuthBroker { if (!authenticationRecord) { throw new Error("Azure identity did not return an authentication record."); } - await this.saveAuthRecord(authenticationRecord); const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal }); if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { throw new Error("Azure identity returned an incomplete ARM access token."); @@ -235,13 +186,8 @@ export class InteractiveAuthBroker { let credential = this.credential; if (!credential) { try { - const authenticationRecord = await this.loadAuthRecord(); - if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; - credential = this.credential; - if (!credential) { - credential = this.createInteractiveCredential(authenticationRecord); - this.credential = credential; - } + credential = this.createInteractiveCredential(); + this.credential = credential; } catch (error) { if (isAuthenticationRequiredError(error)) { throw new ConnectorAuthenticationRequiredError( @@ -281,9 +227,7 @@ export class InteractiveAuthBroker { } export const interactiveAuth = new InteractiveAuthBroker({ - cleanupLegacyCredentials: removeLegacyAuthCache, - loadAuthRecord: loadAuthenticationRecord, - saveAuthRecord: saveAuthenticationRecord, + cleanupLegacyCredentials: removeLegacyAuthArtifacts, }); export const startSignIn = () => interactiveAuth.startSignIn(); diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs index 43356644..33278646 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs @@ -4,8 +4,6 @@ import assert from "node:assert/strict"; import { ConnectorAuthenticationRequiredError, InteractiveAuthBroker, - loadAuthenticationRecord, - TOKEN_CACHE_NAME, } from "./auth.mjs"; function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) { @@ -35,7 +33,6 @@ test("ARM token requests require an explicit browser sign-in", async () => { }, }; }, - loadAuthRecord: async () => undefined, }); await assert.rejects( @@ -43,32 +40,13 @@ test("ARM token requests require an explicit browser sign-in", async () => { (error) => error instanceof ConnectorAuthenticationRequiredError && error.code === "authentication_required", ); - assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, { - enabled: true, - name: TOKEN_CACHE_NAME, + assert.deepEqual(credentialOptions, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, }); }); -test("a malformed authentication record falls back to browser sign-in", async () => { - assert.equal( - await loadAuthenticationRecord({ - readFile: async () => "{malformed-json", - }), - undefined, - ); -}); - -test("authentication record read failures remain operational errors", async () => { - const readError = Object.assign(new Error("authentication record is unreadable"), { code: "EACCES" }); - await assert.rejects( - loadAuthenticationRecord({ - readFile: async () => { throw readError; }, - }), - (error) => error === readError, - ); -}); - -test("interactive sign-in reports pending then done and caches the ARM token", async () => { +test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => { let credentialOptions; let authenticateOptions; const credential = { @@ -89,7 +67,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a return credential; }, createSessionId: () => "signin-session", - saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()), now: () => 1_000, }); @@ -110,10 +87,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a assert.deepEqual(credentialOptions, { redirectUri: "http://localhost", disableAutomaticAuthentication: true, - tokenCachePersistenceOptions: { - enabled: true, - name: TOKEN_CACHE_NAME, - }, }); assert.equal(authenticateOptions.abortSignal.aborted, false); assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); @@ -124,22 +97,24 @@ test("interactive sign-in reports pending then done and caches the ARM token", a assert.equal(await broker.getToken(), "token"); }); -test("a new broker restores the persisted credential without reopening the browser", async () => { - const cache = { record: null, token: null }; +test("a new broker requires browser sign-in after the extension reloads", async () => { let authenticateCalls = 0; + let createCredentialCalls = 0; const createCredential = (options) => { - assert.deepEqual(options.tokenCachePersistenceOptions, { - enabled: true, - name: TOKEN_CACHE_NAME, + createCredentialCalls++; + assert.deepEqual(options, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, }); + let signedIn = false; return { async authenticate() { authenticateCalls++; - cache.token = accessToken("persisted-token"); + signedIn = true; return authenticationRecord(); }, async getToken() { - if (cache.token && options.authenticationRecord) return cache.token; + if (signedIn) return accessToken("memory-token"); const error = new Error("No cached account found."); error.name = "AuthenticationRequiredError"; throw error; @@ -149,41 +124,36 @@ test("a new broker restores the persisted credential without reopening the brows const signedInBroker = new InteractiveAuthBroker({ createCredential, createSessionId: () => "persist-session", - saveAuthRecord: async (record) => { cache.record = record; }, now: () => 1_000, }); const started = signedInBroker.startSignIn(); await signedInBroker.sessions.get(started.sessionId).promise; assert.equal(authenticateCalls, 1); + assert.equal(await signedInBroker.getToken(), "memory-token"); const restartedBroker = new InteractiveAuthBroker({ createCredential, - loadAuthRecord: async () => cache.record, now: () => 1_000, }); - assert.equal(await restartedBroker.getToken(), "persisted-token"); + await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError); assert.equal(authenticateCalls, 1); + assert.equal(createCredentialCalls, 2); }); -test("concurrent first-time token requests share credential initialization and acquisition", async () => { - let releaseAuthenticationRecord; - const authenticationRecordReady = new Promise((resolve) => { - releaseAuthenticationRecord = resolve; +test("concurrent first-time token requests share credential acquisition", async () => { + let releaseToken; + const tokenReady = new Promise((resolve) => { + releaseToken = resolve; }); - let loadAuthRecordCalls = 0; let createCredentialCalls = 0; let tokenCalls = 0; const broker = new InteractiveAuthBroker({ - async loadAuthRecord() { - loadAuthRecordCalls++; - await authenticationRecordReady; - return authenticationRecord(); - }, createCredential: () => { createCredentialCalls++; return { async getToken() { tokenCalls++; + await tokenReady; return accessToken("shared-token"); }, }; @@ -193,15 +163,12 @@ test("concurrent first-time token requests share credential initialization and a const firstRequest = broker.getToken(); const secondRequest = broker.getToken(); await new Promise((resolve) => setImmediate(resolve)); - const loadsBeforeRelease = loadAuthRecordCalls; - releaseAuthenticationRecord(); + releaseToken(); assert.deepEqual( await Promise.all([firstRequest, secondRequest]), ["shared-token", "shared-token"], ); - assert.equal(loadsBeforeRelease, 1); - assert.equal(loadAuthRecordCalls, 1); assert.equal(createCredentialCalls, 1); assert.equal(tokenCalls, 1); }); @@ -224,7 +191,6 @@ test("cancelling sign-in aborts the credential request", async () => { const broker = new InteractiveAuthBroker({ createCredential: () => credential, createSessionId: () => "cancel-session", - loadAuthRecord: async () => undefined, now: () => 1_000, }); @@ -289,7 +255,6 @@ test("legacy credential cleanup retries after a transient failure", async () => return accessToken(); }, }), - loadAuthRecord: async () => authenticationRecord(), }); await assert.rejects(broker.getToken(), /legacy cache is locked/); @@ -312,7 +277,6 @@ test("token acquisition preserves operational errors and retries the credential" }, }; }, - loadAuthRecord: async () => authenticationRecord(), }); await assert.rejects(broker.getToken(), (error) => error === outage); @@ -328,7 +292,6 @@ test("incomplete tokens remain operational errors instead of prompting sign-in", return { token: "incomplete" }; }, }), - loadAuthRecord: async () => authenticationRecord(), }); await assert.rejects( diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/extensions/connector-namespaces/package.json index 24e6f05b..26f54bbc 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/package.json +++ b/extensions/connector-namespaces/extensions/connector-namespaces/package.json @@ -15,7 +15,6 @@ "license": "MIT", "dependencies": { "@azure/identity": "4.13.1", - "@azure/identity-cache-persistence": "1.3.0", "@github/copilot-sdk": "1.0.6" } } diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs index a6cdf413..b2a74c6a 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs @@ -304,14 +304,23 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s // Setup / Namespace Picker // --------------------------------------------------------------------------- -export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") { +export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "", { linkedNamespace = "" } = {}) { + const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0; + const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace"; + const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace"; + const subheading = hasLinkedNamespace + ? `Connector namespace ${esc(linkedNamespace)} is already linked.` + : "Choose which connector namespace to browse. This choice is saved for future sessions."; + const defaultSigninMessage = hasLinkedNamespace + ? "Sign in to Azure to view and manage its connectors." + : "Sign in to Azure to load your subscriptions and connector namespaces."; const subOptions = subscriptions.map((s) => `` ).join(""); return ` -Select Connector Namespace${baseStyles()} +${pageTitle}${baseStyles()}
-

${brandMark(30, "setup")}Select a Connector Namespace

-
Choose which connector namespace to browse. This choice is saved for future sessions.
+

${brandMark(30, "setup")}${heading}

+
${subheading}
${notice ? `
${esc(notice)}
` : ""} -
+
@@ -386,6 +395,8 @@ ${notice ? `
${esc(notice)}
` : ""}
`; } diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs index 3a3899cb..fbb57e52 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs @@ -67,6 +67,27 @@ test("setup prompts, polls, cancels, and reloads subscriptions after browser sig assert.match(html, /\/api\/subscriptions/); }); +test("a linked namespace gets a focused sign-in state and returns to its catalog", () => { + const html = renderSetupHtml([], "", "token", { linkedNamespace: "saved-gateway" }); + assert.match(html, /Sign in to see your connectors/); + assert.match(html, /Connector namespace saved-gateway<\/code> is already linked\./); + assert.match(html, /Sign in to Azure to view and manage its connectors\./); + assert.match(html, /const hasLinkedNamespace = true/); + assert.match(html, /window\.location\.replace\("\/"\)/); + assert.match(html, /