Compare commits
51 Commits
bl/extract
...
architectu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef8133a53 | ||
|
|
9f7646309c | ||
|
|
28797da9c0 | ||
|
|
98e3742ada | ||
|
|
e2b93bd0e5 | ||
|
|
997f4995dc | ||
|
|
0d71e6757a | ||
|
|
9b5c24b0ed | ||
|
|
418ae5b767 | ||
|
|
98c1ffc5de | ||
|
|
a8670fed6e | ||
|
|
5d3b074f9d | ||
|
|
62a34952f1 | ||
|
|
fcb7d914f3 | ||
|
|
5954b799dd | ||
|
|
440b3280b6 | ||
|
|
8aef477ce7 | ||
|
|
c33ab2f155 | ||
|
|
63022511a2 | ||
|
|
9a2eb88d6c | ||
|
|
bb5713d4c3 | ||
|
|
ddf0256695 | ||
|
|
7ff4938ed6 | ||
|
|
16b262cbb5 | ||
|
|
b55a5582fd | ||
|
|
0d19d8646c | ||
|
|
8449a496ca | ||
|
|
b6d8836d14 | ||
|
|
5dea2d0f1b | ||
|
|
0505bda1ce | ||
|
|
c78ad2f2c6 | ||
|
|
fcdb1237d2 | ||
|
|
3428466eff | ||
|
|
f61bde18dc | ||
|
|
eb4be6052e | ||
|
|
0dca2d5b05 | ||
|
|
caf98def1b | ||
|
|
8319e74f7b | ||
|
|
6e06280642 | ||
|
|
716629adbc | ||
|
|
8779a1e4f8 | ||
|
|
4a5e409d7f | ||
|
|
70c3f88f1f | ||
|
|
7db077534e | ||
|
|
0006815ebf | ||
|
|
629513579e | ||
|
|
06739fc4b0 | ||
|
|
79a2f577c0 | ||
|
|
bed2c2fdab | ||
|
|
e6901a32a3 | ||
|
|
97c61eeff3 |
1
.gitignore
vendored
@@ -66,6 +66,7 @@ dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
.superpowers/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
16
apps/architecture-adventure/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codebase Caverns — ComfyUI Architecture Adventure</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A prestige-driven architecture adventure game. Discover problems, learn patterns, make decisions, and watch the consequences unfold."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
apps/architecture-adventure/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@comfyorg/architecture-adventure",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && tsx scripts/inline-build.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:docs",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"command": "tsc --noEmit && vite build --config apps/architecture-adventure/vite.config.ts && tsx apps/architecture-adventure/scripts/inline-build.ts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/architecture-adventure/scripts/inline-build.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const distDir = join(import.meta.dirname, '..', 'dist')
|
||||
const htmlPath = join(distDir, 'index.html')
|
||||
|
||||
let html = readFileSync(htmlPath, 'utf-8')
|
||||
|
||||
const assetsDir = join(distDir, 'assets')
|
||||
if (existsSync(assetsDir)) {
|
||||
const assets = readdirSync(assetsDir)
|
||||
|
||||
// Inline CSS files
|
||||
for (const file of assets) {
|
||||
if (file.endsWith('.css')) {
|
||||
const css = readFileSync(join(assetsDir, file), 'utf-8')
|
||||
html = html.replace(
|
||||
new RegExp(`<link[^>]*href="[./]*assets/${file}"[^>]*>`),
|
||||
`<style>${css}</style>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Inline JS files
|
||||
for (const file of assets) {
|
||||
if (file.endsWith('.js')) {
|
||||
const js = readFileSync(join(assetsDir, file), 'utf-8')
|
||||
html = html.replace(
|
||||
new RegExp(`<script[^>]*src="[./]*assets/${file}"[^>]*></script>`),
|
||||
`<script type="module">${js}</script>`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(htmlPath, html)
|
||||
|
||||
const sizeKB = (Buffer.byteLength(html) / 1024).toFixed(1)
|
||||
console.warn(`Single-file build complete: ${htmlPath} (${sizeKB} KB)`)
|
||||
476
apps/architecture-adventure/src/data/challenges.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import type { ChallengeDefinition } from '@/types'
|
||||
|
||||
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
|
||||
|
||||
export const challenges: Record<string, ChallengeDefinition> = {
|
||||
'circular-dependency': {
|
||||
id: 'circular-dependency',
|
||||
roomId: 'components',
|
||||
title: 'The Circular Dependency',
|
||||
tier: 1,
|
||||
description:
|
||||
'A tangled knot blocks the corridor ahead. Subgraph extends LGraph, ' +
|
||||
'but LGraph creates and manages Subgraph instances. The circular import ' +
|
||||
'forces order-dependent barrel exports and makes testing impossible in isolation. ' +
|
||||
'How do you untangle it?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['composition'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: Circular Dependencies',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Composition over inheritance',
|
||||
hint: 'A subgraph IS a graph \u2014 just a node with a SubgraphStructure component. ECS eliminates class inheritance entirely.',
|
||||
icon: 'components-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The circular dependency dissolves. Under graph unification, a subgraph is just a node carrying a SubgraphStructure component in a flat World. No inheritance, no special cases.',
|
||||
tagsGranted: ['composition'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Barrel file reordering',
|
||||
hint: 'Rearrange exports so the cycle resolves at module load time.',
|
||||
icon: 'components-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The imports stop crashing... for now. But the underlying coupling remains, and any new file touching both classes risks reviving the cycle.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Factory injection',
|
||||
hint: 'Pass a graph factory function to break the static import cycle.',
|
||||
icon: 'components-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
"The factory breaks the import cycle cleanly. It's a pragmatic fix, though the classes remain tightly coupled at runtime.",
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'scattered-mutations': {
|
||||
id: 'scattered-mutations',
|
||||
roomId: 'stores',
|
||||
title: 'The Scattered Mutations',
|
||||
tier: 1,
|
||||
description:
|
||||
'Deep in the vaults, you find a fragile counter: graph._version++. ' +
|
||||
'It appears in 19 locations across 7 files \u2014 LGraph.ts (5 sites), ' +
|
||||
'LGraphNode.ts (8 sites), LGraphCanvas.ts (2 sites), BaseWidget.ts, SubgraphInput.ts, ' +
|
||||
'SubgraphInputNode.ts, SubgraphOutput.ts. ' +
|
||||
'Change tracking depends on this scattered increment. One missed site means silent data loss.',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['centralized-mutations'],
|
||||
docLink: {
|
||||
label: 'Migration Plan: Phase 0a',
|
||||
url: `${GH}/docs/architecture/ecs-migration-plan.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Centralize into graph.incrementVersion()',
|
||||
hint: 'Route all 19 sites through a single method. Phase 0a of the migration plan.',
|
||||
icon: 'stores-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'All 19 scattered increments now flow through one method. Change tracking becomes auditable, and the VersionSystem has a single hook point.',
|
||||
tagsGranted: ['centralized-mutations'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Add a JavaScript Proxy',
|
||||
hint: 'Intercept all writes to _version automatically.',
|
||||
icon: 'stores-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The Proxy catches mutations, but adds runtime overhead and makes debugging opaque. The scattered sites remain in the code.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Leave it as-is',
|
||||
hint: "It works. Don't touch it.",
|
||||
icon: 'stores-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The team breathes a sigh of relief... until the next silent data loss bug from a missed increment site.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'migration-question': {
|
||||
id: 'migration-question',
|
||||
roomId: 'services',
|
||||
title: 'The Migration Question',
|
||||
tier: 1,
|
||||
description:
|
||||
'A fork in the corridor. The legacy litegraph engine works \u2014 thousands of users ' +
|
||||
'depend on it daily. But the architecture docs describe a better future: ECS with ' +
|
||||
'branded types, pure systems, and a World registry. ' +
|
||||
'How do you get from here to there without breaking production?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['incremental-migration'],
|
||||
docLink: {
|
||||
label: 'ECS Migration Plan',
|
||||
url: `${GH}/docs/architecture/ecs-migration-plan.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: '5-phase incremental plan',
|
||||
hint: 'Foundation \u2192 Types \u2192 Bridge \u2192 Systems \u2192 Legacy Removal. Each phase is independently shippable.',
|
||||
icon: 'services-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The team maps out five phases, each independently testable and shippable. Old and new coexist during transition. Production never breaks.',
|
||||
tagsGranted: ['incremental-migration'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Big bang rewrite',
|
||||
hint: 'Freeze features, rewrite everything in parallel, swap when ready.',
|
||||
icon: 'services-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Feature freeze begins. Weeks pass. The rewrite grows scope. Morale plummets. The old codebase drifts further from the new one.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Strangler fig pattern',
|
||||
hint: 'Build new ECS beside old code, migrate consumers one by one.',
|
||||
icon: 'services-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'A solid pattern. The new system grows organically around the old, though without a phased plan the migration lacks clear milestones.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'god-object-dilemma': {
|
||||
id: 'god-object-dilemma',
|
||||
roomId: 'litegraph',
|
||||
title: 'The God Object Dilemma',
|
||||
tier: 2,
|
||||
description:
|
||||
'LGraphCanvas looms before you: ~9,100 lines of rendering, ' +
|
||||
'input handling, selection, context menus, undo/redo, and more. LGraphNode ' +
|
||||
'adds ~4,300 lines with ~539 method/property definitions mixing rendering, ' +
|
||||
'serialization, connectivity, execution, layout, and state management. ' +
|
||||
"These god objects are the root of most architectural pain. What's your approach?",
|
||||
recommended: 'B',
|
||||
tagsGranted: ['responsibility-extraction'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: God Objects',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Rewrite from scratch',
|
||||
hint: 'Tear it all down and rebuild with clean architecture from day one.',
|
||||
icon: 'litegraph-a',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The rewrite begins heroically... and stalls at month three. The team burns out reimplementing edge cases the god objects handled implicitly.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Extract incrementally',
|
||||
hint: 'Peel responsibilities into focused modules one at a time. Position first, then connectivity, then rendering.',
|
||||
icon: 'litegraph-b',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
"Position extraction lands first (it's already in LayoutStore). Then connectivity. Each extraction is a small, testable PR. The god objects shrink steadily.",
|
||||
tagsGranted: ['responsibility-extraction'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Add a facade layer',
|
||||
hint: 'Wrap the god objects with a clean API without changing internals.',
|
||||
icon: 'litegraph-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The facade provides a nicer API, but the complexity still lives behind it. New features still require diving into the god objects.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'id-crossroads': {
|
||||
id: 'id-crossroads',
|
||||
roomId: 'ecs',
|
||||
title: 'The ID Crossroads',
|
||||
tier: 2,
|
||||
description:
|
||||
'The blueprints show a problem: NodeId is typed as number | string. ' +
|
||||
'Nothing prevents passing a LinkId where a NodeId is expected. ' +
|
||||
'Widgets are identified by name + parent node (fragile lookup). ' +
|
||||
'Slots are identified by array index (breaks when reordered). ' +
|
||||
'The six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 all ' +
|
||||
'share the same untyped ID space. How do you bring type safety to this ID chaos?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['branded-types'],
|
||||
docLink: {
|
||||
label: 'ECS Target Architecture: Entity IDs',
|
||||
url: `${GH}/docs/architecture/ecs-target-architecture.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Branded types with cast helpers',
|
||||
hint: "type NodeEntityId = number & { __brand: 'NodeEntityId' } \u2014 compile-time safety, zero runtime cost.",
|
||||
icon: 'ecs-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The compiler now catches cross-kind ID bugs. Cast helpers at system boundaries (asNodeEntityId()) keep the ergonomics clean. Phase 1a complete.',
|
||||
tagsGranted: ['branded-types'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'String prefixes at runtime',
|
||||
hint: '"node:42", "link:7" \u2014 parse and validate at every usage site.',
|
||||
icon: 'ecs-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'Runtime checks catch some bugs, but parsing overhead spreads everywhere. And someone will forget the prefix check in a hot path.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Keep plain numbers',
|
||||
hint: 'Just be careful. Document which IDs are which.',
|
||||
icon: 'ecs-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The next developer passes a LinkId to a node lookup. The silent failure takes two days to debug in production.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'widget-promotion': {
|
||||
id: 'widget-promotion',
|
||||
roomId: 'subgraph',
|
||||
title: 'The Widget Promotion Decision',
|
||||
tier: 2,
|
||||
description:
|
||||
'A user right-clicks a widget inside a subgraph and selects "Promote to parent." ' +
|
||||
'Today this requires three layers: PromotionStore, PromotedWidgetViewManager, ' +
|
||||
'and PromotedWidgetView \u2014 a parallel state system that duplicates what ' +
|
||||
'the type-to-widget mapping already does for normal inputs. ' +
|
||||
'Two candidates for the ECS future. The team must decide before Phase 3 solidifies.',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['typed-contracts'],
|
||||
docLink: {
|
||||
label: 'Subgraph Boundaries: Widget Promotion',
|
||||
url: `${GH}/docs/architecture/subgraph-boundaries-and-promotion.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Connections-only: promotion = adding a typed input',
|
||||
hint: 'Promote a widget by adding an interface input. The type\u2192widget mapping creates the widget automatically. No new concepts.',
|
||||
icon: 'subgraph-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely. Promotion becomes an operation on the subgraph\u2019s function signature. The existing slot, link, and widget infrastructure handles everything.',
|
||||
tagsGranted: ['typed-contracts'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Simplified component promotion',
|
||||
hint: 'A WidgetPromotion component on widget entities. Removes ViewManager but preserves promotion as a distinct concept.',
|
||||
icon: 'subgraph-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'The ViewManager and proxy reconciliation are gone, but promotion remains a separate concept from connection. Shared subgraph instances face an open question: which source widget is authoritative?',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Keep the current three-layer system',
|
||||
hint: 'PromotionStore + ViewManager + PromotedWidgetView. It works today.',
|
||||
icon: 'subgraph-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'The parallel state system persists. Every promoted widget is a shadow copy reconciled by a virtual DOM-like diffing layer. The ECS migration must work around it indefinitely.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'render-time-mutation': {
|
||||
id: 'render-time-mutation',
|
||||
roomId: 'renderer',
|
||||
title: 'The Render-Time Mutation',
|
||||
tier: 2,
|
||||
description:
|
||||
'Alarms sound. The render pipeline has a critical flaw: drawNode() calls ' +
|
||||
'_setConcreteSlots() and arrange() during the render pass. ' +
|
||||
'The render phase mutates state, making draw order affect layout. ' +
|
||||
"Node A's position depends on whether Node B was drawn first. " +
|
||||
'How do you fix the pipeline?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['phase-separation'],
|
||||
docLink: {
|
||||
label: 'Entity Problems: Render-Time Mutations',
|
||||
url: `${GH}/docs/architecture/entity-problems.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Separate update and render phases',
|
||||
hint: 'Compute all layout in an update pass, then render as a pure read-only pass. Matches the ECS system pipeline.',
|
||||
icon: 'renderer-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The pipeline becomes: Input \u2192 Update (layout, connectivity) \u2192 Render (read-only). Draw order no longer matters. Bugs vanish.',
|
||||
tagsGranted: ['phase-separation'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Dirty flags and deferred render',
|
||||
hint: 'Mark mutated nodes dirty, skip them, re-render next frame.',
|
||||
icon: 'renderer-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
"Dirty flags reduce the worst symptoms, but the render pass still has permission to mutate. It's a band-aid on an architectural wound.",
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'collaboration-protocol': {
|
||||
id: 'collaboration-protocol',
|
||||
roomId: 'composables',
|
||||
title: 'The Collaboration Protocol',
|
||||
tier: 3,
|
||||
description:
|
||||
'A request arrives: multiple users want to edit the same workflow simultaneously. ' +
|
||||
'The layoutStore already extracts position data from litegraph entities. ' +
|
||||
'But how do you synchronize positions across users without conflicts?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['crdt-sync'],
|
||||
docLink: {
|
||||
label: 'Proto-ECS Stores: LayoutStore',
|
||||
url: `${GH}/docs/architecture/proto-ecs-stores.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Y.js CRDTs',
|
||||
hint: 'Conflict-free replicated data types. Merge without coordination. Already proven at scale.',
|
||||
icon: 'composables-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'Y.js CRDT maps back the layout store. Concurrent edits merge automatically. ADR 0003 is realized. The collaboration future is here.',
|
||||
tagsGranted: ['crdt-sync'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Polling-based sync',
|
||||
hint: 'Fetch full state every few seconds, merge manually, hope for the best.',
|
||||
icon: 'composables-b',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Polling creates a flickering, laggy experience. Two users move the same node and one edit is silently lost. Support tickets pile up.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Skip collaboration for now',
|
||||
hint: 'Single-user editing only. Focus on other priorities.',
|
||||
icon: 'composables-c',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'A pragmatic choice. The team focuses elsewhere. But the cloud product team is not happy about the delay.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
'mutation-gateway': {
|
||||
id: 'mutation-gateway',
|
||||
roomId: 'sidepanel',
|
||||
title: 'The Mutation Gateway',
|
||||
tier: 3,
|
||||
description:
|
||||
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
|
||||
'API (world.setComponent()) conflicts with the command pattern requirement ' +
|
||||
'from ADR 0003. Another faction says commands and the World serve different layers. ' +
|
||||
'How should external callers mutate the World?',
|
||||
recommended: 'A',
|
||||
tagsGranted: ['command-layer'],
|
||||
docLink: {
|
||||
label: 'World API and Command Layer',
|
||||
url: `${GH}/docs/architecture/ecs-world-command-api.md`
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
key: 'A',
|
||||
label: 'Commands as intent; systems as handlers; World as store',
|
||||
hint: 'Caller \u2192 Command \u2192 System \u2192 World \u2192 Y.js. Commands are serializable. The World\u2019s imperative API is internal, called only by systems inside transactions.',
|
||||
icon: 'sidepanel-a',
|
||||
rating: 'good',
|
||||
feedback:
|
||||
'The layering clicks. Commands are serializable intent. Systems are command handlers. The World is the store \u2014 its imperative API is internal, just like Redux\u2019s state mutations inside reducers. ADR 0003 and ADR 0008 are complementary layers.',
|
||||
tagsGranted: ['command-layer'],
|
||||
insightReward: 1
|
||||
},
|
||||
{
|
||||
key: 'B',
|
||||
label: 'Make World.setComponent() itself serializable',
|
||||
hint: 'Log every World mutation as a serializable operation. The World IS the command system.',
|
||||
icon: 'sidepanel-b',
|
||||
rating: 'ok',
|
||||
feedback:
|
||||
'This conflates the store with the command layer. Every internal implementation detail becomes part of the public API. Batch operations like Paste become dozens of logged mutations instead of one intent.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
},
|
||||
{
|
||||
key: 'C',
|
||||
label: 'Skip commands \u2014 let callers mutate directly',
|
||||
hint: 'External code calls world.setComponent() directly. Simpler. No ceremony.',
|
||||
icon: 'sidepanel-c',
|
||||
rating: 'bad',
|
||||
feedback:
|
||||
'Without a command layer, there is no undo/redo log, no replay, no CRDT sync, and no way to audit what changed. Every caller becomes responsible for transaction management.',
|
||||
tagsGranted: [],
|
||||
insightReward: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
apps/architecture-adventure/src/data/graph.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { rooms } from './rooms'
|
||||
|
||||
interface GraphEdge {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export const edges: GraphEdge[] = Object.values(rooms).flatMap((room) =>
|
||||
room.connections.map((conn) => ({
|
||||
from: room.id,
|
||||
to: conn.targetRoomId
|
||||
}))
|
||||
)
|
||||
194
apps/architecture-adventure/src/data/narrative.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
ChallengeRating,
|
||||
NarrativeBridge,
|
||||
NarrativeSection,
|
||||
NarrativeSentence
|
||||
} from '@/types'
|
||||
|
||||
const sentences: NarrativeSentence[] = [
|
||||
{
|
||||
challengeId: 'circular-dependency',
|
||||
good: 'The circular dependency between Subgraph and LGraph dissolved completely. Composition replaced inheritance, and the flat World made special cases unnecessary.',
|
||||
ok: 'A factory injection broke the import cycle, but the classes remain coupled at runtime. The next refactor will revisit this tension.',
|
||||
bad: 'The circular dependency was papered over with barrel file reordering. It lurks beneath the surface, waiting for the next import to revive the cycle.'
|
||||
},
|
||||
{
|
||||
challengeId: 'scattered-mutations',
|
||||
good: 'All 19 scattered version increments were centralized into a single auditable method. Change tracking became reliable overnight.',
|
||||
ok: 'A JavaScript Proxy intercepts version mutations, but the scattered increment sites remain in the code. Debugging has become more opaque.',
|
||||
bad: 'The 19 scattered graph._version++ sites were left untouched. Silent data loss continues to haunt the team with every missed increment.'
|
||||
},
|
||||
{
|
||||
challengeId: 'migration-question',
|
||||
good: 'A 5-phase incremental migration plan was adopted. Each phase ships independently, and production never breaks during the transition.',
|
||||
ok: 'The strangler fig pattern lets new ECS code grow beside the old, but without clear milestones the migration drifts without a timeline.',
|
||||
bad: 'A big-bang rewrite was attempted. Feature freeze dragged on for months, morale collapsed, and the old codebase drifted beyond reconciliation.'
|
||||
},
|
||||
{
|
||||
challengeId: 'god-object-dilemma',
|
||||
good: 'The god objects are being dismantled incrementally. Position extraction shipped first, then connectivity. Each PR is small and testable.',
|
||||
ok: 'A facade wraps the god objects with a cleaner API, but the 9,100-line monolith still lurks behind it. New features still require diving in.',
|
||||
bad: 'The heroic rewrite stalled at month three. The team burned out reimplementing edge cases that the god objects handled implicitly.'
|
||||
},
|
||||
{
|
||||
challengeId: 'id-crossroads',
|
||||
good: 'Branded entity IDs now catch cross-kind bugs at compile time. Cast helpers at system boundaries keep ergonomics clean.',
|
||||
ok: 'Runtime string prefixes catch some ID mix-ups, but parsing overhead spreads everywhere and hot-path checks are occasionally forgotten.',
|
||||
bad: 'Plain untyped numbers remain the norm. A LinkId passed to a node lookup caused a silent failure that took two days to debug.'
|
||||
},
|
||||
{
|
||||
challengeId: 'widget-promotion',
|
||||
good: 'Widget promotion was unified with the connection system. Adding a typed interface input is all it takes \u2014 no parallel state, no shadow copies.',
|
||||
ok: 'A simplified WidgetPromotion component replaced the ViewManager, but promotion remains a concept separate from connections.',
|
||||
bad: 'The three-layer promotion system persists. Every promoted widget is a shadow copy reconciled by a diffing layer the ECS must work around.'
|
||||
},
|
||||
{
|
||||
challengeId: 'render-time-mutation',
|
||||
good: 'Update and render phases are now fully separated. The render pass is read-only, and draw order no longer affects layout.',
|
||||
ok: 'Dirty flags reduced the worst render-time mutation symptoms, but the render pass still has permission to mutate state.',
|
||||
bad: 'Render-time mutations continue unchecked. Node positions depend on draw order, and every new node type risks layout-dependent bugs.'
|
||||
},
|
||||
{
|
||||
challengeId: 'collaboration-protocol',
|
||||
good: 'Y.js CRDTs back the layout store. Concurrent edits merge automatically, and real-time collaboration is now a reality.',
|
||||
ok: 'Collaboration was deferred to focus on other priorities. The cloud product team awaits, but the architecture is ready when the time comes.',
|
||||
bad: 'Polling-based sync was implemented. Users experience flickering, lag, and silently lost edits. Support tickets pile up.'
|
||||
},
|
||||
{
|
||||
challengeId: 'mutation-gateway',
|
||||
good: 'The command layer is in place: serializable intent flows through systems into the World. Undo/redo, replay, and CRDT sync all work.',
|
||||
ok: 'World mutations are logged as serializable operations, but the store and command layer are conflated. Batch operations produce excessive noise.',
|
||||
bad: 'Without a command layer, callers mutate the World directly. There is no undo/redo, no replay, and no audit trail.'
|
||||
}
|
||||
]
|
||||
|
||||
const sections: NarrativeSection[] = [
|
||||
{
|
||||
id: 'legacy',
|
||||
title: 'The Legacy',
|
||||
challengeIds: [
|
||||
'circular-dependency',
|
||||
'god-object-dilemma',
|
||||
'scattered-mutations'
|
||||
],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The legacy codebase has been thoroughly understood and its worst patterns addressed.',
|
||||
mixed:
|
||||
'Some legacy patterns were addressed, while others remain embedded in the architecture.',
|
||||
pessimistic:
|
||||
'The legacy codebase retains most of its original pain points, resisting transformation.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
title: 'The Architecture',
|
||||
challengeIds: ['id-crossroads', 'mutation-gateway', 'render-time-mutation'],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The new architecture stands on solid foundations \u2014 type-safe, layered, and deterministic.',
|
||||
mixed:
|
||||
'The architectural vision is partially realized. Some foundations are strong, others compromise.',
|
||||
pessimistic:
|
||||
'The architectural redesign never fully materialized. Old and new patterns clash at every boundary.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
title: 'The Future',
|
||||
challengeIds: [
|
||||
'migration-question',
|
||||
'collaboration-protocol',
|
||||
'widget-promotion'
|
||||
],
|
||||
introByTone: {
|
||||
optimistic:
|
||||
'The path forward is clear. Migration proceeds in phases, collaboration is live, and the ECS world hums with clean data.',
|
||||
mixed:
|
||||
'The future is promising but uncertain. Some migration paths are clear while others remain open questions.',
|
||||
pessimistic:
|
||||
'The migration stalls. Technical debt compounds, and the team struggles to chart a path through the complexity.'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const bridges: NarrativeBridge[] = [
|
||||
{
|
||||
fromSectionId: 'legacy',
|
||||
toSectionId: 'architecture',
|
||||
byTone: {
|
||||
optimistic:
|
||||
'With the legacy pain points addressed, the team turned to building the new architecture with confidence.',
|
||||
mixed:
|
||||
'Despite unresolved legacy issues, the team pressed forward with architectural decisions.',
|
||||
pessimistic:
|
||||
'The unaddressed legacy problems cast a long shadow over every architectural decision that followed.'
|
||||
}
|
||||
},
|
||||
{
|
||||
fromSectionId: 'architecture',
|
||||
toSectionId: 'future',
|
||||
byTone: {
|
||||
optimistic:
|
||||
'The solid architectural foundations enabled ambitious plans for migration and collaboration.',
|
||||
mixed:
|
||||
'With a mixed architectural foundation, the team faced the future with cautious optimism.',
|
||||
pessimistic:
|
||||
'Weak architectural foundations made every forward-looking decision feel like building on sand.'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function getSentenceMap(): Map<string, NarrativeSentence> {
|
||||
return new Map(sentences.map((s) => [s.challengeId, s]))
|
||||
}
|
||||
|
||||
type Tone = 'optimistic' | 'mixed' | 'pessimistic'
|
||||
|
||||
function sectionTone(
|
||||
results: Record<string, { rating: ChallengeRating }>,
|
||||
challengeIds: string[]
|
||||
): Tone {
|
||||
const ratings = challengeIds.map((id) => results[id]?.rating).filter(Boolean)
|
||||
if (ratings.length === 0) return 'mixed'
|
||||
|
||||
const goodCount = ratings.filter((r) => r === 'good').length
|
||||
const badCount = ratings.filter((r) => r === 'bad').length
|
||||
|
||||
if (goodCount >= ratings.length * 0.6) return 'optimistic'
|
||||
if (badCount >= ratings.length * 0.6) return 'pessimistic'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
export function buildNarrativeSummary(
|
||||
results: Record<string, { rating: ChallengeRating }>
|
||||
): string {
|
||||
const sentenceMap = getSentenceMap()
|
||||
const parts: string[] = []
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i]
|
||||
const tone = sectionTone(results, section.challengeIds)
|
||||
|
||||
parts.push(section.introByTone[tone])
|
||||
|
||||
for (const challengeId of section.challengeIds) {
|
||||
const sentence = sentenceMap.get(challengeId)
|
||||
const result = results[challengeId]
|
||||
if (sentence && result) {
|
||||
parts.push(sentence[result.rating])
|
||||
}
|
||||
}
|
||||
|
||||
if (i < bridges.length) {
|
||||
const bridge = bridges[i]
|
||||
const nextSection = sections[i + 1]
|
||||
const bridgeTone = nextSection
|
||||
? sectionTone(results, nextSection.challengeIds)
|
||||
: tone
|
||||
parts.push(bridge.byTone[bridgeTone])
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
430
apps/architecture-adventure/src/data/rooms.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import type { RoomDefinition } from '@/types'
|
||||
|
||||
const GH = 'https://github.com/Comfy-Org/ComfyUI_frontend/blob/main'
|
||||
|
||||
export const rooms: Record<string, RoomDefinition> = {
|
||||
entry: {
|
||||
id: 'entry',
|
||||
title: 'The Entry Point',
|
||||
layer: 'src/main.ts',
|
||||
discoveryDescription:
|
||||
`You stand at ${GH}/src/main.ts, the entry point of the ComfyUI frontend. ` +
|
||||
'The air hums with the bootstrapping of a Vue 3 application. Pinia stores ' +
|
||||
'initialize around you, the router unfurls paths into the distance, and ' +
|
||||
'i18n translations whisper in dozens of languages. ' +
|
||||
'Three corridors stretch ahead, each leading deeper into the architecture. ' +
|
||||
'Somewhere in this codebase, god objects lurk, mutations scatter in the shadows, ' +
|
||||
'and a grand migration awaits your decisions.',
|
||||
solutionDescription: '',
|
||||
prerequisites: [],
|
||||
artifacts: [],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Enter the Component Gallery',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'services',
|
||||
label: 'Follow the wires to Services',
|
||||
hint: 'Business Logic'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
components: {
|
||||
id: 'components',
|
||||
title: 'The Component Gallery',
|
||||
layer: 'Presentation',
|
||||
discoveryDescription:
|
||||
'Vast halls lined with Vue Single File Components. GraphView.vue dominates the center \u2014 ' +
|
||||
'the main canvas workspace where nodes are wired together. But a tangled knot blocks ' +
|
||||
'the corridor ahead: Subgraph extends LGraph, and LGraph creates Subgraph instances. ' +
|
||||
'The circular import forces order-dependent barrel exports and makes testing impossible ' +
|
||||
'in isolation.',
|
||||
solutionDescription:
|
||||
'The circular dependency dissolves when you realize a subgraph is just a node ' +
|
||||
'carrying a SubgraphStructure component. Composition replaces inheritance, and the ' +
|
||||
'flat World eliminates special cases entirely.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{ name: 'GraphView.vue', type: 'Component', icon: 'graphview' }
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'litegraph',
|
||||
label: 'Inspect the Canvas',
|
||||
hint: 'Litegraph Engine'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'sidepanel',
|
||||
label: 'Enter the Command Forge',
|
||||
hint: 'Commands & Intent'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'circular-dependency'
|
||||
},
|
||||
|
||||
stores: {
|
||||
id: 'stores',
|
||||
title: 'The Store Vaults',
|
||||
layer: 'State',
|
||||
discoveryDescription:
|
||||
'Sixty Pinia stores line the walls like vault doors, each guarding a domain of reactive state. ' +
|
||||
'Deep in the vaults, you find a fragile counter: graph._version++. It appears in 19 locations ' +
|
||||
'across 7 files \u2014 LGraph.ts, LGraphNode.ts, LGraphCanvas.ts, BaseWidget.ts, SubgraphInput.ts, ' +
|
||||
'SubgraphInputNode.ts, SubgraphOutput.ts. Change tracking depends on this scattered increment. ' +
|
||||
'One missed site means silent data loss.',
|
||||
solutionDescription:
|
||||
'Centralizing all 19 increment sites into a single graph.incrementVersion() method makes ' +
|
||||
'change tracking auditable. The VersionSystem gains a single hook point, and Phase 0a ' +
|
||||
'of the migration plan is complete.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'widgetValueStore.ts',
|
||||
type: 'Proto-ECS Store',
|
||||
icon: 'widgetvaluestore'
|
||||
},
|
||||
{
|
||||
name: 'layoutStore.ts',
|
||||
type: 'Proto-ECS Store',
|
||||
icon: 'layoutstore'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'renderer',
|
||||
label: 'Visit the Renderer',
|
||||
hint: 'Canvas & Layout'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'scattered-mutations'
|
||||
},
|
||||
|
||||
services: {
|
||||
id: 'services',
|
||||
title: 'The Service Corridors',
|
||||
layer: 'Services',
|
||||
discoveryDescription:
|
||||
'Clean corridors of orchestration logic. litegraphService.ts manages graph creation and ' +
|
||||
'serialization. extensionService.ts loads third-party extensions. But a fork in the corridor ' +
|
||||
'reveals the core tension: the legacy litegraph engine works \u2014 thousands of users depend on ' +
|
||||
'it daily \u2014 yet the architecture docs describe a better future with ECS, branded types, and ' +
|
||||
'a World registry. How do you get from here to there without breaking production?',
|
||||
solutionDescription:
|
||||
'A 5-phase incremental migration plan maps the path forward. Each phase is independently ' +
|
||||
'testable and shippable. Old and new coexist during transition. Production never breaks.',
|
||||
prerequisites: [],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'litegraphService.ts',
|
||||
type: 'Service',
|
||||
icon: 'litegraphservice'
|
||||
},
|
||||
{
|
||||
name: 'Extension Migration Guide',
|
||||
type: 'Design Pattern',
|
||||
icon: 'extension-migration'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'composables',
|
||||
label: 'Follow the Composables',
|
||||
hint: 'Reusable Logic Hooks'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'migration-question'
|
||||
},
|
||||
|
||||
litegraph: {
|
||||
id: 'litegraph',
|
||||
title: 'The Litegraph Engine Room',
|
||||
layer: 'Graph Engine',
|
||||
discoveryDescription:
|
||||
"The beating heart of ComfyUI's visual programming. Massive class files loom: " +
|
||||
'LGraphCanvas.ts at ~9,100 lines handles all rendering and interaction, ' +
|
||||
'LGraphNode.ts at ~4,300 lines is the god-object node entity, and ' +
|
||||
'LGraph.ts at ~3,100 lines contains the graph itself. ' +
|
||||
'These god objects are the root of most architectural pain \u2014 circular dependencies, ' +
|
||||
'render-time side effects, and scattered mutation sites.',
|
||||
solutionDescription:
|
||||
'Incremental extraction peels responsibilities into focused modules one at a time. ' +
|
||||
'Position extraction lands first (already in LayoutStore), then connectivity. ' +
|
||||
'Each extraction is a small, testable PR. The god objects shrink steadily.',
|
||||
prerequisites: ['composition'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'LGraphCanvas.ts',
|
||||
type: 'God Object',
|
||||
icon: 'lgraphcanvas'
|
||||
},
|
||||
{ name: 'LGraphNode.ts', type: 'God Object', icon: 'lgraphnode' }
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'The planned future'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Return to Components',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'god-object-dilemma'
|
||||
},
|
||||
|
||||
ecs: {
|
||||
id: 'ecs',
|
||||
title: "The ECS Architect's Chamber",
|
||||
layer: 'ECS',
|
||||
discoveryDescription:
|
||||
'Blueprints cover every surface. The Entity-Component-System architecture is taking shape: ' +
|
||||
'six entity kinds \u2014 Node, Link, Widget, Slot, Reroute, Group \u2014 each identified by ' +
|
||||
'untyped IDs. NodeId is typed as number | string. Nothing prevents passing a LinkId where ' +
|
||||
'a NodeId is expected. Widgets are identified by name + parent node (fragile lookup). ' +
|
||||
'Slots are identified by array index (breaks when reordered). The six entity kinds all ' +
|
||||
'share the same untyped ID space.',
|
||||
solutionDescription:
|
||||
'Branded types with cast helpers bring compile-time safety at zero runtime cost. ' +
|
||||
'type NodeEntityId = number & { __brand: "NodeEntityId" }. Cast helpers at system ' +
|
||||
'boundaries keep ergonomics clean. Phase 1a is complete.',
|
||||
prerequisites: ['centralized-mutations'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'World Registry',
|
||||
type: 'ECS Core',
|
||||
icon: 'world-registry'
|
||||
},
|
||||
{
|
||||
name: 'Branded Entity IDs',
|
||||
type: 'Type Safety',
|
||||
icon: 'branded-ids'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'subgraph',
|
||||
label: 'Descend into the Subgraph Depths',
|
||||
hint: 'Boundaries & Promotion'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'renderer',
|
||||
label: 'Visit the Renderer',
|
||||
hint: 'Canvas & Layout'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'id-crossroads'
|
||||
},
|
||||
|
||||
sidepanel: {
|
||||
id: 'sidepanel',
|
||||
title: 'The Command Forge',
|
||||
layer: 'Commands & Intent',
|
||||
discoveryDescription:
|
||||
'You enter a forge where raw user intent is shaped into structured commands. ' +
|
||||
"A heated debate blocks the forge entrance. One faction argues the World's imperative " +
|
||||
'API (world.setComponent()) conflicts with the command pattern requirement from ADR 0003. ' +
|
||||
'Another faction says commands and the World serve different layers. ' +
|
||||
'How should external callers mutate the World?',
|
||||
solutionDescription:
|
||||
'Commands are serializable intent. Systems are command handlers. The World is the store \u2014 ' +
|
||||
"its imperative API is internal, just like Redux's state mutations inside reducers. " +
|
||||
'ADR 0003 and ADR 0008 are complementary layers.',
|
||||
prerequisites: ['branded-types'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'CommandExecutor',
|
||||
type: 'ECS Core',
|
||||
icon: 'command-executor'
|
||||
},
|
||||
{
|
||||
name: 'Command Interface',
|
||||
type: 'Design Pattern',
|
||||
icon: 'command-interface'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'components',
|
||||
label: 'Return to the Component Gallery',
|
||||
hint: 'Presentation Layer'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'mutation-gateway'
|
||||
},
|
||||
|
||||
subgraph: {
|
||||
id: 'subgraph',
|
||||
title: 'The Subgraph Depths',
|
||||
layer: 'Graph Boundaries',
|
||||
discoveryDescription:
|
||||
'You descend into nested chambers, each a perfect replica of the one above \u2014 graphs ' +
|
||||
'within graphs within graphs. The current code tells a painful story: Subgraph extends LGraph, ' +
|
||||
'virtual nodes with magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20), and three ' +
|
||||
'layers of indirection at every boundary crossing. Widget promotion requires PromotionStore, ' +
|
||||
'PromotedWidgetViewManager, and PromotedWidgetView \u2014 a parallel state system duplicating ' +
|
||||
'what the type-to-widget mapping already handles.',
|
||||
solutionDescription:
|
||||
"Under graph unification, promotion becomes an operation on the subgraph's function signature. " +
|
||||
'Promote a widget by adding an interface input. The type-to-widget mapping creates the widget ' +
|
||||
'automatically. PromotionStore, ViewManager, and PromotedWidgetView are eliminated entirely.',
|
||||
prerequisites: ['branded-types', 'composition'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'SubgraphStructure',
|
||||
type: 'ECS Component',
|
||||
icon: 'subgraph-structure'
|
||||
},
|
||||
{
|
||||
name: 'Typed Interface Contracts',
|
||||
type: 'Design Pattern',
|
||||
icon: 'typed-contracts'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Return to the ECS Chamber',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'litegraph',
|
||||
label: 'Visit the Litegraph Engine Room',
|
||||
hint: 'Graph Engine'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'widget-promotion'
|
||||
},
|
||||
|
||||
renderer: {
|
||||
id: 'renderer',
|
||||
title: 'The Renderer Overlook',
|
||||
layer: 'Renderer',
|
||||
discoveryDescription:
|
||||
'From here you can see the entire canvas rendering pipeline. But alarms sound: ' +
|
||||
'drawNode() calls _setConcreteSlots() and arrange() during the render pass. ' +
|
||||
'The render phase mutates state, making draw order affect layout. ' +
|
||||
"Node A's position depends on whether Node B was drawn first. " +
|
||||
'This is a critical pipeline flaw.',
|
||||
solutionDescription:
|
||||
'Separating update and render phases fixes the pipeline: Input \u2192 Update (layout, connectivity) ' +
|
||||
'\u2192 Render (read-only). Draw order no longer matters. The ECS system pipeline enforces ' +
|
||||
'this separation structurally.',
|
||||
prerequisites: ['responsibility-extraction'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'QuadTree Spatial Index',
|
||||
type: 'Data Structure',
|
||||
icon: 'quadtree'
|
||||
},
|
||||
{
|
||||
name: 'Y.js CRDT Layout',
|
||||
type: 'Collaboration',
|
||||
icon: 'yjs-crdt'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'ecs',
|
||||
label: 'Examine the ECS Blueprints',
|
||||
hint: 'Entity-Component-System'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'render-time-mutation'
|
||||
},
|
||||
|
||||
composables: {
|
||||
id: 'composables',
|
||||
title: 'The Composables Workshop',
|
||||
layer: 'Composables',
|
||||
discoveryDescription:
|
||||
'Hooks hang from the walls, each a reusable piece of Vue composition logic. ' +
|
||||
'useCoreCommands.ts is the largest at 42KB \u2014 an orchestrator binding keyboard ' +
|
||||
'shortcuts to application commands. A request arrives: multiple users want to edit ' +
|
||||
'the same workflow simultaneously. The layoutStore already extracts position data ' +
|
||||
'from litegraph entities. But how do you synchronize positions across users without conflicts?',
|
||||
solutionDescription:
|
||||
'Y.js CRDTs back the layout store. Concurrent edits merge automatically without coordination. ' +
|
||||
'ADR 0003 is realized. The collaboration future is here.',
|
||||
prerequisites: ['incremental-migration'],
|
||||
artifacts: [
|
||||
{
|
||||
name: 'useCoreCommands.ts',
|
||||
type: 'Composable',
|
||||
icon: 'usecorecommands'
|
||||
}
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetRoomId: 'stores',
|
||||
label: 'Descend into the Store Vaults',
|
||||
hint: 'State Management'
|
||||
},
|
||||
{
|
||||
targetRoomId: 'entry',
|
||||
label: 'Return to the Entry Point',
|
||||
hint: 'src/main.ts'
|
||||
}
|
||||
],
|
||||
challengeId: 'collaboration-protocol'
|
||||
}
|
||||
}
|
||||
15
apps/architecture-adventure/src/engine/navigation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { SaveState } from '@/types'
|
||||
|
||||
function isRoomDiscovered(roomId: string, save: SaveState): boolean {
|
||||
return save.currentRun.path.includes(roomId)
|
||||
}
|
||||
|
||||
function isChallengeResolved(challengeId: string, save: SaveState): boolean {
|
||||
return challengeId in save.currentRun.resolvedChallenges
|
||||
}
|
||||
|
||||
function countResolvedChallenges(save: SaveState): number {
|
||||
return Object.keys(save.currentRun.resolvedChallenges).length
|
||||
}
|
||||
|
||||
export { countResolvedChallenges, isChallengeResolved, isRoomDiscovered }
|
||||
107
apps/architecture-adventure/src/engine/stateMachine.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
ChallengeDefinition,
|
||||
ChallengeResult,
|
||||
GamePhase,
|
||||
GameState,
|
||||
SaveState
|
||||
} from '@/types'
|
||||
import { persistSave } from '@/state/gameState'
|
||||
import { grantTags } from '@/state/tags'
|
||||
|
||||
type GameEventHandler = (state: GameState) => void
|
||||
|
||||
let currentState: GameState
|
||||
let listeners: GameEventHandler[] = []
|
||||
|
||||
function initGameState(save: SaveState): void {
|
||||
currentState = {
|
||||
phase: 'exploring',
|
||||
save
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
function subscribe(handler: GameEventHandler): () => void {
|
||||
listeners.push(handler)
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== handler)
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
listener(currentState)
|
||||
}
|
||||
}
|
||||
|
||||
function transition(phase: GamePhase, saveUpdates?: Partial<SaveState>): void {
|
||||
const newSave = saveUpdates
|
||||
? { ...currentState.save, ...saveUpdates }
|
||||
: currentState.save
|
||||
|
||||
currentState = { phase, save: newSave }
|
||||
persistSave(currentState.save)
|
||||
notify()
|
||||
}
|
||||
|
||||
function enterRoom(roomId: string): void {
|
||||
const run = currentState.save.currentRun
|
||||
const newPath = run.path.includes(roomId) ? run.path : [...run.path, roomId]
|
||||
|
||||
transition('exploring', {
|
||||
currentRun: {
|
||||
...run,
|
||||
currentRoom: roomId,
|
||||
path: newPath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveChallenge(
|
||||
challenge: ChallengeDefinition,
|
||||
choiceKey: string
|
||||
): void {
|
||||
const choice = challenge.choices.find((c) => c.key === choiceKey)
|
||||
if (!choice) return
|
||||
|
||||
const result: ChallengeResult = {
|
||||
choiceKey,
|
||||
rating: choice.rating,
|
||||
tier: challenge.tier
|
||||
}
|
||||
|
||||
let save = {
|
||||
...currentState.save,
|
||||
currentRun: {
|
||||
...currentState.save.currentRun,
|
||||
resolvedChallenges: {
|
||||
...currentState.save.currentRun.resolvedChallenges,
|
||||
[challenge.id]: result
|
||||
},
|
||||
insightEarned:
|
||||
currentState.save.currentRun.insightEarned + choice.insightReward
|
||||
}
|
||||
}
|
||||
|
||||
save = grantTags(save, challenge.tagsGranted)
|
||||
save = grantTags(save, choice.tagsGranted)
|
||||
|
||||
transition('challenge-resolved', save)
|
||||
}
|
||||
|
||||
function showEnding(): void {
|
||||
transition('ending')
|
||||
}
|
||||
|
||||
function resetForPrestige(newSave: SaveState): void {
|
||||
transition('exploring', newSave)
|
||||
}
|
||||
|
||||
export {
|
||||
enterRoom,
|
||||
initGameState,
|
||||
resetForPrestige,
|
||||
resolveChallenge,
|
||||
showEnding,
|
||||
subscribe
|
||||
}
|
||||
26
apps/architecture-adventure/src/main.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import './style/theme.css'
|
||||
import './style/layout.css'
|
||||
import './style/hud.css'
|
||||
import './style/room.css'
|
||||
import './style/challenge.css'
|
||||
import './style/sidebar.css'
|
||||
import './style/map.css'
|
||||
import './style/animations.css'
|
||||
|
||||
import { isV1Save, loadSave } from '@/state/gameState'
|
||||
import { enterRoom, initGameState, subscribe } from '@/engine/stateMachine'
|
||||
import { mountApp, render } from '@/ui/renderer'
|
||||
|
||||
function main(): void {
|
||||
if (isV1Save()) {
|
||||
console.warn('Codebase Caverns v1 save detected. Starting fresh for v2.')
|
||||
}
|
||||
|
||||
const save = loadSave()
|
||||
mountApp()
|
||||
initGameState(save)
|
||||
subscribe(render)
|
||||
enterRoom(save.currentRun.currentRoom)
|
||||
}
|
||||
|
||||
main()
|
||||
67
apps/architecture-adventure/src/state/gameState.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { CurrentRun, Layer, SaveState } from '@/types'
|
||||
|
||||
const STORAGE_KEY = 'codebase-caverns-v2'
|
||||
const SAVE_VERSION = 1
|
||||
|
||||
function createFreshRun(layer: Layer): CurrentRun {
|
||||
return {
|
||||
layer,
|
||||
path: [],
|
||||
resolvedChallenges: {},
|
||||
conceptTags: [],
|
||||
insightEarned: 0,
|
||||
currentRoom: 'entry'
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultSave(): SaveState {
|
||||
return {
|
||||
version: SAVE_VERSION,
|
||||
currentRun: createFreshRun(1),
|
||||
history: [],
|
||||
persistent: {
|
||||
totalInsight: 0,
|
||||
currentLayer: 1,
|
||||
achievements: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSave(): SaveState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return createDefaultSave()
|
||||
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'version' in parsed &&
|
||||
(parsed as SaveState).version === SAVE_VERSION
|
||||
) {
|
||||
return parsed as SaveState
|
||||
}
|
||||
return createDefaultSave()
|
||||
} catch {
|
||||
return createDefaultSave()
|
||||
}
|
||||
}
|
||||
|
||||
function persistSave(save: SaveState): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(save))
|
||||
}
|
||||
|
||||
function clearSave(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function isV1Save(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem('codebase-caverns')
|
||||
return raw !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { clearSave, createFreshRun, isV1Save, loadSave, persistSave }
|
||||
36
apps/architecture-adventure/src/state/prestige.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Layer, RunRecord, SaveState } from '@/types'
|
||||
import { createFreshRun } from '@/state/gameState'
|
||||
|
||||
function finalizeRun(save: SaveState, narrativeSummary: string): RunRecord {
|
||||
return {
|
||||
layer: save.currentRun.layer,
|
||||
path: save.currentRun.path,
|
||||
challenges: { ...save.currentRun.resolvedChallenges },
|
||||
conceptTags: [...save.currentRun.conceptTags],
|
||||
insightEarned: save.currentRun.insightEarned,
|
||||
narrativeSummary
|
||||
}
|
||||
}
|
||||
|
||||
function canPrestige(save: SaveState): boolean {
|
||||
return save.persistent.currentLayer < 3
|
||||
}
|
||||
|
||||
function prestige(save: SaveState, narrativeSummary: string): SaveState {
|
||||
const record = finalizeRun(save, narrativeSummary)
|
||||
const nextLayer = Math.min(save.persistent.currentLayer + 1, 3) as Layer
|
||||
|
||||
return {
|
||||
...save,
|
||||
currentRun: createFreshRun(nextLayer),
|
||||
history: [...save.history, record],
|
||||
persistent: {
|
||||
...save.persistent,
|
||||
totalInsight:
|
||||
save.persistent.totalInsight + save.currentRun.insightEarned,
|
||||
currentLayer: nextLayer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { canPrestige, prestige }
|
||||
22
apps/architecture-adventure/src/state/tags.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { RoomDefinition, SaveState } from '@/types'
|
||||
|
||||
function canEnterRoom(room: RoomDefinition, save: SaveState): boolean {
|
||||
return room.prerequisites.every((tag) =>
|
||||
save.currentRun.conceptTags.includes(tag)
|
||||
)
|
||||
}
|
||||
|
||||
function grantTags(save: SaveState, tags: string[]): SaveState {
|
||||
const newTags = tags.filter((t) => !save.currentRun.conceptTags.includes(t))
|
||||
if (newTags.length === 0) return save
|
||||
|
||||
return {
|
||||
...save,
|
||||
currentRun: {
|
||||
...save.currentRun,
|
||||
conceptTags: [...save.currentRun.conceptTags, ...newTags]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { canEnterRoom, grantTags }
|
||||
46
apps/architecture-adventure/src/style/animations.css
Normal file
@@ -0,0 +1,46 @@
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes unlockPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(88 166 255 / 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgb(88 166 255 / 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(88 166 255 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nodeUnlock {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.map-node.newly-unlocked circle {
|
||||
animation: unlockPulse 0.6s ease-out;
|
||||
}
|
||||
|
||||
.map-node {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
208
apps/architecture-adventure/src/style/challenge.css
Normal file
@@ -0,0 +1,208 @@
|
||||
#challenge-panel {
|
||||
border: 2px solid var(--yellow);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
#challenge-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#challenge-header {
|
||||
background: rgb(210 153 34 / 0.1);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--yellow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#challenge-header .icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#challenge-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
#challenge-desc {
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#challenge-desc code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#challenge-desc a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
#challenge-desc a:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
#challenge-choices {
|
||||
padding: 8px 16px 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn {
|
||||
flex: 1;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.challenge-choice-btn:hover {
|
||||
border-color: var(--yellow);
|
||||
background: rgb(210 153 34 / 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon-wrap {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-key {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: var(--yellow);
|
||||
color: var(--bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-text {
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.challenge-choice-btn .choice-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#result-banner {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
display: none;
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#result-banner.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#result-banner.good {
|
||||
border: 1px solid var(--green);
|
||||
background: rgb(63 185 80 / 0.08);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
#result-banner.ok {
|
||||
border: 1px solid var(--yellow);
|
||||
background: rgb(210 153 34 / 0.08);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
#result-banner.bad {
|
||||
border: 1px solid var(--red);
|
||||
background: rgb(248 81 73 / 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stat-delta {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-delta.positive {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.stat-delta.negative {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.result-recommended {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgb(88 166 255 / 0.06);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-recommended strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.result-doc-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.result-doc-link:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
92
apps/architecture-adventure/src/style/hud.css
Normal file
@@ -0,0 +1,92 @@
|
||||
#hud,
|
||||
.choice-key,
|
||||
.sidebar-header,
|
||||
#room-layer,
|
||||
#challenge-header,
|
||||
#toggle-map,
|
||||
.choice-btn .choice-hint,
|
||||
.challenge-choice-btn .choice-hint {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#hud h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#hud-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#restart-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#restart-btn:hover {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
#toggle-map {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#toggle-map:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#hud {
|
||||
padding: 6px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#hud h1 {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#hud-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#restart-btn,
|
||||
#toggle-map {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
38
apps/architecture-adventure/src/style/layout.css
Normal file
@@ -0,0 +1,38 @@
|
||||
#main {
|
||||
display: flex;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#main {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
103
apps/architecture-adventure/src/style/map.css
Normal file
@@ -0,0 +1,103 @@
|
||||
#map-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
color: var(--text);
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 0.6);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
overlay 0.2s ease allow-discrete,
|
||||
display 0.2s ease allow-discrete;
|
||||
}
|
||||
|
||||
#map-dialog[open] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#map-dialog[open] {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
#map-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
overlay 0.2s ease allow-discrete,
|
||||
display 0.2s ease allow-discrete;
|
||||
}
|
||||
|
||||
#map-dialog[open]::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#map-dialog[open]::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#map-dialog h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.map-node circle {
|
||||
transition:
|
||||
fill 0.3s ease,
|
||||
stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.map-node.locked circle {
|
||||
fill: var(--bg);
|
||||
stroke: var(--border);
|
||||
}
|
||||
|
||||
.map-node.visited circle {
|
||||
fill: var(--surface);
|
||||
stroke: var(--green);
|
||||
}
|
||||
|
||||
.map-node.current circle {
|
||||
fill: var(--accent-dim);
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.map-edge {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.map-label {
|
||||
fill: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.map-title {
|
||||
fill: var(--muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.map-badge {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.map-lock {
|
||||
font-size: 12px;
|
||||
}
|
||||
118
apps/architecture-adventure/src/style/room.css
Normal file
@@ -0,0 +1,118 @@
|
||||
#room-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
#room-header h2 {
|
||||
font-size: 26px;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#room-layer {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.room-image {
|
||||
aspect-ratio: 21 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.room-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.room-image.placeholder {
|
||||
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 50%, #161b22 100%);
|
||||
border-style: dashed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#room-description {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#room-description code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#room-description a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
#room-description a:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
#room-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.choice-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.choice-btn .choice-key {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.choice-btn .choice-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.choice-btn .choice-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
41
apps/architecture-adventure/src/style/sidebar.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.sidebar-header {
|
||||
background: var(--surface);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-entry.discovery {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.log-entry.ending {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
77
apps/architecture-adventure/src/style/theme.css
Normal file
@@ -0,0 +1,77 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #9ea7b0;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb33;
|
||||
--green: #3fb950;
|
||||
--yellow: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: auto;
|
||||
}
|
||||
146
apps/architecture-adventure/src/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// --- Enumerations ---
|
||||
|
||||
type Layer = 1 | 2 | 3
|
||||
|
||||
type ChallengeRating = 'good' | 'ok' | 'bad'
|
||||
|
||||
type GamePhase =
|
||||
| 'exploring'
|
||||
| 'challenge-available'
|
||||
| 'challenge-resolved'
|
||||
| 'ending'
|
||||
| 'prestige'
|
||||
|
||||
// --- Room & Challenge Data ---
|
||||
|
||||
interface RoomConnection {
|
||||
targetRoomId: string
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
name: string
|
||||
type: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface RoomDefinition {
|
||||
id: string
|
||||
title: string
|
||||
layer: string
|
||||
discoveryDescription: string
|
||||
solutionDescription: string
|
||||
prerequisites: string[]
|
||||
artifacts: Artifact[]
|
||||
connections: RoomConnection[]
|
||||
challengeId?: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
interface ChallengeChoice {
|
||||
key: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: string
|
||||
rating: ChallengeRating
|
||||
feedback: string
|
||||
tagsGranted: string[]
|
||||
insightReward: number
|
||||
}
|
||||
|
||||
interface ChallengeDefinition {
|
||||
id: string
|
||||
roomId: string
|
||||
title: string
|
||||
tier: number
|
||||
description: string
|
||||
recommended: string
|
||||
docLink?: { label: string; url: string }
|
||||
tagsGranted: string[]
|
||||
choices: ChallengeChoice[]
|
||||
}
|
||||
|
||||
// --- Narrative ---
|
||||
|
||||
interface NarrativeSentence {
|
||||
challengeId: string
|
||||
good: string
|
||||
ok: string
|
||||
bad: string
|
||||
}
|
||||
|
||||
interface NarrativeSection {
|
||||
id: string
|
||||
title: string
|
||||
challengeIds: string[]
|
||||
introByTone: { optimistic: string; mixed: string; pessimistic: string }
|
||||
}
|
||||
|
||||
interface NarrativeBridge {
|
||||
fromSectionId: string
|
||||
toSectionId: string
|
||||
byTone: { optimistic: string; mixed: string; pessimistic: string }
|
||||
}
|
||||
|
||||
// --- Save State ---
|
||||
|
||||
interface ChallengeResult {
|
||||
choiceKey: string
|
||||
rating: ChallengeRating
|
||||
tier: number
|
||||
}
|
||||
|
||||
interface RunRecord {
|
||||
layer: Layer
|
||||
path: string[]
|
||||
challenges: Record<string, ChallengeResult>
|
||||
conceptTags: string[]
|
||||
insightEarned: number
|
||||
narrativeSummary: string
|
||||
}
|
||||
|
||||
interface CurrentRun {
|
||||
layer: Layer
|
||||
path: string[]
|
||||
resolvedChallenges: Record<string, ChallengeResult>
|
||||
conceptTags: string[]
|
||||
insightEarned: number
|
||||
currentRoom: string
|
||||
}
|
||||
|
||||
interface PersistentState {
|
||||
totalInsight: number
|
||||
currentLayer: Layer
|
||||
achievements: string[]
|
||||
}
|
||||
|
||||
interface SaveState {
|
||||
version: number
|
||||
currentRun: CurrentRun
|
||||
history: RunRecord[]
|
||||
persistent: PersistentState
|
||||
}
|
||||
|
||||
// --- Engine State ---
|
||||
|
||||
interface GameState {
|
||||
phase: GamePhase
|
||||
save: SaveState
|
||||
}
|
||||
|
||||
export type {
|
||||
ChallengeDefinition,
|
||||
ChallengeRating,
|
||||
ChallengeResult,
|
||||
CurrentRun,
|
||||
GamePhase,
|
||||
GameState,
|
||||
Layer,
|
||||
NarrativeBridge,
|
||||
NarrativeSection,
|
||||
NarrativeSentence,
|
||||
RoomDefinition,
|
||||
RunRecord,
|
||||
SaveState
|
||||
}
|
||||
117
apps/architecture-adventure/src/ui/challengeView.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { ChallengeDefinition, GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved } from '@/engine/navigation'
|
||||
import { resolveChallenge } from '@/engine/stateMachine'
|
||||
|
||||
function renderChallenge(state: GameState): void {
|
||||
const mount = document.getElementById('challenge-mount')
|
||||
if (!mount) return
|
||||
|
||||
mount.innerHTML = ''
|
||||
|
||||
const roomId = state.save.currentRun.currentRoom
|
||||
const room = rooms[roomId]
|
||||
if (!room?.challengeId) return
|
||||
|
||||
const challenge = challenges[room.challengeId]
|
||||
if (!challenge) return
|
||||
|
||||
if (isChallengeResolved(challenge.id, state.save)) {
|
||||
mount.appendChild(renderResultBanner(challenge, state))
|
||||
return
|
||||
}
|
||||
|
||||
mount.appendChild(renderChallengePanel(challenge))
|
||||
}
|
||||
|
||||
function renderChallengePanel(challenge: ChallengeDefinition): HTMLElement {
|
||||
const panel = document.createElement('div')
|
||||
panel.id = 'challenge-panel'
|
||||
panel.className = 'active'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.id = 'challenge-header'
|
||||
header.innerHTML = `
|
||||
<span class="icon">⚡</span>
|
||||
<span id="challenge-title">${challenge.title}</span>
|
||||
`
|
||||
|
||||
const desc = document.createElement('div')
|
||||
desc.id = 'challenge-desc'
|
||||
desc.textContent = challenge.description
|
||||
|
||||
const choicesEl = document.createElement('div')
|
||||
choicesEl.id = 'challenge-choices'
|
||||
|
||||
for (const choice of challenge.choices) {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'challenge-choice-btn'
|
||||
btn.innerHTML = `
|
||||
<div class="choice-icon-wrap">
|
||||
<span class="choice-key">${choice.key}</span>
|
||||
<div class="choice-icon"></div>
|
||||
</div>
|
||||
<div class="choice-text">
|
||||
<span class="choice-label">${choice.label}</span>
|
||||
<span class="choice-hint">${choice.hint}</span>
|
||||
</div>
|
||||
`
|
||||
btn.addEventListener('click', () => resolveChallenge(challenge, choice.key))
|
||||
choicesEl.appendChild(btn)
|
||||
}
|
||||
|
||||
panel.appendChild(header)
|
||||
panel.appendChild(desc)
|
||||
panel.appendChild(choicesEl)
|
||||
return panel
|
||||
}
|
||||
|
||||
function renderResultBanner(
|
||||
challenge: ChallengeDefinition,
|
||||
state: GameState
|
||||
): HTMLElement {
|
||||
const result = state.save.currentRun.resolvedChallenges[challenge.id]
|
||||
const choice = challenge.choices.find((c) => c.key === result?.choiceKey)
|
||||
|
||||
const banner = document.createElement('div')
|
||||
banner.id = 'result-banner'
|
||||
banner.className = `active ${result?.rating ?? ''}`
|
||||
|
||||
const ratingLabel =
|
||||
result?.rating === 'good' ? 'GOOD' : result?.rating === 'ok' ? 'OK' : 'BAD'
|
||||
|
||||
let html = `
|
||||
<strong class="rating-${result?.rating ?? ''}">${ratingLabel}</strong>
|
||||
— ${choice?.feedback ?? ''}
|
||||
`
|
||||
|
||||
if (result?.choiceKey !== challenge.recommended) {
|
||||
const recommended = challenge.choices.find(
|
||||
(c) => c.key === challenge.recommended
|
||||
)
|
||||
if (recommended) {
|
||||
html += `
|
||||
<div class="result-recommended">
|
||||
<strong>Recommended:</strong> ${recommended.label} — ${recommended.hint}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
if (challenge.docLink) {
|
||||
html += `
|
||||
<div style="margin-top:8px">
|
||||
<a class="result-doc-link" href="${challenge.docLink.url}" target="_blank" rel="noopener">
|
||||
${challenge.docLink.label} ↗
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
banner.innerHTML = html
|
||||
return banner
|
||||
}
|
||||
|
||||
export { renderChallenge }
|
||||
73
apps/architecture-adventure/src/ui/endingView.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { buildNarrativeSummary } from '@/data/narrative'
|
||||
import { resetForPrestige } from '@/engine/stateMachine'
|
||||
import { persistSave } from '@/state/gameState'
|
||||
import { canPrestige, prestige } from '@/state/prestige'
|
||||
|
||||
function renderPrestigeSection(state: GameState, summary: string): HTMLElement {
|
||||
const section = document.createElement('div')
|
||||
section.className = 'prestige-section'
|
||||
|
||||
if (canPrestige(state.save)) {
|
||||
const teaser = document.createElement('p')
|
||||
teaser.className = 'prestige-teaser'
|
||||
teaser.textContent =
|
||||
'The architecture breathes. Deeper layers await — more entangled, more instructive. Are you ready to descend?'
|
||||
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'prestige-btn'
|
||||
btn.textContent = 'Descend Deeper'
|
||||
btn.addEventListener('click', () => {
|
||||
const newSave = prestige(state.save, summary)
|
||||
persistSave(newSave)
|
||||
resetForPrestige(newSave)
|
||||
})
|
||||
|
||||
section.appendChild(teaser)
|
||||
section.appendChild(btn)
|
||||
} else {
|
||||
const maxLayer = document.createElement('p')
|
||||
maxLayer.className = 'max-layer-text'
|
||||
maxLayer.textContent = 'You have reached the deepest layer.'
|
||||
section.appendChild(maxLayer)
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
function renderEnding(state: GameState): void {
|
||||
const main = document.getElementById('main')
|
||||
if (!main) return
|
||||
|
||||
const run = state.save.currentRun
|
||||
const summary = buildNarrativeSummary(run.resolvedChallenges)
|
||||
const resolvedCount = Object.keys(run.resolvedChallenges).length
|
||||
const conceptCount = run.conceptTags.length
|
||||
|
||||
main.innerHTML = ''
|
||||
|
||||
const title = document.createElement('h2')
|
||||
title.className = 'ending-title'
|
||||
title.textContent = 'State of the Codebase'
|
||||
|
||||
const narrative = document.createElement('p')
|
||||
narrative.className = 'ending-narrative'
|
||||
narrative.textContent = summary
|
||||
|
||||
const stats = document.createElement('div')
|
||||
stats.className = 'ending-stats'
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><span class="stat-label">Insight Earned</span><span class="stat-value">${run.insightEarned}</span></div>
|
||||
<div class="stat"><span class="stat-label">Challenges Resolved</span><span class="stat-value">${resolvedCount}</span></div>
|
||||
<div class="stat"><span class="stat-label">Concepts Learned</span><span class="stat-value">${conceptCount}</span></div>
|
||||
<div class="stat"><span class="stat-label">Current Layer</span><span class="stat-value">${run.layer}</span></div>
|
||||
`
|
||||
|
||||
main.appendChild(title)
|
||||
main.appendChild(narrative)
|
||||
main.appendChild(stats)
|
||||
main.appendChild(renderPrestigeSection(state, summary))
|
||||
}
|
||||
|
||||
export { renderEnding }
|
||||
43
apps/architecture-adventure/src/ui/hud.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { countResolvedChallenges } from '@/engine/navigation'
|
||||
|
||||
function createHud(): HTMLElement {
|
||||
const hud = document.createElement('header')
|
||||
hud.id = 'hud'
|
||||
hud.innerHTML = `
|
||||
<h1 id="game-title">Codebase Caverns</h1>
|
||||
<div id="hud-right">
|
||||
<div id="hud-insight">
|
||||
<span class="hud-label">Insight</span>
|
||||
<span id="insight-value">0</span>
|
||||
</div>
|
||||
<div id="hud-progress">
|
||||
<span class="hud-label">Challenges</span>
|
||||
<span id="progress-value">0/0</span>
|
||||
</div>
|
||||
<button id="toggle-map" type="button">Map [M]</button>
|
||||
<button id="restart-btn" type="button">Restart</button>
|
||||
</div>
|
||||
`
|
||||
return hud
|
||||
}
|
||||
|
||||
function renderHud(state: GameState): void {
|
||||
const insightEl = document.getElementById('insight-value')
|
||||
const progressEl = document.getElementById('progress-value')
|
||||
|
||||
if (insightEl) {
|
||||
const total =
|
||||
state.save.persistent.totalInsight + state.save.currentRun.insightEarned
|
||||
insightEl.textContent = String(total)
|
||||
}
|
||||
|
||||
if (progressEl) {
|
||||
const resolved = countResolvedChallenges(state.save)
|
||||
const total = Object.keys(challenges).length
|
||||
progressEl.textContent = `${resolved}/${total}`
|
||||
}
|
||||
}
|
||||
|
||||
export { createHud, renderHud }
|
||||
194
apps/architecture-adventure/src/ui/nodeMap.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { edges } from '@/data/graph'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved, isRoomDiscovered } from '@/engine/navigation'
|
||||
import { enterRoom } from '@/engine/stateMachine'
|
||||
import { canEnterRoom } from '@/state/tags'
|
||||
|
||||
interface NodePosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const NODE_POSITIONS: Record<string, NodePosition> = {
|
||||
entry: { x: 300, y: 40 },
|
||||
components: { x: 120, y: 140 },
|
||||
stores: { x: 300, y: 140 },
|
||||
services: { x: 480, y: 140 },
|
||||
litegraph: { x: 60, y: 260 },
|
||||
sidepanel: { x: 180, y: 260 },
|
||||
ecs: { x: 300, y: 260 },
|
||||
renderer: { x: 420, y: 260 },
|
||||
composables: { x: 540, y: 260 },
|
||||
subgraph: { x: 300, y: 370 }
|
||||
}
|
||||
|
||||
const SVG_WIDTH = 600
|
||||
const SVG_HEIGHT = 440
|
||||
const NODE_RADIUS = 28
|
||||
|
||||
function getNodeState(
|
||||
roomId: string,
|
||||
state: GameState
|
||||
): 'locked' | 'visited' | 'current' {
|
||||
if (roomId === state.save.currentRun.currentRoom) return 'current'
|
||||
if (isRoomDiscovered(roomId, state.save)) return 'visited'
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
function createSvgElement<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K
|
||||
): SVGElementTagNameMap[K] {
|
||||
return document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
}
|
||||
|
||||
function buildEdges(): SVGGElement {
|
||||
const g = createSvgElement('g')
|
||||
const drawn = new Set<string>()
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = [edge.from, edge.to].sort().join('--')
|
||||
if (drawn.has(key)) continue
|
||||
drawn.add(key)
|
||||
|
||||
const from = NODE_POSITIONS[edge.from]
|
||||
const to = NODE_POSITIONS[edge.to]
|
||||
if (!from || !to) continue
|
||||
|
||||
const line = createSvgElement('line')
|
||||
line.setAttribute('class', 'map-edge')
|
||||
line.setAttribute('x1', String(from.x))
|
||||
line.setAttribute('y1', String(from.y))
|
||||
line.setAttribute('x2', String(to.x))
|
||||
line.setAttribute('y2', String(to.y))
|
||||
g.appendChild(line)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
function buildNode(
|
||||
roomId: string,
|
||||
state: GameState,
|
||||
onSelect: (id: string) => void
|
||||
): SVGGElement {
|
||||
const room = rooms[roomId]
|
||||
const pos = NODE_POSITIONS[roomId]
|
||||
if (!room || !pos) return createSvgElement('g')
|
||||
|
||||
const nodeState = getNodeState(roomId, state)
|
||||
const accessible = canEnterRoom(room, state.save)
|
||||
|
||||
const g = createSvgElement('g')
|
||||
g.setAttribute('class', `map-node ${nodeState}`)
|
||||
g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`)
|
||||
|
||||
if (accessible && nodeState !== 'locked') {
|
||||
g.style.cursor = 'pointer'
|
||||
g.addEventListener('click', () => onSelect(roomId))
|
||||
}
|
||||
|
||||
const circle = createSvgElement('circle')
|
||||
circle.setAttribute('r', String(NODE_RADIUS))
|
||||
circle.setAttribute('cx', '0')
|
||||
circle.setAttribute('cy', '0')
|
||||
g.appendChild(circle)
|
||||
|
||||
const label = createSvgElement('text')
|
||||
label.setAttribute('class', 'map-label')
|
||||
label.setAttribute('text-anchor', 'middle')
|
||||
label.setAttribute('dominant-baseline', 'middle')
|
||||
label.setAttribute('y', '0')
|
||||
label.textContent = room.id
|
||||
g.appendChild(label)
|
||||
|
||||
const layerLabel = createSvgElement('text')
|
||||
layerLabel.setAttribute('class', 'map-title')
|
||||
layerLabel.setAttribute('text-anchor', 'middle')
|
||||
layerLabel.setAttribute('y', String(NODE_RADIUS + 12))
|
||||
layerLabel.textContent = room.layer
|
||||
g.appendChild(layerLabel)
|
||||
|
||||
if (nodeState === 'locked') {
|
||||
const lock = createSvgElement('text')
|
||||
lock.setAttribute('class', 'map-lock')
|
||||
lock.setAttribute('text-anchor', 'middle')
|
||||
lock.setAttribute('dominant-baseline', 'middle')
|
||||
lock.setAttribute('y', String(-NODE_RADIUS - 8))
|
||||
lock.textContent = '🔒'
|
||||
g.appendChild(lock)
|
||||
} else if (room.challengeId) {
|
||||
const resolved = isChallengeResolved(room.challengeId, state.save)
|
||||
const badge = createSvgElement('text')
|
||||
badge.setAttribute('class', 'map-badge')
|
||||
badge.setAttribute('text-anchor', 'middle')
|
||||
badge.setAttribute('dominant-baseline', 'middle')
|
||||
badge.setAttribute('y', String(-NODE_RADIUS - 8))
|
||||
badge.textContent = resolved ? '✓' : '?'
|
||||
g.appendChild(badge)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
function buildSvg(
|
||||
state: GameState,
|
||||
onSelect: (id: string) => void
|
||||
): SVGSVGElement {
|
||||
const svg = createSvgElement('svg')
|
||||
svg.setAttribute('viewBox', `0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`)
|
||||
svg.setAttribute('width', '100%')
|
||||
svg.setAttribute('style', 'max-height: 440px;')
|
||||
|
||||
svg.appendChild(buildEdges())
|
||||
|
||||
for (const roomId of Object.keys(rooms)) {
|
||||
svg.appendChild(buildNode(roomId, state, onSelect))
|
||||
}
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
function getDialog(): HTMLDialogElement | null {
|
||||
return document.getElementById('map-dialog') as HTMLDialogElement | null
|
||||
}
|
||||
|
||||
function createMapOverlay(): HTMLDialogElement {
|
||||
const dialog = document.createElement('dialog')
|
||||
dialog.id = 'map-dialog'
|
||||
dialog.innerHTML = '<h3>Map</h3><div id="map-svg-container"></div>'
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) dialog.close()
|
||||
})
|
||||
|
||||
document.body.appendChild(dialog)
|
||||
return dialog
|
||||
}
|
||||
|
||||
function renderMap(state: GameState): void {
|
||||
const container = document.getElementById('map-svg-container')
|
||||
if (!container) return
|
||||
|
||||
container.innerHTML = ''
|
||||
|
||||
const svg = buildSvg(state, (roomId) => {
|
||||
enterRoom(roomId)
|
||||
getDialog()?.close()
|
||||
})
|
||||
|
||||
container.appendChild(svg)
|
||||
}
|
||||
|
||||
function toggleMap(): void {
|
||||
const dialog = getDialog()
|
||||
if (!dialog) return
|
||||
|
||||
if (dialog.open) {
|
||||
dialog.close()
|
||||
} else {
|
||||
dialog.showModal()
|
||||
}
|
||||
}
|
||||
|
||||
export { createMapOverlay, renderMap, toggleMap }
|
||||
92
apps/architecture-adventure/src/ui/renderer.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { challenges } from '@/data/challenges'
|
||||
import { countResolvedChallenges } from '@/engine/navigation'
|
||||
import { showEnding } from '@/engine/stateMachine'
|
||||
import { clearSave } from '@/state/gameState'
|
||||
import { createHud, renderHud } from '@/ui/hud'
|
||||
import { renderChallenge } from '@/ui/challengeView'
|
||||
import { renderEnding } from '@/ui/endingView'
|
||||
import { createMapOverlay, renderMap, toggleMap } from '@/ui/nodeMap'
|
||||
import { createRoomView, renderRoom } from '@/ui/roomView'
|
||||
import { createSidebar, renderSidebar } from '@/ui/sidebar'
|
||||
|
||||
function mountApp(): void {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) throw new Error('Missing #app element')
|
||||
|
||||
app.appendChild(createHud())
|
||||
app.appendChild(createRoomView())
|
||||
app.appendChild(createSidebar())
|
||||
createMapOverlay()
|
||||
|
||||
const toggleBtn = document.getElementById('toggle-map')
|
||||
toggleBtn?.addEventListener('click', toggleMap)
|
||||
|
||||
const restartBtn = document.getElementById('restart-btn')
|
||||
restartBtn?.addEventListener('click', () => {
|
||||
clearSave()
|
||||
location.reload()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
|
||||
if (e.key === 'M' || e.key === 'm') {
|
||||
toggleMap()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const dialog = document.getElementById(
|
||||
'map-dialog'
|
||||
) as HTMLDialogElement | null
|
||||
if (dialog?.open) dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
const numMatch = e.key.match(/^[1-9]$/)
|
||||
if (numMatch) {
|
||||
const index = parseInt(e.key, 10) - 1
|
||||
const choices = document.querySelectorAll<HTMLButtonElement>('.choice-btn')
|
||||
choices[index]?.click()
|
||||
return
|
||||
}
|
||||
|
||||
const letterMatch = e.key.match(/^[A-Ca-c]$/)
|
||||
if (letterMatch) {
|
||||
const key = e.key.toUpperCase()
|
||||
const choices = document.querySelectorAll<HTMLButtonElement>(
|
||||
'.challenge-choice-btn'
|
||||
)
|
||||
const match = Array.from(choices).find(
|
||||
(btn) => btn.querySelector('.choice-key')?.textContent === key
|
||||
)
|
||||
match?.click()
|
||||
}
|
||||
}
|
||||
|
||||
function render(state: GameState): void {
|
||||
renderHud(state)
|
||||
renderSidebar(state)
|
||||
renderMap(state)
|
||||
|
||||
if (state.phase === 'ending') {
|
||||
renderEnding(state)
|
||||
return
|
||||
}
|
||||
|
||||
renderRoom(state)
|
||||
renderChallenge(state)
|
||||
|
||||
const totalChallenges = Object.keys(challenges).length
|
||||
const resolved = countResolvedChallenges(state.save)
|
||||
if (resolved >= totalChallenges) {
|
||||
showEnding()
|
||||
}
|
||||
}
|
||||
|
||||
export { mountApp, render }
|
||||
83
apps/architecture-adventure/src/ui/roomView.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { GameState } from '@/types'
|
||||
import { rooms } from '@/data/rooms'
|
||||
import { isChallengeResolved } from '@/engine/navigation'
|
||||
import { enterRoom } from '@/engine/stateMachine'
|
||||
import { canEnterRoom } from '@/state/tags'
|
||||
|
||||
function createRoomView(): HTMLElement {
|
||||
const main = document.createElement('main')
|
||||
main.id = 'main'
|
||||
main.innerHTML = `
|
||||
<div id="room-header">
|
||||
<h2 id="room-title"></h2>
|
||||
<div id="room-layer"></div>
|
||||
</div>
|
||||
<div id="room-image" class="room-image placeholder"></div>
|
||||
<p id="room-description"></p>
|
||||
<div id="challenge-mount"></div>
|
||||
<div id="room-choices"></div>
|
||||
`
|
||||
return main
|
||||
}
|
||||
|
||||
function renderRoom(state: GameState): void {
|
||||
const roomId = state.save.currentRun.currentRoom
|
||||
const room = rooms[roomId]
|
||||
if (!room) return
|
||||
|
||||
const titleEl = document.getElementById('room-title')
|
||||
if (titleEl) titleEl.textContent = room.title
|
||||
|
||||
const layerEl = document.getElementById('room-layer')
|
||||
if (layerEl) layerEl.textContent = room.layer
|
||||
|
||||
const imageEl = document.getElementById('room-image')
|
||||
if (imageEl) {
|
||||
if (room.imageUrl) {
|
||||
imageEl.innerHTML = `<img src="${room.imageUrl}" alt="${room.title}" />`
|
||||
imageEl.className = 'room-image'
|
||||
} else {
|
||||
imageEl.innerHTML = `<span>${room.layer}</span>`
|
||||
imageEl.className = 'room-image placeholder'
|
||||
}
|
||||
}
|
||||
|
||||
const descEl = document.getElementById('room-description')
|
||||
if (descEl) {
|
||||
const challengeResolved =
|
||||
room.challengeId !== undefined &&
|
||||
isChallengeResolved(room.challengeId, state.save)
|
||||
const showSolution = challengeResolved && room.solutionDescription !== ''
|
||||
descEl.textContent = showSolution
|
||||
? room.solutionDescription
|
||||
: room.discoveryDescription
|
||||
}
|
||||
|
||||
const choicesEl = document.getElementById('room-choices')
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = ''
|
||||
room.connections.forEach((conn, index) => {
|
||||
const targetRoom = rooms[conn.targetRoomId]
|
||||
if (!targetRoom) return
|
||||
|
||||
const accessible = canEnterRoom(targetRoom, state.save)
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'choice-btn' + (accessible ? '' : ' locked')
|
||||
|
||||
btn.innerHTML = `
|
||||
<span class="choice-key">${index + 1}</span>
|
||||
<span class="choice-label">${conn.label}</span>
|
||||
<span class="choice-hint">${accessible ? conn.hint : '🔒 ' + conn.hint}</span>
|
||||
`
|
||||
|
||||
if (accessible) {
|
||||
btn.addEventListener('click', () => enterRoom(conn.targetRoomId))
|
||||
}
|
||||
|
||||
choicesEl.appendChild(btn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { createRoomView, renderRoom }
|
||||
37
apps/architecture-adventure/src/ui/sidebar.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { GameState } from '@/types'
|
||||
|
||||
function createSidebar(): HTMLElement {
|
||||
const sidebar = document.createElement('aside')
|
||||
sidebar.id = 'sidebar'
|
||||
sidebar.innerHTML = `
|
||||
<div id="concept-tags">
|
||||
<h3 class="sidebar-header">Concept Tags</h3>
|
||||
<div id="tags-list"></div>
|
||||
</div>
|
||||
<div id="artifacts-panel">
|
||||
<h3 class="sidebar-header">Artifacts</h3>
|
||||
<div id="artifacts-list"></div>
|
||||
</div>
|
||||
<div id="run-log">
|
||||
<h3 class="sidebar-header">Log</h3>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
`
|
||||
return sidebar
|
||||
}
|
||||
|
||||
function renderSidebar(state: GameState): void {
|
||||
const tagsList = document.getElementById('tags-list')
|
||||
if (tagsList) {
|
||||
tagsList.innerHTML = state.save.currentRun.conceptTags
|
||||
.map((tag) => `<span class="tag-pill">${tag}</span>`)
|
||||
.join('')
|
||||
|
||||
if (state.save.currentRun.conceptTags.length === 0) {
|
||||
tagsList.innerHTML =
|
||||
'<span class="empty-hint">None yet — explore and solve challenges</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { createSidebar, renderSidebar }
|
||||
21
apps/architecture-adventure/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
26
apps/architecture-adventure/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
root: projectRoot,
|
||||
base: './',
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 1_000_000,
|
||||
cssCodeSplit: false,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
683
docs/architecture/WALKTHROUGH.txt
Normal file
@@ -0,0 +1,683 @@
|
||||
===============================================================================
|
||||
____ _ _ ____
|
||||
/ ___|___ __| | ___| |__ __ _ ___ ___ / ___|__ ___ _____ _ __ _ __ ___
|
||||
| | / _ \ / _` |/ _ \ '_ \ / _` / __|/ _ \ | | / _` \ \ / / _ \ '__| '_ \/ __|
|
||||
| |__| (_) | (_| | __/ |_) | (_| \__ \ __/ | |__| (_| |\ V / __/ | | | | \__ \
|
||||
\____\___/ \__,_|\___|_.__/ \__,_|___/\___| \____\__,_| \_/ \___|_| |_| |_|___/
|
||||
|
||||
ComfyUI Frontend Architecture Adventure - Complete Walkthrough
|
||||
===============================================================================
|
||||
|
||||
Platform: Web Browser (any modern browser)
|
||||
Version: 1.0
|
||||
Author: An Architect Who Has Seen Things
|
||||
Last Updated: 2026-03-24
|
||||
Spoilers: YES. This guide contains ALL solutions and ALL endings.
|
||||
|
||||
===============================================================================
|
||||
TABLE OF CONTENTS
|
||||
===============================================================================
|
||||
|
||||
I. Introduction & Controls
|
||||
II. Game Mechanics
|
||||
III. Room Guide & Map
|
||||
IV. Challenge Solutions (SPOILERS)
|
||||
V. Optimal Route - "The ECS Enlightenment" Speedrun
|
||||
VI. All Four Endings
|
||||
VII. Achievements
|
||||
VIII. Artifacts Checklist
|
||||
IX. Pro Tips & Secrets
|
||||
|
||||
===============================================================================
|
||||
I. INTRODUCTION & CONTROLS
|
||||
===============================================================================
|
||||
|
||||
Codebase Caverns is an interactive choose-your-own-adventure game that
|
||||
teaches you the architecture of the ComfyUI frontend codebase. You explore
|
||||
10 rooms representing different architectural layers, face 9 real engineering
|
||||
challenges, collect artifacts, and reach one of 4 endings based on your
|
||||
decisions.
|
||||
|
||||
Every challenge in this game is based on REAL architectural problems
|
||||
documented in the ComfyUI frontend repo. The "correct" answers match the
|
||||
actual migration strategy being used in production.
|
||||
|
||||
CONTROLS:
|
||||
=========
|
||||
1, 2, 3 Navigate between rooms (press the number key)
|
||||
A, B, C Choose a challenge option (press the letter key)
|
||||
M Toggle the map overlay
|
||||
Escape Close the map / close ending preview
|
||||
|
||||
BUTTONS:
|
||||
========
|
||||
Map [M] Opens the room map overlay
|
||||
Restart Resets the current run (keeps achievements)
|
||||
Play Again After an ending, starts a new run
|
||||
|
||||
Your progress auto-saves to localStorage. Close the tab and come back
|
||||
later - you'll pick up right where you left off.
|
||||
|
||||
===============================================================================
|
||||
II. GAME MECHANICS
|
||||
===============================================================================
|
||||
|
||||
STATS
|
||||
=====
|
||||
You have four stats tracked in the HUD at the top:
|
||||
|
||||
Debt [||||||||..] 50 Technical debt. LOWER is better.
|
||||
Quality [|||.......] 30 Code quality. HIGHER is better.
|
||||
Morale [||||||....] 60 Team morale. HIGHER is better.
|
||||
ECS [.........] 0/5 Migration progress. 5 is max.
|
||||
|
||||
Each challenge choice modifies these stats. Your final stats determine
|
||||
which of the 4 endings you get.
|
||||
|
||||
CHALLENGES
|
||||
==========
|
||||
9 of the 10 rooms contain a one-time challenge - an architectural dilemma
|
||||
with 2-3 options. Each option has a rating:
|
||||
|
||||
[GOOD] Best practice. Matches the real migration strategy.
|
||||
Usually: Debt down, Quality up, +1 ECS progress.
|
||||
|
||||
[OK] Pragmatic but imperfect. Gets the job done.
|
||||
Mixed stat effects.
|
||||
|
||||
[BAD] Tempting but harmful. Short-term gain, long-term pain.
|
||||
Usually: Debt up or Morale down.
|
||||
|
||||
After choosing, you see your result, the recommended answer, and a link
|
||||
to the real architecture documentation that explains why.
|
||||
|
||||
ARTIFACTS
|
||||
=========
|
||||
Rooms contain collectible artifacts - key files and concepts from the
|
||||
codebase. These are auto-collected when you enter a room. They appear
|
||||
as icons in your Inventory sidebar.
|
||||
|
||||
ENDINGS
|
||||
=======
|
||||
After resolving all 8 challenges, you get one of 4 endings based on
|
||||
your accumulated stats. See Section VI for details.
|
||||
|
||||
ACHIEVEMENTS
|
||||
============
|
||||
Each ending you reach is permanently saved as an achievement badge.
|
||||
Achievements persist across runs - even after restarting. Click an
|
||||
unlocked badge to review that ending's screen.
|
||||
|
||||
===============================================================================
|
||||
III. ROOM GUIDE & MAP
|
||||
===============================================================================
|
||||
|
||||
+-------------------+
|
||||
| ENTRY POINT |
|
||||
| (src/main.ts) |
|
||||
+-+--------+------+-+
|
||||
| | |
|
||||
+----------+ | +-----------+
|
||||
| | |
|
||||
+---v----------+ +-----v--------+ +------v---------+
|
||||
| COMPONENT | | STORE | | SERVICE |
|
||||
| GALLERY | | VAULTS | | CORRIDORS |
|
||||
| [Challenge] | | [Challenge] | | [Challenge] |
|
||||
+--+------+----+ +--+------+----+ +--------+-------+
|
||||
| | | | |
|
||||
| | +----v---+ | +------v-------+
|
||||
| | | ECS | | | COMPOSABLES |
|
||||
| | | CHAMB. | | | WORKSHOP |
|
||||
| | | [Chal] | | | [Challenge] |
|
||||
| | +---+----+ | +--------------+
|
||||
| | | +----v------+
|
||||
| | +----v--+--+ |
|
||||
| | |SUBGRAPH| RENDERER |
|
||||
| | | DEPTHS | OVERLOOK |
|
||||
| | | [Chal] | [Chal] |
|
||||
| | +--------+----------+
|
||||
| |
|
||||
+--v------v----+
|
||||
| LITEGRAPH |
|
||||
| ENGINE |
|
||||
| [Challenge] |
|
||||
+------+-------+
|
||||
|
|
||||
+------v-------+
|
||||
| COMMAND |
|
||||
| FORGE |
|
||||
| [Challenge] |
|
||||
+--------------+
|
||||
|
||||
ROOM DETAILS:
|
||||
=============
|
||||
|
||||
1. THE ENTRY POINT [src/main.ts]
|
||||
No challenge. No artifacts. Starting room.
|
||||
Exits: Components (1), Stores (2), Services (3)
|
||||
|
||||
2. THE COMPONENT GALLERY [Presentation]
|
||||
Challenge: The Circular Dependency
|
||||
Artifacts: GraphView.vue
|
||||
Exits: Litegraph (1), Command Forge (2), Entry (3)
|
||||
|
||||
3. THE STORE VAULTS [State]
|
||||
Challenge: The Scattered Mutations
|
||||
Artifacts: widgetValueStore.ts, layoutStore.ts
|
||||
Exits: ECS (1), Renderer (2), Entry (3)
|
||||
|
||||
4. THE SERVICE CORRIDORS [Services]
|
||||
Challenge: The Migration Question
|
||||
Artifacts: litegraphService.ts, Extension Migration Guide
|
||||
Exits: Composables (1), Entry (2)
|
||||
|
||||
5. THE LITEGRAPH ENGINE ROOM [Graph Engine]
|
||||
Challenge: The God Object Dilemma
|
||||
Artifacts: LGraphCanvas.ts, LGraphNode.ts
|
||||
Exits: ECS (1), Components (2), Entry (3)
|
||||
|
||||
6. THE ECS ARCHITECT'S CHAMBER [ECS]
|
||||
Challenge: The ID Crossroads
|
||||
Artifacts: World Registry, Branded Entity IDs
|
||||
Exits: Subgraph Depths (1), Renderer (2), Entry (3)
|
||||
|
||||
7. THE SUBGRAPH DEPTHS [Graph Boundaries]
|
||||
Challenge: The Widget Promotion Decision
|
||||
Artifacts: SubgraphStructure, Typed Interface Contracts
|
||||
Exits: ECS (1), Litegraph (2), Entry (3)
|
||||
|
||||
8. THE RENDERER OVERLOOK [Renderer]
|
||||
Challenge: The Render-Time Mutation
|
||||
Artifacts: QuadTree Spatial Index, Y.js CRDT Layout
|
||||
Exits: ECS (1), Entry (2)
|
||||
|
||||
9. THE COMPOSABLES WORKSHOP [Composables]
|
||||
Challenge: The Collaboration Protocol
|
||||
Artifacts: useCoreCommands.ts
|
||||
Exits: Stores (1), Entry (2)
|
||||
|
||||
10. THE COMMAND FORGE [Commands & Intent]
|
||||
Challenge: The Mutation Gateway
|
||||
Artifacts: CommandExecutor, Command Interface
|
||||
Exits: Components (1), Stores (2), Entry (3)
|
||||
|
||||
===============================================================================
|
||||
IV. CHALLENGE SOLUTIONS (SPOILERS)
|
||||
===============================================================================
|
||||
|
||||
*** WARNING: FULL SOLUTIONS BELOW ***
|
||||
*** SCROLL PAST SECTION VI IF YOU WANT TO PLAY BLIND ***
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 1: The Circular Dependency | Room: Components |
|
||||
|------------------------------------------------------------------ |
|
||||
| Subgraph extends LGraph, but LGraph creates Subgraph instances. |
|
||||
| Circular import forces order-dependent barrel exports. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Composition over inheritance [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| A subgraph IS a graph - just a node with SubgraphStructure. |
|
||||
| Under graph unification, no class inheritance at all. |
|
||||
| |
|
||||
| B. Barrel file reordering [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Band-aid. The coupling remains and will break again. |
|
||||
| |
|
||||
| C. Factory injection [OK] |
|
||||
| Debt -5, Quality +10 |
|
||||
| Pragmatic fix but classes stay coupled at runtime. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 2: The Scattered Mutations | Room: Stores |
|
||||
|------------------------------------------------------------------ |
|
||||
| graph._version++ appears in 19 locations across 7 files. |
|
||||
| One missed site = silent data loss. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Centralize into graph.incrementVersion() [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| This is Phase 0a of the real migration plan. |
|
||||
| 19 sites -> 1 method. Auditable change tracking. |
|
||||
| |
|
||||
| B. Add a JavaScript Proxy [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Catches mutations but adds opaque runtime overhead. |
|
||||
| |
|
||||
| C. Leave it as-is [BAD] |
|
||||
| Debt +10, Morale +5 |
|
||||
| "It works, don't touch it" - until it doesn't. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 3: The Migration Question | Room: Services |
|
||||
|------------------------------------------------------------------ |
|
||||
| Legacy litegraph works. How to migrate to ECS without breaking |
|
||||
| production for thousands of users? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. 5-phase incremental plan [GOOD] <<< |
|
||||
| Quality +15, Morale +10, ECS +1 |
|
||||
| Foundation -> Types -> Bridge -> Systems -> Legacy Removal. |
|
||||
| Each phase independently shippable. This is the real plan. |
|
||||
| |
|
||||
| B. Big bang rewrite [BAD] |
|
||||
| Debt -10, Quality +5, Morale -20 |
|
||||
| Feature freeze + scope creep + burnout = disaster. |
|
||||
| |
|
||||
| C. Strangler fig pattern [OK] |
|
||||
| Quality +10, Morale +5 |
|
||||
| Solid pattern but lacks clear milestones without a plan. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 4: The God Object Dilemma | Room: Litegraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| LGraphCanvas: ~9,100 lines. LGraphNode: ~4,300 lines. |
|
||||
| God objects mixing rendering, serialization, connectivity, etc. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| A. Rewrite from scratch [BAD] |
|
||||
| Debt -20, Quality +5, Morale -25 |
|
||||
| Heroic rewrite stalls at month three. Team burns out. |
|
||||
| |
|
||||
| >>> B. Extract incrementally [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| Position -> Connectivity -> Rendering. Small testable PRs. |
|
||||
| This matches the actual migration strategy. |
|
||||
| |
|
||||
| C. Add a facade layer [OK] |
|
||||
| Debt +5, Quality +5, Morale +10 |
|
||||
| Nicer API but complexity lives behind the facade. |
|
||||
| |
|
||||
| NOTE: This is the only challenge where A is NOT the best answer! |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 5: The ID Crossroads | Room: ECS |
|
||||
|------------------------------------------------------------------ |
|
||||
| NodeId is number | string. Nothing prevents passing a LinkId |
|
||||
| where a NodeId is expected. Six entity kinds share one ID space. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Branded types with cast helpers [GOOD] <<< |
|
||||
| Debt -15, Quality +20, ECS +1 |
|
||||
| type NodeEntityId = number & { __brand: 'NodeEntityId' } |
|
||||
| Compile-time safety, zero runtime cost. Phase 1a. |
|
||||
| |
|
||||
| B. String prefixes at runtime [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| "node:42" - parsing overhead everywhere. |
|
||||
| |
|
||||
| C. Keep plain numbers [BAD] |
|
||||
| Debt +15, Quality -5 |
|
||||
| "Just be careful" - someone WILL pass the wrong ID. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 6: The Widget Promotion Decision | Room: Subgraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| A user promotes a widget from inside a subgraph to the parent. |
|
||||
| Today this needs PromotionStore + ViewManager + PromotedWidgetView |
|
||||
| — a parallel state system. Two ECS candidates. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Connections-only: promotion = typed input [GOOD] <<< |
|
||||
| Debt -15, Quality +15, Morale +5, ECS +1 |
|
||||
| Promotion = adding an interface input. Type->widget mapping |
|
||||
| creates the widget automatically. Eliminates PromotionStore, |
|
||||
| ViewManager, and PromotedWidgetView entirely. |
|
||||
| |
|
||||
| B. Simplified component promotion [OK] |
|
||||
| Debt -5, Quality +10, Morale +5 |
|
||||
| WidgetPromotion component on widget entities. Removes |
|
||||
| ViewManager but keeps promotion as a distinct concept. |
|
||||
| Shared subgraph instance ambiguity remains. |
|
||||
| |
|
||||
| C. Keep the current three-layer system [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| The parallel state system persists indefinitely. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 7: The Render-Time Mutation | Room: Renderer |
|
||||
|------------------------------------------------------------------ |
|
||||
| drawNode() calls _setConcreteSlots() and arrange() during the |
|
||||
| render pass. Draw order affects layout. Classic mutation-in- |
|
||||
| render bug. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Separate update and render phases [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| Input -> Update (layout) -> Render (read-only). |
|
||||
| Matches the ECS system pipeline design. |
|
||||
| |
|
||||
| B. Dirty flags and deferred render [OK] |
|
||||
| Debt -5, Quality +5, Morale +5 |
|
||||
| Reduces symptoms but render pass can still mutate. |
|
||||
| |
|
||||
| NOTE: Only 2 options here. Both are reasonable; A is optimal. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 8: The Collaboration Protocol | Room: Composables |
|
||||
|------------------------------------------------------------------ |
|
||||
| Multiple users want to edit the same workflow simultaneously. |
|
||||
| layoutStore already extracts position data. How to sync? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Y.js CRDTs [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +10 |
|
||||
| Conflict-free replicated data types. Already proven. |
|
||||
| This is what the real layoutStore uses. |
|
||||
| |
|
||||
| B. Polling-based sync [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Flickering, lag, silent data loss. Support nightmare. |
|
||||
| |
|
||||
| C. Skip collaboration for now [OK] |
|
||||
| Morale +5 |
|
||||
| Pragmatic delay but cloud team won't be happy. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 9: The Mutation Gateway | Room: Command Forge |
|
||||
|------------------------------------------------------------------ |
|
||||
| The World's imperative API (world.setComponent()) vs. the command |
|
||||
| pattern requirement from ADR 0003. How should external callers |
|
||||
| mutate the World? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Commands as intent; systems as handlers; World as store <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 [GOOD] |
|
||||
| Caller -> Command -> System -> World -> Y.js. Commands are |
|
||||
| serializable. ADR 0003 and ADR 0008 are complementary. |
|
||||
| |
|
||||
| B. Make World.setComponent() itself serializable [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Conflates store with command layer. Batch ops become noisy. |
|
||||
| |
|
||||
| C. Skip commands - let callers mutate directly [BAD] |
|
||||
| Debt +15, Quality -10 |
|
||||
| No undo/redo, no replay, no CRDT sync, no audit trail. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
V. OPTIMAL ROUTE - "THE ECS ENLIGHTENMENT" SPEEDRUN
|
||||
===============================================================================
|
||||
|
||||
This route hits all 8 challenges picking the GOOD answer, collecting
|
||||
all 13 artifacts, visiting all 10 rooms. Order matters for efficiency
|
||||
(fewest key presses).
|
||||
|
||||
Starting stats: Debt 50, Quality 30, Morale 60, ECS 0/5
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Component Gallery
|
||||
|
||||
COMPONENT GALLERY
|
||||
Challenge: The Circular Dependency -> Press A (Composition)
|
||||
[Debt 40, Quality 45, Morale 65, ECS 1/5]
|
||||
Press 2 -> Command Forge
|
||||
|
||||
THE COMMAND FORGE
|
||||
Challenge: The Mutation Gateway -> Press A (Commands as intent)
|
||||
[Debt 30, Quality 60, Morale 70, ECS 2/5]
|
||||
Press 2 -> Store Vaults
|
||||
|
||||
STORE VAULTS
|
||||
Challenge: The Scattered Mutations -> Press A (Centralize)
|
||||
[Debt 15, Quality 75, Morale 70, ECS 3/5]
|
||||
Press 1 -> ECS Chamber
|
||||
|
||||
ECS ARCHITECT'S CHAMBER
|
||||
Challenge: The ID Crossroads -> Press A (Branded types)
|
||||
[Debt 0, Quality 95, Morale 70, ECS 4/5]
|
||||
Press 1 -> Subgraph Depths
|
||||
|
||||
SUBGRAPH DEPTHS
|
||||
Challenge: The Widget Promotion Decision -> Press A (Connections-only)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 1 -> ECS Chamber
|
||||
Press 2 -> Renderer
|
||||
|
||||
RENDERER OVERLOOK
|
||||
Challenge: The Render-Time Mutation -> Press A (Separate phases)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 3 -> Services
|
||||
|
||||
SERVICE CORRIDORS
|
||||
Challenge: The Migration Question -> Press A (5-phase plan)
|
||||
[Debt 0, Quality 100, Morale 85, ECS 5/5]
|
||||
Press 1 -> Composables
|
||||
|
||||
COMPOSABLES WORKSHOP
|
||||
Challenge: The Collaboration Protocol -> Press A (Y.js CRDTs)
|
||||
[Debt 0, Quality 100, Morale 95, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Components
|
||||
Press 1 -> Litegraph
|
||||
|
||||
LITEGRAPH ENGINE ROOM
|
||||
Challenge: The God Object Dilemma -> Press B (Extract incrementally)
|
||||
[Debt 0, Quality 100, Morale 100, ECS 5/5]
|
||||
|
||||
FINAL STATS: Debt 0 | Quality 100 | Morale 100 | ECS 5/5
|
||||
|
||||
*** ENDING: THE ECS ENLIGHTENMENT ***
|
||||
|
||||
Total key presses: 28 (including challenge answers)
|
||||
Rooms visited: 10/10
|
||||
Artifacts: 16/16
|
||||
Challenges: 9/9 correct
|
||||
|
||||
===============================================================================
|
||||
VI. ALL FOUR ENDINGS
|
||||
===============================================================================
|
||||
|
||||
Endings are checked in order. First match wins.
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 1: THE ECS ENLIGHTENMENT [BEST] |
|
||||
| |
|
||||
| Requirements: Debt < 25 AND Quality >= 75 AND Morale >= 60 |
|
||||
| |
|
||||
| "The World registry hums with clean data. Node removal: |
|
||||
| 30 lines instead of 107. Serialization: one system instead |
|
||||
| of six scattered methods. Branded IDs catch bugs at compile |
|
||||
| time. Y.js CRDTs enable real-time collaboration. The team |
|
||||
| ships features faster than ever." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick ALL good answers. Hard to miss if you |
|
||||
| read the hints carefully. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 2: THE CLEAN ARCHITECTURE [GOOD] |
|
||||
| |
|
||||
| Requirements: Debt < 40 AND Quality >= 50 |
|
||||
| |
|
||||
| "The migration completes on schedule. Systems hum along, |
|
||||
| the ECS World holds most entity state, and the worst god |
|
||||
| objects have been tamed." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick mostly good answers, 1-2 OK answers. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 3: THE ETERNAL REFACTOR [MEH] |
|
||||
| |
|
||||
| Requirements: Debt < 70 |
|
||||
| |
|
||||
| "The migration... continues. Every sprint has a 'cleanup' |
|
||||
| ticket that never quite closes." |
|
||||
| |
|
||||
| HOW TO GET IT: Mix of OK and BAD answers. The "safe" middle. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 4: THE SPAGHETTI SINGULARITY [WORST] |
|
||||
| |
|
||||
| Requirements: Debt >= 70 (catch-all) |
|
||||
| |
|
||||
| "The god objects grew sentient. LGraphCanvas hit 12,000 lines |
|
||||
| and developed a circular dependency with itself." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick all BAD answers. You have to try. |
|
||||
| Starting debt is 50, so you need +20 from bad choices. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
VII. ACHIEVEMENTS
|
||||
===============================================================================
|
||||
|
||||
Achievements are permanently saved across runs. You need 4 playthroughs
|
||||
(minimum) to unlock all endings, since each run can only reach one.
|
||||
|
||||
[x] The ECS Enlightenment - All good answers
|
||||
[x] The Clean Architecture - Mostly good, few OK
|
||||
[x] The Eternal Refactor - Mix of OK and bad
|
||||
[x] The Spaghetti Singularity - Maximize debt (see pro tip below)
|
||||
|
||||
Click any unlocked achievement badge in the Endings sidebar panel
|
||||
to review that ending's screen without resetting your current game.
|
||||
|
||||
PRO TIP: "The Spaghetti Singularity" requires Debt >= 70. This is
|
||||
TRICKY because some "bad" answers actually LOWER debt! Rewrites
|
||||
(Litegraph A: Debt -20) and big bang rewrites (Services B: Debt -10)
|
||||
reduce debt short-term even though they tank morale.
|
||||
|
||||
To hit Debt >= 70 you must pick options that ADD debt or leave it
|
||||
alone. Here's the proven path (starting at Debt 50):
|
||||
|
||||
Components: B (Barrel file reordering) Debt +10 -> 60
|
||||
Command Forge: C (Skip commands) Debt +15 -> 75
|
||||
Stores: C (Leave it as-is) Debt +10 -> 85
|
||||
Services: C (Strangler fig) Debt +0 -> 85
|
||||
Litegraph: C (Add a facade) Debt +5 -> 90
|
||||
ECS: C (Keep plain numbers) Debt +15 -> 100
|
||||
Subgraph: C (Keep three-layer system) Debt +10 -> 100
|
||||
Renderer: B (Dirty flags) Debt -5 -> 95
|
||||
Composables: B (Polling-based sync) Debt +10 -> 100
|
||||
|
||||
Final: Debt 100 / Quality 10 / Morale 50 -> SPAGHETTI SINGULARITY
|
||||
|
||||
WARNING: Picking "all bad-rated answers" does NOT work! The bad
|
||||
answers for Litegraph (A: Rewrite, Debt -20) and Services (B: Big
|
||||
bang, Debt -10) have negative debt effects that pull you back
|
||||
under 70.
|
||||
|
||||
===============================================================================
|
||||
VIII. ARTIFACTS CHECKLIST
|
||||
===============================================================================
|
||||
|
||||
Room | Artifact | Type
|
||||
==================|============================|==================
|
||||
Component Gallery | GraphView.vue | Component
|
||||
Store Vaults | widgetValueStore.ts | Proto-ECS Store
|
||||
Store Vaults | layoutStore.ts | Proto-ECS Store
|
||||
Service Corridors | litegraphService.ts | Service
|
||||
Service Corridors | Extension Migration Guide | Design Pattern
|
||||
Litegraph Engine | LGraphCanvas.ts | God Object
|
||||
Litegraph Engine | LGraphNode.ts | God Object
|
||||
ECS Chamber | World Registry | ECS Core
|
||||
ECS Chamber | Branded Entity IDs | Type Safety
|
||||
Subgraph Depths | SubgraphStructure | ECS Component
|
||||
Subgraph Depths | Typed Interface Contracts | Design Pattern
|
||||
Renderer Overlook | QuadTree Spatial Index | Data Structure
|
||||
Renderer Overlook | Y.js CRDT Layout | Collaboration
|
||||
Composables | useCoreCommands.ts | Composable
|
||||
Command Forge | CommandExecutor | ECS Core
|
||||
Command Forge | Command Interface | Design Pattern
|
||||
|
||||
Total: 16 artifacts across 9 rooms.
|
||||
Entry Point has no artifacts.
|
||||
|
||||
===============================================================================
|
||||
IX. PRO TIPS & SECRETS
|
||||
===============================================================================
|
||||
|
||||
* Your game auto-saves after every room change and challenge. Close
|
||||
the tab and come back anytime - you won't lose progress.
|
||||
|
||||
* The Restart button in the HUD resets your run but KEEPS your
|
||||
achievement badges. Use it to go for a different ending.
|
||||
|
||||
* Every code reference in the room descriptions is a clickable link
|
||||
to the actual file on GitHub. Open them in new tabs to read the
|
||||
real code while you play.
|
||||
|
||||
* After each challenge, the "Read more" link takes you to the
|
||||
architecture documentation that explains the real engineering
|
||||
rationale behind the recommended answer.
|
||||
|
||||
* The map overlay (press M) shows challenge badges:
|
||||
[?] = challenge available but not yet attempted
|
||||
[v] = challenge completed
|
||||
|
||||
* Room navigation preloads images for adjacent rooms, so transitions
|
||||
should be instant after the first visit.
|
||||
|
||||
* The Command Forge (formerly the Side Panel) teaches the Command
|
||||
Pattern - how commands relate to systems and the World. Its challenge
|
||||
covers the architectural layering from ADR 0003 and ADR 0008.
|
||||
|
||||
* The ECS Migration Progress stat maxes at 5, matching the 5 phases
|
||||
of the real migration plan. But 9 challenges can give +1 each
|
||||
(8 of the 9 GOOD answers grant +1 ECS). The Services challenge
|
||||
("5-phase plan") gives +1 ECS but no debt reduction - it's pure
|
||||
planning, not implementation.
|
||||
|
||||
* There are between 2-3 choices per challenge, giving
|
||||
3*3*3*3*3*3*2*3*3 = 13,122 possible playthroughs. But only 4
|
||||
distinct endings. Most paths lead to "The Clean Architecture"
|
||||
or "The Eternal Refactor."
|
||||
|
||||
* If you want to learn the ComfyUI frontend architecture for real,
|
||||
the recommended reading order matches the optimal speedrun route:
|
||||
1. src/main.ts (entry point)
|
||||
2. src/views/GraphView.vue (main canvas)
|
||||
3. src/stores/ (state management)
|
||||
4. src/ecs/ (the future)
|
||||
5. docs/architecture/ecs-world-command-api.md (command layer)
|
||||
6. src/renderer/core/ (canvas pipeline)
|
||||
7. docs/architecture/ecs-migration-plan.md (the plan)
|
||||
8. src/composables/ (Vue logic hooks)
|
||||
9. src/lib/litegraph/src/ (the legacy engine)
|
||||
|
||||
* The pixel art images were generated using the Z-Image Turbo
|
||||
pipeline on the same ComfyUI that this frontend controls.
|
||||
Meta, isn't it?
|
||||
|
||||
===============================================================================
|
||||
|
||||
This document Copyright (c) 2026 A Concerned Architect
|
||||
ComfyUI is maintained by Comfy-Org: https://github.com/Comfy-Org
|
||||
|
||||
"In a world of god objects, be an entity-component-system."
|
||||
|
||||
___
|
||||
| |
|
||||
___| |___
|
||||
| |
|
||||
| COMFY UI |
|
||||
| FRONTEND |
|
||||
|___________|
|
||||
| | | | | | |
|
||||
| | | | | | |
|
||||
_| | | | | | |_
|
||||
|_______________|
|
||||
|
||||
GG. GIT GUD.
|
||||
|
||||
===============================================================================
|
||||
26
docs/architecture/adventure-achievement-icon-prompts.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art badge/medal icon, 128x128, dark background, achievement unlock style",
|
||||
"usage": "Each key matches an ending ID. Shown in achievements panel when that ending has been reached.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"achievements": {
|
||||
"great": {
|
||||
"title": "The ECS Enlightenment",
|
||||
"prompt": "Pixel art achievement badge of a radiant crystal temple with clean geometric architecture, bright green and gold triumphant glow, laurel wreath border, dark background"
|
||||
},
|
||||
"good": {
|
||||
"title": "The Clean Architecture",
|
||||
"prompt": "Pixel art achievement badge of a solid fortress with neat organized blocks, blue and silver steady glow, star emblem, dark background"
|
||||
},
|
||||
"mediocre": {
|
||||
"title": "The Eternal Refactor",
|
||||
"prompt": "Pixel art achievement badge of an hourglass with sand still flowing endlessly, amber and grey weary glow, circular border, dark background"
|
||||
},
|
||||
"disaster": {
|
||||
"title": "The Spaghetti Singularity",
|
||||
"prompt": "Pixel art achievement badge of a tangled mass of spaghetti code wires collapsing into a black hole, red and purple chaotic glow, cracked border, dark background"
|
||||
}
|
||||
}
|
||||
}
|
||||
114
docs/architecture/adventure-choice-icon-prompts.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art icon, 128x128, dark background, game UI button icon style, clean readable silhouette",
|
||||
"usage": "Each key is {room}-{choiceKey lowercase}. Used in challenge choice buttons in adventure.html.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"choices": {
|
||||
"components-a": {
|
||||
"label": "Composition over inheritance",
|
||||
"prompt": "Pixel art icon of puzzle pieces snapping together cleanly, green glow, dark background, game UI icon"
|
||||
},
|
||||
"components-b": {
|
||||
"label": "Barrel file reordering",
|
||||
"prompt": "Pixel art icon of a stack of files being shuffled with arrows, amber warning glow, dark background, game UI icon"
|
||||
},
|
||||
"components-c": {
|
||||
"label": "Factory injection",
|
||||
"prompt": "Pixel art icon of a factory building with a syringe injecting into it, blue mechanical glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-a": {
|
||||
"label": "Centralize into graph.incrementVersion()",
|
||||
"prompt": "Pixel art icon of scattered dots converging into a single glowing funnel point, green glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-b": {
|
||||
"label": "Add a JavaScript Proxy",
|
||||
"prompt": "Pixel art icon of a shield proxy intercepting arrows mid-flight, amber translucent glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-c": {
|
||||
"label": "Leave it as-is",
|
||||
"prompt": "Pixel art icon of a shrug gesture with cobwebs on old machinery, grey muted glow, dark background, game UI icon"
|
||||
},
|
||||
"services-a": {
|
||||
"label": "5-phase incremental plan",
|
||||
"prompt": "Pixel art icon of five stepping stones ascending in a staircase with checkmarks, green glow, dark background, game UI icon"
|
||||
},
|
||||
"services-b": {
|
||||
"label": "Big bang rewrite",
|
||||
"prompt": "Pixel art icon of a dynamite stick with lit fuse and explosion sparks, red danger glow, dark background, game UI icon"
|
||||
},
|
||||
"services-c": {
|
||||
"label": "Strangler fig pattern",
|
||||
"prompt": "Pixel art icon of vines growing around and enveloping an old tree trunk, green and brown organic glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-a": {
|
||||
"label": "Rewrite from scratch",
|
||||
"prompt": "Pixel art icon of a wrecking ball demolishing a building into rubble, red destructive glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-b": {
|
||||
"label": "Extract incrementally",
|
||||
"prompt": "Pixel art icon of surgical tweezers carefully extracting a glowing module from a larger block, green precise glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-c": {
|
||||
"label": "Add a facade layer",
|
||||
"prompt": "Pixel art icon of a decorative mask covering a cracked wall, yellow cosmetic glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-a": {
|
||||
"label": "Branded types with cast helpers",
|
||||
"prompt": "Pixel art icon of ID badges with distinct colored stamps and a compiler checkmark, green type-safe glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-b": {
|
||||
"label": "String prefixes at runtime",
|
||||
"prompt": "Pixel art icon of text labels being parsed with a magnifying glass at runtime, amber slow glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-c": {
|
||||
"label": "Keep plain numbers",
|
||||
"prompt": "Pixel art icon of bare numbers floating unprotected with a question mark, red risky glow, dark background, game UI icon"
|
||||
},
|
||||
"renderer-a": {
|
||||
"label": "Separate update and render phases",
|
||||
"prompt": "Pixel art icon of two clean pipeline stages labeled U and R with an arrow between them, green orderly glow, dark background, game UI icon"
|
||||
},
|
||||
"renderer-b": {
|
||||
"label": "Dirty flags and deferred render",
|
||||
"prompt": "Pixel art icon of a flag with a smudge mark and a clock showing delay, amber patch glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-a": {
|
||||
"label": "Y.js CRDTs",
|
||||
"prompt": "Pixel art icon of two documents merging seamlessly with sync arrows and no conflicts, green collaboration glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-b": {
|
||||
"label": "Polling-based sync",
|
||||
"prompt": "Pixel art icon of a clock with circular refresh arrows and flickering signal, red laggy glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-c": {
|
||||
"label": "Skip collaboration for now",
|
||||
"prompt": "Pixel art icon of a single person at a desk with a pause symbol, grey neutral glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-a": {
|
||||
"label": "Connections-only: promotion = adding a typed input",
|
||||
"prompt": "Pixel art icon of a function signature with typed input slots and a green checkmark, clean minimal glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-b": {
|
||||
"label": "Simplified component promotion",
|
||||
"prompt": "Pixel art icon of a widget being lifted up with a promotion arrow and a component badge, amber glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-c": {
|
||||
"label": "Keep the current three-layer system",
|
||||
"prompt": "Pixel art icon of three stacked translucent layers with proxy shadows underneath, red complex glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-a": {
|
||||
"label": "Commands as intent; systems as handlers; World as store",
|
||||
"prompt": "Pixel art icon of a layered architectural diagram with arrows flowing top-to-bottom through five labeled tiers, green glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-b": {
|
||||
"label": "Make World.setComponent() itself serializable",
|
||||
"prompt": "Pixel art icon of a database with every cell being logged into a scroll, amber overflow glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-c": {
|
||||
"label": "Skip commands — let callers mutate directly",
|
||||
"prompt": "Pixel art icon of multiple hands reaching into a glowing orb simultaneously causing cracks, red chaos glow, dark background, game UI icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
docs/architecture/adventure-icon-prompts.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art icon on transparent black background, 128x128, clean edges, glowing accent color, game inventory item style",
|
||||
"usage": "Each key is an artifact ID used in adventure.html. Generate one icon per artifact.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"artifacts": {
|
||||
"graphview": {
|
||||
"name": "GraphView.vue",
|
||||
"type": "Component",
|
||||
"prompt": "Pixel art icon of a glowing canvas frame with connected nodes and wires inside, blue accent glow, dark background, game inventory item"
|
||||
},
|
||||
"widgetvaluestore": {
|
||||
"name": "widgetValueStore.ts",
|
||||
"type": "Proto-ECS Store",
|
||||
"prompt": "Pixel art icon of a vault door with a glowing slider widget embossed on it, purple and gold accents, dark background, game inventory item"
|
||||
},
|
||||
"layoutstore": {
|
||||
"name": "layoutStore.ts",
|
||||
"type": "Proto-ECS Store",
|
||||
"prompt": "Pixel art icon of a grid blueprint with glowing position markers, purple accent lines, dark background, game inventory item"
|
||||
},
|
||||
"litegraphservice": {
|
||||
"name": "litegraphService.ts",
|
||||
"type": "Service",
|
||||
"prompt": "Pixel art icon of a gear with a graph node symbol in the center, copper and blue metallic glow, dark background, game inventory item"
|
||||
},
|
||||
"lgraphcanvas": {
|
||||
"name": "LGraphCanvas.ts",
|
||||
"type": "God Object",
|
||||
"prompt": "Pixel art icon of a massive cracked monolith radiating red warning light, labeled 9100, ominous dark background, game inventory item"
|
||||
},
|
||||
"lgraphnode": {
|
||||
"name": "LGraphNode.ts",
|
||||
"type": "God Object",
|
||||
"prompt": "Pixel art icon of an oversized cube with tangled wires bursting from every face, red and amber glow, dark background, game inventory item"
|
||||
},
|
||||
"world-registry": {
|
||||
"name": "World Registry",
|
||||
"type": "ECS Core",
|
||||
"prompt": "Pixel art icon of a glowing crystalline orb containing tiny entity symbols, bright blue and white aura, dark background, game inventory item"
|
||||
},
|
||||
"branded-ids": {
|
||||
"name": "Branded Entity IDs",
|
||||
"type": "Type Safety",
|
||||
"prompt": "Pixel art icon of a set of ID cards with distinct colored borders and brand stamps, green checkmark glow, dark background, game inventory item"
|
||||
},
|
||||
"quadtree": {
|
||||
"name": "QuadTree Spatial Index",
|
||||
"type": "Data Structure",
|
||||
"prompt": "Pixel art icon of a square recursively divided into four quadrants with glowing dots at intersections, teal accent, dark background, game inventory item"
|
||||
},
|
||||
"yjs-crdt": {
|
||||
"name": "Y.js CRDT Layout",
|
||||
"type": "Collaboration",
|
||||
"prompt": "Pixel art icon of two overlapping document layers merging with sync arrows, purple and green glow, dark background, game inventory item"
|
||||
},
|
||||
"usecorecommands": {
|
||||
"name": "useCoreCommands.ts",
|
||||
"type": "Composable",
|
||||
"prompt": "Pixel art icon of a hook tool with keyboard key symbols orbiting it, yellow and blue glow, dark background, game inventory item"
|
||||
},
|
||||
"subgraph-structure": {
|
||||
"name": "SubgraphStructure",
|
||||
"type": "ECS Component",
|
||||
"prompt": "Pixel art icon of nested rectangular frames inside each other like Russian dolls with glowing typed connections at each boundary, purple and teal accent, dark background, game inventory item"
|
||||
},
|
||||
"typed-contracts": {
|
||||
"name": "Typed Interface Contracts",
|
||||
"type": "Architecture",
|
||||
"prompt": "Pixel art icon of a sealed scroll with a glowing typed signature stamp and interface brackets, gold and blue accent, dark background, game inventory item"
|
||||
},
|
||||
"command-executor": {
|
||||
"name": "CommandExecutor",
|
||||
"type": "ECS Core",
|
||||
"prompt": "Pixel art icon of a glowing anvil with a gear and execute arrow symbol, blue-purple forge glow, dark background, game inventory item"
|
||||
},
|
||||
"command-interface": {
|
||||
"name": "Command Interface",
|
||||
"type": "Design Pattern",
|
||||
"prompt": "Pixel art icon of a sealed scroll with a type discriminator tag and execute method seal, blue glow, dark background, game inventory item"
|
||||
},
|
||||
"extension-migration": {
|
||||
"name": "Extension Migration Guide",
|
||||
"type": "Design Pattern",
|
||||
"prompt": "Pixel art icon of a scroll with legacy code on left transforming via arrow to ECS code on right, green transition glow, dark background, game inventory item"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
docs/architecture/adventure-image-prompts.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art, 16:9 aspect ratio, dark moody palette with glowing accent lighting",
|
||||
"usage": "Each key corresponds to a room ID in adventure.html. Generate images with generate-images.py.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "1152x640",
|
||||
"generated": "2026-03-24"
|
||||
},
|
||||
"rooms": {
|
||||
"entry": {
|
||||
"title": "The Entry Point",
|
||||
"prompt": "Pixel art of a glowing terminal in a vast dark server room, Vue.js and TypeScript logos floating as holographic projections, three corridors branching ahead lit by blue, green, and purple lights",
|
||||
"path": "images/entry.png"
|
||||
},
|
||||
"components": {
|
||||
"title": "The Component Gallery",
|
||||
"prompt": "Pixel art gallery hall with framed Vue component cards hung on stone walls, a massive canvas painting labeled 'GraphView' in the center, smaller panels flanking either side, warm torchlight",
|
||||
"path": "images/components.png"
|
||||
},
|
||||
"stores": {
|
||||
"title": "The Store Vaults",
|
||||
"prompt": "Pixel art underground vault with 60 glowing vault doors lining the walls, three doors in front glow brightest (labeled widget, layout, promotion), a Pinia pineapple emblem etched in stone above",
|
||||
"path": "images/stores.png"
|
||||
},
|
||||
"services": {
|
||||
"title": "The Service Corridors",
|
||||
"prompt": "Pixel art clean corridors with labeled pipes connecting rooms overhead, data flowing as glowing particles through transparent tubes, service names etched on brass plaques",
|
||||
"path": "images/services.png"
|
||||
},
|
||||
"litegraph": {
|
||||
"title": "The Litegraph Engine Room",
|
||||
"prompt": "Pixel art dark engine room with three massive monolith machines labeled 9100, 4300, and 3100 lines of code, warning lights flashing amber, tangled wires and cables everywhere",
|
||||
"path": "images/litegraph.png"
|
||||
},
|
||||
"ecs": {
|
||||
"title": "The ECS Architect's Chamber",
|
||||
"prompt": "Pixel art architect's drafting room with blueprints pinned to walls showing entity-component diagrams, a glowing World orb floating in the center, branded ID cards scattered across the desk",
|
||||
"path": "images/ecs.png"
|
||||
},
|
||||
"subgraph": {
|
||||
"title": "The Subgraph Depths",
|
||||
"prompt": "Pixel art recursive fractal chamber where identical rooms nest inside each other like Russian dolls, typed contract scrolls float at each boundary doorway, a DAG tree diagram glows on the ceiling",
|
||||
"path": "images/subgraph.png"
|
||||
},
|
||||
"renderer": {
|
||||
"title": "The Renderer Overlook",
|
||||
"prompt": "Pixel art observation deck overlooking a vast canvas being painted by precise robotic arms, Y.js CRDT symbols floating in the air, a QuadTree grid visible on the floor below",
|
||||
"path": "images/renderer.png"
|
||||
},
|
||||
"composables": {
|
||||
"title": "The Composables Workshop",
|
||||
"prompt": "Pixel art workshop with hooks hanging from a pegboard wall, each labeled (useCoreCommands, useCanvasDrop, etc.), workbenches for auth, canvas, and queue domains, cozy lantern light",
|
||||
"path": "images/composables.png"
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "The Command Forge",
|
||||
"prompt": "Pixel art anvil forge where glowing command scrolls are being hammered into structured objects, a layered diagram on the wall showing five architectural tiers connected by arrows, blue and purple forge light, dark background",
|
||||
"path": "images/sidepanel.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
3270
docs/architecture/adventure.html
Normal file
196
docs/architecture/generate-icons.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Generate pixel art inventory icons for the Architecture Adventure game.
|
||||
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
|
||||
Skips icons that already exist on disk.
|
||||
|
||||
Usage: python docs/architecture/generate-icons.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
COMFY_URL = "http://localhost:8188"
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ARTIFACT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-icon-prompts.json")
|
||||
CHOICE_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-choice-icon-prompts.json")
|
||||
ACHIEVEMENT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-achievement-icon-prompts.json")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "icons")
|
||||
BASE_SEED = 7777
|
||||
WIDTH = 128
|
||||
HEIGHT = 128
|
||||
|
||||
|
||||
def build_workflow(prompt_text, seed, prefix):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "lumina2",
|
||||
"device": "default",
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": "ae.safetensors"},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {"shift": 3, "model": ["1", 0]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt_text, "clip": ["2", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["6", 0]},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "res_multistep",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["8", 0],
|
||||
},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def submit_prompt(workflow):
|
||||
payload = json.dumps({"prompt": workflow}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{COMFY_URL}/prompt",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
raise RuntimeError(f"HTTP {e.code}: {body}")
|
||||
|
||||
|
||||
def poll_history(prompt_id, timeout=120):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
|
||||
data = json.loads(resp.read())
|
||||
if prompt_id in data:
|
||||
return data[prompt_id]
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def download_image(filename, subfolder, dest_path):
|
||||
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
|
||||
urllib.request.urlretrieve(url, dest_path)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Collect all icons from both prompt files
|
||||
all_icons = {}
|
||||
|
||||
with open(ARTIFACT_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["artifacts"].items():
|
||||
all_icons[icon_id] = entry["prompt"]
|
||||
|
||||
if os.path.exists(CHOICE_PROMPTS):
|
||||
with open(CHOICE_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["choices"].items():
|
||||
all_icons[icon_id] = entry["prompt"]
|
||||
|
||||
if os.path.exists(ACHIEVEMENT_PROMPTS):
|
||||
with open(ACHIEVEMENT_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["achievements"].items():
|
||||
all_icons[f"ending-{icon_id}"] = entry["prompt"]
|
||||
|
||||
# Filter out already-generated icons
|
||||
to_generate = {}
|
||||
for icon_id, prompt in all_icons.items():
|
||||
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
|
||||
if os.path.exists(dest):
|
||||
print(f" Skipping {icon_id}.png (already exists)")
|
||||
else:
|
||||
to_generate[icon_id] = prompt
|
||||
|
||||
if not to_generate:
|
||||
print("All icons already generated. Nothing to do.")
|
||||
return
|
||||
|
||||
# Submit jobs
|
||||
jobs = []
|
||||
for i, (icon_id, prompt) in enumerate(to_generate.items()):
|
||||
prefix = f"adventure-icons/{icon_id}"
|
||||
wf = build_workflow(prompt, BASE_SEED + i, prefix)
|
||||
result = submit_prompt(wf)
|
||||
prompt_id = result["prompt_id"]
|
||||
jobs.append((icon_id, prompt_id))
|
||||
print(f" Submitted: {icon_id} -> {prompt_id}")
|
||||
|
||||
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
|
||||
|
||||
# Poll for completion
|
||||
completed = set()
|
||||
while len(completed) < len(jobs):
|
||||
for icon_id, prompt_id in jobs:
|
||||
if prompt_id in completed:
|
||||
continue
|
||||
history = poll_history(prompt_id, timeout=5)
|
||||
if history:
|
||||
completed.add(prompt_id)
|
||||
outputs = history.get("outputs", {})
|
||||
for node_out in outputs.values():
|
||||
for img in node_out.get("images", []):
|
||||
src_filename = img["filename"]
|
||||
subfolder = img.get("subfolder", "")
|
||||
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
|
||||
download_image(src_filename, subfolder, dest)
|
||||
print(f" [{len(completed)}/{len(jobs)}] {icon_id}.png downloaded")
|
||||
if len(completed) < len(jobs):
|
||||
time.sleep(2)
|
||||
|
||||
print(f"\nDone! {len(completed)} icons saved to {OUTPUT_DIR}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
165
docs/architecture/generate-images.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Generate pixel art room images for the Architecture Adventure game.
|
||||
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
|
||||
|
||||
Usage: python docs/architecture/generate-images.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
COMFY_URL = "http://localhost:8188"
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROMPTS_FILE = os.path.join(SCRIPT_DIR, "adventure-image-prompts.json")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "images")
|
||||
BASE_SEED = 2024
|
||||
WIDTH = 1152
|
||||
HEIGHT = 640
|
||||
|
||||
|
||||
def build_workflow(prompt_text, seed, prefix):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "lumina2",
|
||||
"device": "default",
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": "ae.safetensors"},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {"shift": 3, "model": ["1", 0]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt_text, "clip": ["2", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["6", 0]},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "res_multistep",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["8", 0],
|
||||
},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def submit_prompt(workflow):
|
||||
payload = json.dumps({"prompt": workflow}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{COMFY_URL}/prompt",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
raise RuntimeError(f"HTTP {e.code}: {body}")
|
||||
|
||||
|
||||
def poll_history(prompt_id, timeout=120):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
|
||||
data = json.loads(resp.read())
|
||||
if prompt_id in data:
|
||||
return data[prompt_id]
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def download_image(filename, subfolder, dest_path):
|
||||
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
|
||||
urllib.request.urlretrieve(url, dest_path)
|
||||
|
||||
|
||||
def main():
|
||||
with open(PROMPTS_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
rooms = data["rooms"]
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Submit all jobs
|
||||
jobs = []
|
||||
for i, (room_id, room) in enumerate(rooms.items()):
|
||||
prefix = f"adventure/{room_id}"
|
||||
wf = build_workflow(room["prompt"], BASE_SEED + i, prefix)
|
||||
result = submit_prompt(wf)
|
||||
prompt_id = result["prompt_id"]
|
||||
jobs.append((room_id, prompt_id, prefix))
|
||||
print(f" Submitted: {room_id} -> {prompt_id}")
|
||||
|
||||
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
|
||||
|
||||
# Poll for completion
|
||||
completed = set()
|
||||
while len(completed) < len(jobs):
|
||||
for room_id, prompt_id, prefix in jobs:
|
||||
if prompt_id in completed:
|
||||
continue
|
||||
history = poll_history(prompt_id, timeout=5)
|
||||
if history:
|
||||
completed.add(prompt_id)
|
||||
# Find output filename
|
||||
outputs = history.get("outputs", {})
|
||||
for node_id, node_out in outputs.items():
|
||||
images = node_out.get("images", [])
|
||||
for img in images:
|
||||
src_filename = img["filename"]
|
||||
subfolder = img.get("subfolder", "")
|
||||
dest = os.path.join(OUTPUT_DIR, f"{room_id}.png")
|
||||
download_image(src_filename, subfolder, dest)
|
||||
print(f" [{len(completed)}/{len(jobs)}] {room_id}.png downloaded")
|
||||
if len(completed) < len(jobs):
|
||||
time.sleep(2)
|
||||
|
||||
print(f"\nDone! {len(completed)} images saved to {OUTPUT_DIR}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
docs/architecture/icons/branded-ids.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/architecture/icons/command-executor.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/architecture/icons/command-interface.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/architecture/icons/components-a.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/architecture/icons/components-b.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/architecture/icons/components-c.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/architecture/icons/composables-a.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/architecture/icons/composables-b.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/architecture/icons/composables-c.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/architecture/icons/ecs-a.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/architecture/icons/ecs-b.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/architecture/icons/ecs-c.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/architecture/icons/ending-disaster.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/architecture/icons/ending-good.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/architecture/icons/ending-great.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/architecture/icons/ending-mediocre.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/architecture/icons/extension-migration.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/architecture/icons/graphview.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/architecture/icons/layoutstore.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/architecture/icons/lgraphcanvas.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/architecture/icons/lgraphnode.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/architecture/icons/litegraph-a.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/architecture/icons/litegraph-b.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/architecture/icons/litegraph-c.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/architecture/icons/litegraphservice.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/architecture/icons/quadtree.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/architecture/icons/renderer-a.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/architecture/icons/renderer-b.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/architecture/icons/services-a.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/architecture/icons/services-b.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/architecture/icons/services-c.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/architecture/icons/sidepanel-a.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/architecture/icons/sidepanel-b.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/architecture/icons/sidepanel-c.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/architecture/icons/stores-a.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/architecture/icons/stores-b.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/architecture/icons/stores-c.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/architecture/icons/subgraph-a.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/architecture/icons/subgraph-b.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
docs/architecture/icons/subgraph-c.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/architecture/icons/subgraph-structure.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/architecture/icons/typed-contracts.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/architecture/icons/usecorecommands.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/architecture/icons/widgetvaluestore.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/architecture/icons/world-registry.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/architecture/icons/yjs-crdt.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/architecture/images/components.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/architecture/images/composables.png
Normal file
|
After Width: | Height: | Size: 880 KiB |
BIN
docs/architecture/images/ecs.png
Normal file
|
After Width: | Height: | Size: 941 KiB |
BIN
docs/architecture/images/entry.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/architecture/images/litegraph.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/architecture/images/renderer.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/architecture/images/services.png
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
docs/architecture/images/sidepanel.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
docs/architecture/images/stores.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/architecture/images/subgraph.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -28,6 +28,10 @@ const config: KnipConfig = {
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'apps/architecture-adventure': {
|
||||
project: ['src/**/*.ts'],
|
||||
vite: { config: ['vite.config.ts'] }
|
||||
},
|
||||
'apps/website': {
|
||||
entry: [
|
||||
'src/pages/**/*.astro',
|
||||
@@ -61,7 +65,9 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// ECS draft interfaces (ADR 0008) — not yet consumed by production code
|
||||
'src/ecs/**/*.ts'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
|
||||
@@ -12,7 +12,11 @@ export default {
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts,json,yaml,md}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
const lintable = stagedFiles.filter((f) => !f.endsWith('lock.yaml'))
|
||||
const commands = [
|
||||
...(lintable.length > 0 ? formatAndEslint(lintable) : []),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
.map((f) => path.relative(process.cwd(), f).replace(/\\/g, '/'))
|
||||
|
||||
12
pnpm-lock.yaml
generated
@@ -829,6 +829,18 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.1(zod@3.25.76)
|
||||
|
||||
apps/architecture-adventure:
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.4
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
apps/desktop-ui:
|
||||
dependencies:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
|
||||
32
src/ecs/components/group.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Group components.
|
||||
*
|
||||
* Groups are visual containers that hold nodes and reroutes.
|
||||
* Currently no state has been extracted from LGraphGroup — these
|
||||
* components represent the full extraction target.
|
||||
*/
|
||||
|
||||
import type { NodeEntityId, RerouteEntityId } from '../entityId'
|
||||
|
||||
/** Metadata for a group. */
|
||||
export interface GroupMeta {
|
||||
title: string
|
||||
font?: string
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
/** Visual properties for group rendering. */
|
||||
export interface GroupVisual {
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Entities contained within a group.
|
||||
*
|
||||
* Replaces LGraphGroup._children (Set<Positionable>) and
|
||||
* LGraphGroup._nodes (LGraphNode[]).
|
||||
*/
|
||||
export interface GroupChildren {
|
||||
nodeIds: ReadonlySet<NodeEntityId>
|
||||
rerouteIds: ReadonlySet<RerouteEntityId>
|
||||
}
|
||||