gdharness 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/build/cli.js +369 -97
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -9
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  4. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
  6. package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
  7. package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
  8. package/build/godot/addons/gdharness_editor/property_values.gd +161 -17
  9. package/build/godot/addons/gdharness_editor/reading.gd +37 -0
  10. package/build/godot/addons/gdharness_editor/scene_file.gd +3 -1
  11. package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
  12. package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
  13. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +367 -23
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +133 -39
  15. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +20 -3
  16. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +17 -13
  17. package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
  20. package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
  21. package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
  22. package/build/godot/operations/animation_reads.gd +11 -9
  23. package/build/godot/operations/classdb_queries.gd +13 -3
  24. package/build/godot/operations/declared_values.gd +116 -0
  25. package/build/godot/operations/dependencies.gd +109 -5
  26. package/build/godot/operations/editor_tools.gd +3 -0
  27. package/build/godot/operations/export_presets.gd +4 -6
  28. package/build/godot/operations/file_reads.gd +5 -0
  29. package/build/godot/operations/file_walk.gd +35 -0
  30. package/build/godot/operations/gdscript_analysis.gd +7 -2
  31. package/build/godot/operations/gdscript_authoring.gd +105 -2
  32. package/build/godot/operations/godot_operations.gd +2 -0
  33. package/build/godot/operations/import_pipeline.gd +39 -0
  34. package/build/godot/operations/input_actions.gd +73 -1
  35. package/build/godot/operations/project_config.gd +59 -3
  36. package/build/godot/operations/project_diagnostics.gd +27 -2
  37. package/build/godot/operations/property_values.gd +161 -17
  38. package/build/godot/operations/reading.gd +37 -0
  39. package/build/godot/operations/resource_files.gd +4 -1
  40. package/build/godot/operations/script_check.gd +16 -0
  41. package/build/godot/operations/serialisation.gd +4 -0
  42. package/build/godot/operations/value_shapes.gd +7 -0
  43. package/package.json +1 -1
@@ -252,7 +252,7 @@ func create_resource(args: Dictionary) -> Dictionary:
252
252
  if not folder.is_empty():
253
253
  return {"ok": false, "error": folder}
254
254
  var answer: Dictionary = {"ok": true, "resourcePath": res_path, "resourceType": asked_type}
255
- _properties.note_also_changed(answer)
255
+ _properties.note_write(answer)
256
256
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
257
257
  if not failed.is_empty():
258
258
  return {"ok": false, "error": failed}
@@ -290,7 +290,7 @@ func modify_resource(args: Dictionary) -> Dictionary:
290
290
  if not refused.is_empty():
291
291
  return {"ok": false, "error": refused}
292
292
  var answer: Dictionary = {"ok": true, "resourcePath": res_path}
293
- _properties.note_also_changed(answer)
293
+ _properties.note_write(answer)
294
294
  var previous: PackedByteArray = FileAccess.get_file_as_bytes(res_path)
295
295
  var failed: String = _save_checked(resource, res_path, properties.keys(), previous)
296
296
  if not failed.is_empty():
@@ -346,7 +346,19 @@ func create_tileset(args: Dictionary) -> Dictionary:
346
346
  var tileset_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
347
347
  if tileset_path == "res://":
348
348
  return {"ok": false, "error": "resourcePath is required"}
349
- var taken: String = _taken(tileset_path, "resource_edit op=create_tileset")
349
+ # modify sets properties and builds no atlas, so the other path is the only way on offered.
350
+ var taken: String = (
351
+ ""
352
+ if not FileAccess.file_exists(tileset_path)
353
+ else (
354
+ (
355
+ "%s already exists, and create_tileset builds a whole new TileSet, which would replace "
356
+ + "everything in it, the tiles a scene paints with among them. Give resourcePath a path "
357
+ + "nothing is at."
358
+ )
359
+ % tileset_path
360
+ )
361
+ )
350
362
  if not taken.is_empty():
351
363
  return {"ok": false, "error": taken}
352
364
 
@@ -374,9 +386,16 @@ func create_tileset(args: Dictionary) -> Dictionary:
374
386
  "ok": false,
375
387
  "error": "The TileSet did not take the atlas for %s" % atlas.texture.resource_path
376
388
  }
377
- made.append(
378
- {"sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()}
379
- )
389
+ var entry: Dictionary = {
390
+ "sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()
391
+ }
392
+ var blank: Array = built[2]
393
+ if not blank.is_empty():
394
+ entry["leftBlank"] = blank
395
+ var spare: String = built[3]
396
+ if not spare.is_empty():
397
+ entry["notInAnyTile"] = spare
398
+ made.append(entry)
380
399
 
381
400
  var folder: String = GdhSceneFile.make_folder_for(tileset_path)
382
401
  if not folder.is_empty():
@@ -389,11 +408,14 @@ func create_tileset(args: Dictionary) -> Dictionary:
389
408
  return {"ok": true, "tilesetPath": tileset_path, "sources": made}
390
409
 
391
410
 
392
- ## One atlas source from its description, with its tiles made, as [true, source] or [false, why].
411
+ ## One atlas source from its description, with its tiles made, as [true, source, the grid cells left
412
+ ## without a tile for being wholly transparent, a sentence on the pixels no tile covers or ""] or
413
+ ## [false, why].
393
414
  ##
394
415
  ## Tiles are made in every cell of the grid that has a pixel that is not transparent, which is
395
416
  ## what the editor offers to do when an atlas gets a texture. Without them the atlas held no tiles,
396
- ## and every cell painted from it drew nothing.
417
+ ## and every cell painted from it drew nothing. The cells passed over and the strip a tile size that
418
+ ## does not divide the picture leaves are said, since a count alone left a caller to work out which.
397
419
  func _atlas(entry: Variant, index: int) -> Array:
398
420
  if not entry is Dictionary:
399
421
  return [false, "sources[%d] must be an object with texture and tileSize" % index]
@@ -455,6 +477,7 @@ func _atlas(entry: Variant, index: int) -> Array:
455
477
  )
456
478
  ]
457
479
  var image: Image = texture.get_image()
480
+ var blank: Array = []
458
481
  for y: int in grid.y:
459
482
  for x: int in grid.x:
460
483
  var cell: Vector2i = Vector2i(x, y)
@@ -464,9 +487,33 @@ func _atlas(entry: Variant, index: int) -> Array:
464
487
  atlas.texture_region_size
465
488
  )
466
489
  if image != null and _blank(image, region):
490
+ blank.append({"x": x, "y": y})
467
491
  continue
468
492
  atlas.create_tile(cell)
469
- return [true, atlas]
493
+ var covered: Vector2i = atlas.margins + grid * tile_size + (grid - Vector2i.ONE) * atlas.separation
494
+ var left: Vector2i = Vector2i(texture.get_size()) - covered
495
+ var sides: PackedStringArray = []
496
+ if left.x > 0:
497
+ var _right: bool = sides.append("%d px on the right" % left.x)
498
+ if left.y > 0:
499
+ var _bottom: bool = sides.append("%d px at the bottom" % left.y)
500
+ var spare: String = (
501
+ ""
502
+ if sides.is_empty()
503
+ else (
504
+ "%s is %d by %d, so %s %s in no %d by %d tile."
505
+ % [
506
+ texture_path,
507
+ texture.get_width(),
508
+ texture.get_height(),
509
+ " and ".join(sides),
510
+ "is" if sides.size() == 1 else "are",
511
+ tile_size.x,
512
+ tile_size.y
513
+ ]
514
+ )
515
+ )
516
+ return [true, atlas, blank, spare]
470
517
 
471
518
 
472
519
  func _blank(image: Image, region: Rect2i) -> bool:
@@ -475,12 +522,15 @@ func _blank(image: Image, region: Rect2i) -> bool:
475
522
  return image.get_region(region).is_invisible()
476
523
 
477
524
 
478
- ## The source ids a tile set holds, for saying what a cell could have named instead.
479
- func _source_ids(tile_set: TileSet) -> Array[int]:
480
- var ids: Array[int] = []
525
+ ## The sources a tile set holds, as "source 0", "sources 0, 2" or "no sources", for saying what a
526
+ ## cell could have named instead.
527
+ func _sources_of(tile_set: TileSet) -> String:
528
+ var ids: PackedStringArray = []
481
529
  for index: int in tile_set.get_source_count():
482
- ids.append(tile_set.get_source_id(index))
483
- return ids
530
+ var _listed: bool = ids.append(str(tile_set.get_source_id(index)))
531
+ if ids.is_empty():
532
+ return "no sources"
533
+ return ("source " if ids.size() == 1 else "sources ") + ", ".join(ids)
484
534
 
485
535
 
486
536
  func set_tilemap_cells(args: Dictionary) -> Dictionary:
@@ -517,11 +567,20 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
517
567
  var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
518
568
 
519
569
  scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
520
- var wanted: Array = []
570
+ # Every cell is checked before any is placed, and every one refused is named: stopping at the first
571
+ # left a caller fixing one cell a call.
572
+ var checked: Array = []
573
+ var refusals: PackedStringArray = []
521
574
  for index: int in placed.size():
522
575
  var cell: Array = _cell(placed[index], index, tile_set)
523
576
  if not cell[0]:
524
- return scene.refuse(str(cell[1]))
577
+ var _named: bool = refusals.append(str(cell[1]))
578
+ checked.append(cell)
579
+ if not refusals.is_empty():
580
+ return scene.refuse("%s. Nothing was placed." % "; ".join(refusals))
581
+ var wanted: Array = []
582
+ var erased: int = 0
583
+ for cell: Array in checked:
525
584
  var at: Vector2i = cell[1]
526
585
  var source_id: int = cell[2]
527
586
  var atlas_coords: Vector2i = cell[3]
@@ -531,6 +590,8 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
531
590
  else:
532
591
  single.set_cell(at, source_id, atlas_coords, alternative)
533
592
  wanted.append([at, source_id, atlas_coords, alternative])
593
+ if source_id == -1:
594
+ erased += 1
534
595
 
535
596
  var at_path: String = scene.path_of(node)
536
597
  var written: Dictionary = scene.write(
@@ -546,7 +607,13 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
546
607
  if not written.is_empty():
547
608
  return written
548
609
 
549
- return {"ok": true, "layer": layer, "placed": wanted.size(), "requested": placed.size()}
610
+ return {
611
+ "ok": true,
612
+ "layer": layer,
613
+ "placed": wanted.size() - erased,
614
+ "erased": erased,
615
+ "requested": placed.size()
616
+ }
550
617
 
551
618
 
552
619
  ## The node at [param node_path] cells are painted on, with a TileSet, as [node, why not].
@@ -575,40 +642,55 @@ func _tile_node(scene: GdhSceneFile, node_path: String, layer: int) -> Array:
575
642
 
576
643
 
577
644
  ## One cell from its description, checked against [param tile_set], as
578
- ## [true, coords, source id, atlas coords, alternative] or [false, why].
645
+ ## [true, coords, source id, atlas coords, alternative] or [false, why]. A source of -1 erases the
646
+ ## cell, as set_cell takes it, and needs no atlasCoords.
579
647
  func _cell(entry: Variant, index: int, tile_set: TileSet) -> Array:
580
648
  if not entry is Dictionary:
581
649
  return [false, "cells[%d] must be an object with coords, sourceId and atlasCoords" % index]
582
650
  var cell: Dictionary = entry
583
- for field: String in ["coords", "sourceId", "atlasCoords"]:
651
+ for field: String in ["coords", "sourceId"]:
584
652
  if not cell.has(field):
585
653
  return [false, "cells[%d] has no %s" % [index, field]]
586
654
  var at: Array = _xy(cell["coords"], "cells[%d].coords" % index)
587
655
  if not at[0]:
588
656
  return at
657
+ var coords: Vector2i = at[1]
658
+ var named: String = "cells[%d] at (%d, %d)" % [index, coords.x, coords.y]
659
+ var source_id: int = GdhRead.as_int(cell["sourceId"], -2)
660
+ if source_id == -1:
661
+ return [true, coords, -1, Vector2i(-1, -1), -1]
662
+ if not cell.has("atlasCoords"):
663
+ return [
664
+ false, "%s has no atlasCoords, which every cell but an erased one (sourceId -1) needs" % named
665
+ ]
589
666
  var atlas_at: Array = _xy(cell["atlasCoords"], "cells[%d].atlasCoords" % index)
590
667
  if not atlas_at[0]:
591
668
  return atlas_at
592
- var source_id: int = GdhRead.as_int(cell["sourceId"], -1)
593
669
  if not tile_set.has_source(source_id):
594
- return [false, "The TileSet has no source %d; it has %s" % [source_id, _source_ids(tile_set)]]
670
+ return [
671
+ false,
672
+ (
673
+ "%s names source %d, and the TileSet has %s; sourceId -1 erases a cell"
674
+ % [named, source_id, _sources_of(tile_set)]
675
+ )
676
+ ]
595
677
  var source: TileSetSource = tile_set.get_source(source_id)
596
678
  var atlas_coords: Vector2i = atlas_at[1]
597
679
  var alternative: int = GdhRead.as_int(cell.get("alternativeTile", 0))
598
680
  if not source.has_tile(atlas_coords):
599
681
  return [
600
682
  false,
601
- (
602
- "cells[%d]: source %d has no tile at %s; it has %s. Nothing was placed."
603
- % [index, source_id, atlas_coords, _tiles_of(source)]
604
- )
683
+ "%s: source %d has no tile at %s; it has %s" % [named, source_id, atlas_coords, _tiles_of(source)]
605
684
  ]
606
685
  if not source.has_alternative_tile(atlas_coords, alternative):
607
686
  return [
608
687
  false,
609
- "The tile at %s in source %d has no alternative %d" % [atlas_coords, source_id, alternative]
688
+ (
689
+ "%s: the tile at %s in source %d has no alternative %d"
690
+ % [named, atlas_coords, source_id, alternative]
691
+ )
610
692
  ]
611
- return [true, at[1], source_id, atlas_coords, alternative]
693
+ return [true, coords, source_id, atlas_coords, alternative]
612
694
 
613
695
 
614
696
  ## The atlas coordinates [param source] has tiles at, the first twelve and how many more, or that it
@@ -722,13 +804,12 @@ func set_theme_color(args: Dictionary) -> Dictionary:
722
804
  if theme == null:
723
805
  return {"ok": false, "error": "No Theme at %s. Create one there first." % theme_path}
724
806
 
725
- var c: Dictionary = args.get("color", {})
726
- var color: Color = Color(
727
- GdhRead.as_float(c.get("r", 1.0), 1.0),
728
- GdhRead.as_float(c.get("g", 1.0), 1.0),
729
- GdhRead.as_float(c.get("b", 1.0), 1.0),
730
- GdhRead.as_float(c.get("a", 1.0), 1.0)
731
- )
807
+ # Read as a property write reads a colour, html code included, so a value scene_node takes for a
808
+ # Button's modulate is taken for its theme colour too; "#ff8000" was refused here alone.
809
+ var parsed: Array = _properties.colour_from(args.get("color", {}), "color")
810
+ if not parsed[0]:
811
+ return {"ok": false, "error": parsed[1]}
812
+ var color: Color = parsed[1]
732
813
  theme.set_color(color_name, control_type, color)
733
814
 
734
815
  var unsaved: String = _save_theme(theme, theme_path, "%s/colors/%s" % [control_type, color_name])
@@ -768,17 +849,18 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
768
849
  # Godot reads a size of 0 or less as unset and answers the theme's default, so -3 would be read
769
850
  # back as 16.
770
851
  var pixels: float = GdhRead.as_float(args.get("size"), 0.0)
771
- if not is_finite(pixels) or pixels < 1 or pixels != floorf(pixels):
772
- var shown: String = str(int(pixels)) if pixels == floorf(pixels) else str(pixels)
852
+ if not is_finite(pixels) or pixels != floorf(pixels):
853
+ return {"ok": false, "error": "size is a whole number of pixels, and %s is not one." % str(pixels)}
854
+ if pixels < 1:
773
855
  return {
774
856
  "ok": false,
775
857
  "error":
776
858
  (
777
859
  (
778
- "size must be a whole number of pixels, 1 or more, not %s: Godot reads 0 or less as no size "
779
- % shown
860
+ "size must be 1 or more, not %d: the theme would hold it, and Godot reads a size of 0 or "
861
+ + "less as unset and draws the default size instead."
780
862
  )
781
- + "and uses the theme's default instead."
863
+ % int(pixels)
782
864
  )
783
865
  }
784
866
  theme.set_font_size(font_size_name, control_type, int(pixels))
@@ -819,4 +901,16 @@ func _noting_variation(answer: Dictionary) -> Dictionary:
819
901
  )
820
902
  % [type, type]
821
903
  )
904
+ # A variation is any name, so a misspelt class is written all the same, and the classes nearest
905
+ # it are what tells a caller which one was meant.
906
+ var themed: PackedStringArray = []
907
+ for known: String in ClassDB.get_class_list():
908
+ if ClassDB.is_parent_class(known, "Control") or ClassDB.is_parent_class(known, "Window"):
909
+ var _added: bool = themed.append(known)
910
+ var close: PackedStringArray = GdhRead.nearest(type, themed)
911
+ if not close.is_empty():
912
+ answer["note"] += (
913
+ " If a class was meant, the nearest %s %s."
914
+ % ["is" if close.size() == 1 else "are", ", ".join(close)]
915
+ )
822
916
  return answer
@@ -4,6 +4,7 @@ extends RefCounted
4
4
  ## What a new node in a scene is made or copied from, what the scene owns of it, and where among its
5
5
  ## siblings a node may go.
6
6
 
7
+ const GdhFileWalk = preload("../file_walk.gd")
7
8
  const GdhRead = preload("../reading.gd")
8
9
  const GdhPropertyValues = preload("../property_values.gd")
9
10
 
@@ -17,11 +18,15 @@ static func what_to_add(node_type: String, instance_path: String, scene_path: St
17
18
  "add takes nodeType for a node of a class, or instancePath for a scene, and one of them only."
18
19
  ]
19
20
  if not instance_path.is_empty():
21
+ # Asked before the cycle walk, which reads the file's dependencies and so loaded a file that
22
+ # was not there, laying the engine's error and a backtrace beneath the refusal.
23
+ if not ResourceLoader.exists(instance_path, "PackedScene"):
24
+ var said: String = "No scene at %s to instance" % instance_path
25
+ var offered: String = GdhFileWalk.new().with_nearest_files(said, instance_path)
26
+ return [null, offered if offered != said else said + "."]
20
27
  var cycle: String = _instancing_cycle(scene_path, instance_path)
21
28
  if not cycle.is_empty():
22
29
  return [null, cycle]
23
- if not ResourceLoader.exists(instance_path, "PackedScene"):
24
- return [null, "No scene at %s to instance." % instance_path]
25
30
  var packed: PackedScene = ResourceLoader.load(instance_path, "PackedScene") as PackedScene
26
31
  if packed == null or not packed.can_instantiate():
27
32
  return [null, "%s does not load as a scene that can be instanced." % instance_path]
@@ -35,12 +40,24 @@ static func what_to_add(node_type: String, instance_path: String, scene_path: St
35
40
  )
36
41
  ]
37
42
  if not ClassDB.class_exists(node_type) or not ClassDB.is_parent_class(node_type, "Node"):
38
- return [null, "nodeType must be a node class, and %s is not one." % node_type]
43
+ return [null, not_a_node_class("nodeType", node_type)]
39
44
  if not ClassDB.can_instantiate(node_type):
40
45
  return [null, "%s cannot be instantiated." % node_type]
41
46
  return [null, ""]
42
47
 
43
48
 
49
+ ## The refusal for [param given], sent as [param argument], which names no node class, with the
50
+ ## node classes nearest it, so "Node2DD" is offered Node2D.
51
+ static func not_a_node_class(argument: String, given: String) -> String:
52
+ var said: String = "%s must be a node class, and %s is not one" % [argument, given]
53
+ var classes: PackedStringArray = ClassDB.get_inheriters_from_class("Node")
54
+ var _added: bool = classes.append("Node")
55
+ var close: PackedStringArray = GdhRead.nearest(given, classes)
56
+ if close.is_empty():
57
+ return said + "."
58
+ return "%s; the nearest %s %s." % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
59
+
60
+
44
61
  ## A new node of [param node_type], or an instance of [param packed] when there is one. The instance
45
62
  ## is made in the editor's edit state, so the pack writes it as an instance of its file, and owning
46
63
  ## its root leaves its own nodes to the instanced scene.
@@ -60,17 +60,15 @@ func _find_node(root: Node, path: String) -> Node:
60
60
  ## Why the property or signal [param missing] could not be found on [param node], adding the one
61
61
  ## cause the name alone does not show: a script that does not compile leaves the editor holding a
62
62
  ## placeholder with no properties or signals at all, and the refusal named the property instead.
63
+ ## Such a script has no engine class beneath it. Compiling a copy of the source to find out failed on
64
+ ## every script with a class_name, since the copy hides the global class the original declares.
63
65
  func _missing_on(node: Node, missing: String) -> String:
64
66
  var script: Script = node.get_script()
65
- if script == null or not script.has_source_code():
66
- return missing
67
- var probe: GDScript = GDScript.new()
68
- probe.source_code = script.source_code
69
- if probe.reload() == OK:
67
+ if script == null or not str(script.get_instance_base_type()).is_empty():
70
68
  return missing
71
69
  return (
72
70
  "%s. Its script, %s, does not compile, so none of what it declares is known here."
73
- % [missing, script.resource_path]
71
+ % [missing.trim_suffix("."), script.resource_path]
74
72
  )
75
73
 
76
74
 
@@ -204,9 +202,7 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
204
202
  # A class that is not a node went into a Node variable, which is a runtime error the caller
205
203
  # heard about only as "Invalid tool result".
206
204
  if not ClassDB.class_exists(root_node_type) or not ClassDB.is_parent_class(root_node_type, "Node"):
207
- return {
208
- "ok": false, "error": "rootNodeType must be a node class, and %s is not one." % root_node_type
209
- }
205
+ return {"ok": false, "error": GdhPlacing.not_a_node_class("rootNodeType", root_node_type)}
210
206
  if not ClassDB.can_instantiate(root_node_type):
211
207
  return {"ok": false, "error": "%s cannot be instantiated." % root_node_type}
212
208
  var unfit: String = GdhSceneFile.unfit_name(root_node_name)
@@ -245,6 +241,7 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
245
241
  # name is no property the write compares.
246
242
  root.name = root_node_name
247
243
 
244
+ var had_folder: bool = DirAccess.dir_exists_absolute(scene_path.get_base_dir())
248
245
  var folder: String = GdhSceneFile.make_folder_for(scene_path)
249
246
  if not folder.is_empty():
250
247
  root.free()
@@ -253,7 +250,12 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
253
250
  if not written.is_empty():
254
251
  return written
255
252
 
256
- return {"ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name}
253
+ var answer: Dictionary = {
254
+ "ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name
255
+ }
256
+ if not had_folder:
257
+ answer["madeFolder"] = scene_path.get_base_dir()
258
+ return answer
257
259
 
258
260
 
259
261
  func list_scene_nodes(args: Dictionary) -> Dictionary:
@@ -343,7 +345,7 @@ func add_node(args: Dictionary) -> Dictionary:
343
345
  "nodePath": new_path,
344
346
  "index": new_node.get_index(),
345
347
  }
346
- _properties.note_also_changed(answer, new_node)
348
+ _properties.note_write(answer, new_node)
347
349
  var written: Dictionary = scene.write(
348
350
  func(saved: Node) -> String:
349
351
  return _kept(
@@ -608,7 +610,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
608
610
  held[str(key)] = _readable(node, _properties.read_at(node, str(key)))
609
611
  var new_path: String = scene.path_of(node)
610
612
  var answer: Dictionary = {"ok": true, "nodePath": new_path, "properties": held}
611
- _properties.note_also_changed(answer, node)
613
+ _properties.note_write(answer, node)
612
614
  var carried: GdhPaths = GdhPaths.new()
613
615
  var _rewritten: int = carried.carry(scene, {old_path: new_path} if new_path != old_path else {})
614
616
  var written: Dictionary = scene.write(
@@ -848,7 +850,8 @@ func _connection_ends(
848
850
  if not foreign.is_empty():
849
851
  return [null, foreign]
850
852
  if not source.has_signal(signal_name):
851
- return [null, _missing_on(source, "%s has no signal %s" % [source_node_path, signal_name])]
853
+ var said: String = "%s has no signal %s" % [source_node_path, signal_name]
854
+ return [null, _missing_on(source, GdhRead.offered(said, signal_name, GdhRead.signals_of(source)))]
852
855
  return [source, target]
853
856
 
854
857
 
@@ -920,6 +923,7 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
920
923
 
921
924
  return {
922
925
  "ok": true,
926
+ "removed": true,
923
927
  "sourceNodePath": from,
924
928
  "signalName": signal_name,
925
929
  "targetNodePath": to,
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
82
82
  ##
83
83
  ## The refusal names only the two types, and the key is what the caller has to take out.
84
84
  static func remedy(value: Variant, type: int) -> String:
85
+ if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
86
+ var number: Variant = value
87
+ if value is float:
88
+ var whole: float = value
89
+ if is_finite(whole) and whole == floorf(whole):
90
+ number = int(whole)
91
+ return ' Text is sent in quotes, as "%s".' % str(number)
85
92
  var shape: String = written_as(type)
86
93
  if shape.is_empty():
87
94
  return ""
@@ -164,6 +164,43 @@ static func not_of_kind(
164
164
  return told + " The scene's %ss are at %s." % [kind, shown]
165
165
 
166
166
 
167
+ ## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
168
+ ## refusal offers for one that is not there, as a typo usually is one of them.
169
+ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
170
+ var wanted: String = word.to_lower()
171
+ var close: Array[String] = []
172
+ for name: String in names:
173
+ if name.to_lower().similarity(wanted) >= 0.5:
174
+ close.append(name)
175
+ close.sort_custom(
176
+ func(a: String, b: String) -> bool:
177
+ return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
178
+ )
179
+ return PackedStringArray(close.slice(0, 3))
180
+
181
+
182
+ ## [param said], the refusal for [param word], which is not among [param names], with those nearest
183
+ ## it when any are, or else every one of them, so the caller has a name to send instead.
184
+ static func offered(said: String, word: String, names: Array) -> String:
185
+ var every: PackedStringArray = PackedStringArray(names)
186
+ var close: PackedStringArray = nearest(word, every)
187
+ if close.is_empty():
188
+ return "%s; it has %s" % [said, ", ".join(every)]
189
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
190
+
191
+
192
+ ## The signals [param object] has that a caller can connect, its own class's first: the engine
193
+ ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
194
+ ## starting with an underscore are the engine's own.
195
+ static func signals_of(object: Object) -> Array:
196
+ var names: Array = []
197
+ for entry: Dictionary in object.get_signal_list():
198
+ var name: String = str(entry["name"])
199
+ if not name.begins_with("_"):
200
+ names.push_front(name)
201
+ return names
202
+
203
+
167
204
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
168
205
  static func with_article(word: String) -> String:
169
206
  return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
@@ -71,10 +71,36 @@ static func node_at(root: Node, path: String) -> Dictionary:
71
71
 
72
72
  var node: Node = root.get_node_or_null(path)
73
73
  if node == null:
74
- return {"type": "error", "message": "Node not found: " + path}
74
+ return {"type": "error", "message": _not_found(root, path)}
75
75
  return {"node": node}
76
76
 
77
77
 
78
+ ## "Node not found" for [param path], with the deepest node of it the game has and the children that
79
+ ## node has instead, as a scene's refusal names them: a mistyped step was otherwise found by reading
80
+ ## the whole tree.
81
+ static func _not_found(root: Node, path: String) -> String:
82
+ var said: String = "Node not found: " + path
83
+ var parts: PackedStringArray = path.split("/", false)
84
+ var at: Node = root
85
+ var step: int = 0
86
+ if path.begins_with("/"):
87
+ if parts.is_empty() or parts[0] != str(root.name):
88
+ return said
89
+ step = 1
90
+ while step < parts.size():
91
+ var below: Node = at.get_node_or_null(NodePath(parts[step]))
92
+ if below == null:
93
+ break
94
+ at = below
95
+ step += 1
96
+ if step >= parts.size():
97
+ return said
98
+ var names: PackedStringArray = []
99
+ for child: Node in at.get_children():
100
+ var _named: bool = names.append(str(child.name))
101
+ return GdhRead.missing_below(said, "", PackedStringArray([str(at.get_path())]), parts[step], names)
102
+
103
+
78
104
  ## How many process frames a whole press holds before its release, from `holdFrames`: one unless
79
105
  ## asked, as the halves of a press have always been a frame apart. Held inside the game because the
80
106
  ## time between a press, a wait and a release sent as three calls counts as held too: a 60-frame
@@ -410,6 +410,10 @@ func fitted(value: Variant, type: int) -> Variant:
410
410
  var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
411
411
  if not simple.has(type) or not simple.has(typeof(rebuilt)):
412
412
  return rebuilt
413
+ # A number or a truth value stays as sent where text is wanted, for the refusal to name. Converted,
414
+ # 1 arrived as the double it is in JSON and was saved as "1.0", which is not the text anyone sends.
415
+ if type == TYPE_STRING and not rebuilt is String:
416
+ return rebuilt
413
417
 
414
418
  # Digits are read as digits, since read as JSON they become a double and one past 2^53 a
415
419
  # neighbour of the number written.
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
82
82
  ##
83
83
  ## The refusal names only the two types, and the key is what the caller has to take out.
84
84
  static func remedy(value: Variant, type: int) -> String:
85
+ if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
86
+ var number: Variant = value
87
+ if value is float:
88
+ var whole: float = value
89
+ if is_finite(whole) and whole == floorf(whole):
90
+ number = int(whole)
91
+ return ' Text is sent in quotes, as "%s".' % str(number)
85
92
  var shape: String = written_as(type)
86
93
  if shape.is_empty():
87
94
  return ""
@@ -81,15 +81,7 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
81
81
  listed.append(summary)
82
82
  return {"ok": true, "animations": listed}
83
83
  if not player.has_animation(StringName(animation_name)):
84
- var names: PackedStringArray = player.get_animation_list()
85
- return {
86
- "ok": false,
87
- "error":
88
- (
89
- "%s has no animation %s. It has: %s."
90
- % [player.name, animation_name, ", ".join(names) if not names.is_empty() else "none"]
91
- )
92
- }
84
+ return {"ok": false, "error": no_animation(player, str(player.name), animation_name)}
93
85
  var anim: Animation = player.get_animation(StringName(animation_name))
94
86
  var answer: Dictionary = _summary(anim)
95
87
  answer["ok"] = true
@@ -102,6 +94,16 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
102
94
  return answer
103
95
 
104
96
 
97
+ ## Why [param player], called [param called], cannot be asked for [param animation_name], with the
98
+ ## animations it has.
99
+ static func no_animation(player: AnimationPlayer, called: String, animation_name: String) -> String:
100
+ var names: PackedStringArray = player.get_animation_list()
101
+ return (
102
+ "%s has no animation %s. It has: %s."
103
+ % [called, animation_name, ", ".join(names) if not names.is_empty() else "none"]
104
+ )
105
+
106
+
105
107
  static func _summary(anim: Animation) -> Dictionary:
106
108
  return {
107
109
  "length": anim.length,
@@ -183,13 +183,23 @@ func _not_found(wanted: String) -> Dictionary:
183
183
  )
184
184
  )
185
185
  )
186
+ # The usual miss is a spelling, one letter off a class the engine has.
187
+ var close: PackedStringArray = GdhRead.nearest(wanted, ClassDB.get_class_list())
188
+ var offered: String = (
189
+ ""
190
+ if close.is_empty()
191
+ else (
192
+ " The nearest engine %s %s."
193
+ % ["class is" if close.size() == 1 else "classes are", ", ".join(close)]
194
+ )
195
+ )
186
196
  return _log.failure(
187
197
  (
188
198
  (
189
- "Class not found: %s. editor_classes reads the engine's ClassDB, so a class_name a project "
190
- + "script declares is not in it either; script_info structure reads one."
199
+ "Class not found: %s.%s editor_classes reads the engine's ClassDB, so a class_name a "
200
+ + "project script declares is not in it either; script_info structure reads one."
191
201
  )
192
- % wanted
202
+ % [wanted, offered]
193
203
  )
194
204
  )
195
205