gdharness 0.11.1 → 0.12.1

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.
package/README.md CHANGED
@@ -73,11 +73,12 @@ and integration suites run against a real engine on all three before anything is
73
73
 
74
74
  What each kind of release means here:
75
75
 
76
- - **Patch**: an answer that was wrong is now right. The output can change, because a wrong answer
77
- corrected is a fix rather than a feature, and leaving one wrong to protect whoever parsed it is
78
- how a tool stops being worth asking.
79
- - **Minor**: a tool, an op, an argument or a field was added, or behaviour a caller can see
80
- changed.
76
+ - **Patch**: nearly everything. An answer that was wrong is now right, and the output may change
77
+ shape for it, because a wrong answer corrected is a fix rather than a feature and leaving one
78
+ wrong to protect whoever parsed it is how a tool stops being worth asking. An addition nothing
79
+ has to adapt to is a patch too: a new argument, a new field, an argument reaching further than it
80
+ did. Ignore all of it and your calls still work.
81
+ - **Minor**: a new tool or a new op, meaning this does something it could not do before.
81
82
  - **Major**: a tool, an op, an argument or a field was renamed or taken away. Nothing else earns
82
83
  one.
83
84
 
package/build/cli.js CHANGED
@@ -15799,7 +15799,7 @@ var init_tool_definitions = __esm(() => {
15799
15799
  property: {
15800
15800
  type: "string",
15801
15801
  ops: ["property", "find"],
15802
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
15802
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step that is not there is named, and says what was there instead.'
15803
15803
  },
15804
15804
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15805
15805
  includeProperties: {
@@ -15873,7 +15873,7 @@ var init_tool_definitions = __esm(() => {
15873
15873
  property: {
15874
15874
  type: "string",
15875
15875
  ops: ["set"],
15876
- description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15876
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15877
15877
  },
15878
15878
  value: {
15879
15879
  ops: ["set"],
@@ -15883,7 +15883,7 @@ var init_tool_definitions = __esm(() => {
15883
15883
  method: {
15884
15884
  type: "string",
15885
15885
  ops: ["call"],
15886
- description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
15886
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, and a list or a map is stepped into by index or key, "_game:run:roster:0:retire".'
15887
15887
  },
15888
15888
  args: {
15889
15889
  type: "array",
@@ -39477,6 +39477,9 @@ async function enablePlugins(engine, projectPath, names) {
39477
39477
  }
39478
39478
  return outcomes;
39479
39479
  }
39480
+ function autoloadIsOurs(named) {
39481
+ return named === null || named === `res://${RUNTIME_AUTOLOAD.path}`;
39482
+ }
39480
39483
  async function setRuntime(engine, projectPath, on) {
39481
39484
  return on ? await runOperation2(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation2(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
39482
39485
  }
@@ -39533,7 +39536,9 @@ function inspectProject(projectPath) {
39533
39536
  }
39534
39537
  }
39535
39538
  const autoload = settings["autoload"];
39536
- const runtimeAutoload = autoload !== undefined && typeof readString(autoload, RUNTIME_AUTOLOAD.name) === "string";
39539
+ const registered = autoload === undefined ? undefined : readString(autoload, RUNTIME_AUTOLOAD.name);
39540
+ const runtimeAutoload = typeof registered === "string";
39541
+ const runtimeAutoloadPath = typeof registered === "string" ? registered.replace(/^\*/, "") : null;
39537
39542
  const named = autoloadPaths(settings);
39538
39543
  const tracked = trackedByGit(projectPath, [...named.values()]);
39539
39544
  for (const [name, path] of named) {
@@ -39553,6 +39558,7 @@ function inspectProject(projectPath) {
39553
39558
  addons,
39554
39559
  pluginsEnabled,
39555
39560
  runtimeAutoload,
39561
+ runtimeAutoloadPath,
39556
39562
  staleClasses,
39557
39563
  classCacheExists: cached !== null,
39558
39564
  problems
@@ -39839,6 +39845,18 @@ function said2(outcome, what) {
39839
39845
  console.error(` engine ${entry.severity}: ${entry.text}`);
39840
39846
  }
39841
39847
  }
39848
+ async function registerRuntime(godot, projectPath) {
39849
+ const named = inspectProject(projectPath).runtimeAutoloadPath;
39850
+ if (autoloadIsOurs(named)) {
39851
+ said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
39852
+ console.log(`${RUNTIME_AUTOLOAD.name} autoload registered at res://${RUNTIME_AUTOLOAD.path}`);
39853
+ return;
39854
+ }
39855
+ console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
39856
+ if (!existsSync13(join17(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
39857
+ console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
39858
+ }
39859
+ }
39842
39860
  var SETUP_FLAGS = new Set(["--runtime", "--no-runtime", "--no-connect", "--no-skill", "--json", "--yes"]);
39843
39861
  function namedHarnesses() {
39844
39862
  const flags = args.filter((arg) => arg.startsWith("--") && !SETUP_FLAGS.has(arg));
@@ -39941,8 +39959,7 @@ async function setup() {
39941
39959
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
39942
39960
  }
39943
39961
  if (runtime) {
39944
- said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
39945
- console.log(`${RUNTIME_AUTOLOAD.name} autoload registered`);
39962
+ await registerRuntime(godot, projectPath);
39946
39963
  }
39947
39964
  const classes = await runOperation(godot, "refresh_class_cache", {}, projectPath);
39948
39965
  said2(classes, "rebuilding the class list");
@@ -40042,7 +40059,7 @@ async function upgrade() {
40042
40059
  said2(outcome, `enabling ${EDITOR_PLUGINS[index] ?? ""}`);
40043
40060
  }
40044
40061
  if (before.runtimeAutoload) {
40045
- said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
40062
+ await registerRuntime(godot, projectPath);
40046
40063
  }
40047
40064
  said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
40048
40065
  const launch = launchFor(version, godot.godotPath, projectPath);
@@ -40092,8 +40109,12 @@ async function runtime3() {
40092
40109
  }
40093
40110
  const projectPath = projectArgument(2);
40094
40111
  const godot = await engine();
40095
- said2(await setRuntime(godot, projectPath, state === "on"), `turning the runtime ${state}`);
40096
- console.log(`${RUNTIME_AUTOLOAD.name} autoload ${state === "on" ? "registered" : "removed"}`);
40112
+ if (state === "on") {
40113
+ await registerRuntime(godot, projectPath);
40114
+ return;
40115
+ }
40116
+ said2(await setRuntime(godot, projectPath, false), "turning the runtime off");
40117
+ console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
40097
40118
  }
40098
40119
  async function classes() {
40099
40120
  const projectPath = projectArgument(1);
@@ -172,12 +172,12 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
172
172
  if reached.has("message"):
173
173
  entry["has_property"] = false
174
174
  return entry
175
- var holder: Object = reached["holder"]
175
+ var holder: Variant = reached["holder"]
176
176
  var named: String = reached["name"]
177
- var has: bool = _has_property(holder, named)
177
+ var has: bool = _can_read(holder, named)
178
178
  entry["has_property"] = has
179
179
  if has:
180
- entry["value"] = _values.serialize(holder.get(named))
180
+ entry["value"] = _values.serialize(_read(holder, named))
181
181
  return entry
182
182
 
183
183
 
@@ -500,18 +500,18 @@ func get_property(params: Dictionary) -> Dictionary:
500
500
  if reached.has("message"):
501
501
  return reached
502
502
 
503
- var holder: Object = reached["holder"]
503
+ var holder: Variant = reached["holder"]
504
504
  var named: String = reached["name"]
505
- # Asked of the property list rather than read and compared to null, because a property the
506
- # holder does not have and a property that is null both read as null.
507
- if not _has_property(holder, named):
508
- return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
505
+ # Asked of the holder rather than read and compared to null, because a property the holder does
506
+ # not have and a property that is null both read as null.
507
+ if not _can_read(holder, named):
508
+ return {"type": "error", "message": _nothing_there(holder, named, str(reached["called"]))}
509
509
 
510
510
  return {
511
511
  "type": "property",
512
512
  "path": node_path,
513
513
  "property": property,
514
- "value": _values.serialize(holder.get(named)),
514
+ "value": _values.serialize(_read(holder, named)),
515
515
  }
516
516
 
517
517
 
@@ -532,21 +532,115 @@ func get_property(params: Dictionary) -> Dictionary:
532
532
  ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
533
533
  func _reached(node: Node, node_path: String, reaching: String) -> Dictionary:
534
534
  var parts: PackedStringArray = reaching.split(":")
535
- var holder: Object = node
535
+ var holder: Variant = node
536
536
  var called: String = node_path
537
537
  for step: int in parts.size() - 1:
538
538
  var named: String = parts[step]
539
- if not _has_property(holder, named):
540
- return {"type": "error", "message": "%s has no property %s" % [called, named]}
541
- var next: Variant = holder.get(named)
539
+ if not _can_read(holder, named):
540
+ return {"type": "error", "message": _nothing_there(holder, named, called)}
541
+ holder = _read(holder, named)
542
542
  called = "%s:%s" % [called, named]
543
- if not next is Object:
543
+ if not _can_hold(holder):
544
544
  var wanted: String = parts[step + 1]
545
545
  return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
546
- holder = next
547
546
  return {"holder": holder, "name": parts[parts.size() - 1], "called": called}
548
547
 
549
548
 
549
+ ## Whether a step can be taken into [param value] at all: an object, a list or a map can be walked
550
+ ## into, and a number or a string is where a path ends whether the caller meant it to or not.
551
+ static func _can_hold(value: Variant) -> bool:
552
+ return value is Object or value is Array or value is Dictionary
553
+
554
+
555
+ ## Whether [param named] is something [param holder] has.
556
+ ##
557
+ ## Three kinds of holder, because the state a game keeps is not all properties of objects: a roster
558
+ ## is a list and an in-tray is a map, and a path that could not step into either stopped at the
559
+ ## first one. An index reads the way a property does, and a negative one counts from the end the
560
+ ## way GDScript's own does, so reading the last of something does not mean asking how many first.
561
+ static func _can_read(holder: Variant, named: String) -> bool:
562
+ if holder is Array:
563
+ var items: Array = holder
564
+ return _index_in(named, items.size()) != -1
565
+ if holder is Dictionary:
566
+ var map: Dictionary = holder
567
+ return map.has(named) or map.has(StringName(named))
568
+ if holder is Object:
569
+ return _has_property(holder, named)
570
+ return false
571
+
572
+
573
+ ## Put [param value] where [param named] points. Only ever called once [method _can_read] agrees,
574
+ ## and lists and maps are reference types, so writing into one reaches the game's own copy.
575
+ static func _write(holder: Variant, named: String, value: Variant) -> void:
576
+ if holder is Array:
577
+ var items: Array = holder
578
+ items[_index_in(named, items.size())] = value
579
+ return
580
+ if holder is Dictionary:
581
+ var map: Dictionary = holder
582
+ # Written back under the key it was found under, since a map keyed by StringName and one
583
+ # keyed by String both read the same way and a write to the wrong one adds a second entry.
584
+ if map.has(named):
585
+ map[named] = value
586
+ else:
587
+ map[StringName(named)] = value
588
+ return
589
+ var object: Object = holder
590
+ object.set(named, value)
591
+
592
+
593
+ ## What [param holder] holds under [param named]. Only ever called once [method _can_read] agrees.
594
+ static func _read(holder: Variant, named: String) -> Variant:
595
+ if holder is Array:
596
+ var items: Array = holder
597
+ return items[_index_in(named, items.size())]
598
+ if holder is Dictionary:
599
+ var map: Dictionary = holder
600
+ if map.has(named):
601
+ return map[named]
602
+ return map[StringName(named)]
603
+ var object: Object = holder
604
+ return object.get(named)
605
+
606
+
607
+ ## Where [param named] lands in a list of [param size], or -1 for a step that is not in it.
608
+ ##
609
+ ## -1 for "nowhere" is safe because every answer this gives is an index into a list that long, so a
610
+ ## real one is never negative by the time it is returned.
611
+ static func _index_in(named: String, size: int) -> int:
612
+ if not named.is_valid_int():
613
+ return -1
614
+ var index: int = int(named)
615
+ if index < 0:
616
+ index += size
617
+ return index if index >= 0 and index < size else -1
618
+
619
+
620
+ ## Why a step could not be taken, said in the terms of what was actually there.
621
+ ##
622
+ ## "has no property 0" about a list is a true sentence that sends somebody looking for a property,
623
+ ## so a list says how long it is and a map says what it is keyed by. The step is named rather than
624
+ ## the whole path, because the step is the typo.
625
+ static func _nothing_there(holder: Variant, named: String, called: String) -> String:
626
+ if holder is Array:
627
+ var items: Array = holder
628
+ return "%s is a list of %d, so there is no %s in it" % [called, items.size(), named]
629
+ if holder is Dictionary:
630
+ var map: Dictionary = holder
631
+ var keys: Array = map.keys()
632
+ # Eight of them, because a map keyed by something unexpected is told by the first few and a
633
+ # map of two hundred would bury the sentence saying which key was missing.
634
+ var some: PackedStringArray = []
635
+ for key: Variant in keys.slice(0, 8):
636
+ some.append(str(key))
637
+ var rest: String = ""
638
+ if keys.size() > 8:
639
+ rest = " and %d more" % [keys.size() - 8]
640
+ return "%s has no key %s; it is keyed by %s%s" % [called, named, ", ".join(some), rest]
641
+ return "%s has no property %s" % [called, named]
642
+
643
+
550
644
  ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
551
645
  ## property read back as null answers it wrongly.
552
646
  static func _has_property(holder: Object, named: String) -> bool:
@@ -576,17 +670,19 @@ func set_property(params: Dictionary) -> Dictionary:
576
670
  if reached.has("message"):
577
671
  return reached
578
672
 
579
- var holder: Object = reached["holder"]
673
+ var holder: Variant = reached["holder"]
580
674
  var named: String = reached["name"]
581
- var old_value: Variant = holder.get(named)
582
- holder.set(named, _values.fitted(value, typeof(old_value)))
675
+ if not _can_read(holder, named):
676
+ return {"type": "error", "message": _nothing_there(holder, named, str(reached["called"]))}
677
+ var old_value: Variant = _read(holder, named)
678
+ _write(holder, named, _values.fitted(value, typeof(old_value)))
583
679
 
584
680
  return {
585
681
  "type": "property_set",
586
682
  "path": node_path,
587
683
  "property": property,
588
684
  "old_value": _values.serialize(old_value),
589
- "new_value": _values.serialize(holder.get(named))
685
+ "new_value": _values.serialize(_read(holder, named))
590
686
  }
591
687
 
592
688
 
@@ -610,6 +706,19 @@ func call_method(params: Dictionary) -> Dictionary:
610
706
  if reached.has("message"):
611
707
  return reached
612
708
 
709
+ # A list or a map can be stepped through on the way, and is not a thing with methods when the
710
+ # path stops on one: said in those terms rather than as "has no method", which reads as a
711
+ # misspelling of a method that was never going to be there.
712
+ if not reached["holder"] is Object:
713
+ var sort: String = "a list" if reached["holder"] is Array else "a map"
714
+ return {
715
+ "type": "error",
716
+ "message":
717
+ (
718
+ "%s is %s, which has no methods: name the element to call it on, as in %s:0:%s"
719
+ % [reached["called"], sort, reached["called"], reached["name"]]
720
+ )
721
+ }
613
722
  var holder: Object = reached["holder"]
614
723
  var named: String = reached["name"]
615
724
  if not holder.has_method(named):
@@ -136,7 +136,36 @@ func _serialize_object(value: Object) -> Dictionary:
136
136
  var node: Node = value
137
137
  if node.is_inside_tree():
138
138
  return {"_type": "Node", "class": node.get_class(), "path": str(node.get_path())}
139
- return {"_type": "Object", "class": value.get_class()}
139
+ return _describe_object(value)
140
+
141
+
142
+ ## A plain object, said in enough detail to tell it from the one beside it.
143
+ ##
144
+ ## `{"class": "RefCounted"}` was all of it, so a roster of twelve people came back as the word
145
+ ## RefCounted twelve times: correct, useless, and indistinguishable from a list of twelve of
146
+ ## anything else. The state worth looking at in a game is lists of these, so each one carries what
147
+ ## it is (the `class_name` its script declares, or the script's path when it has no name), what it
148
+ ## says about itself when the game has given it a `_to_string`, and an id that is at least
149
+ ## different per object. None of it is invented: every field is left out when there is nothing to
150
+ ## put in it, because a key holding "" is a worse answer than a key that is not there.
151
+ func _describe_object(value: Object) -> Dictionary:
152
+ var described: Dictionary = {"_type": "Object", "class": value.get_class()}
153
+ # Asked as a Variant and narrowed, because get_script answers one and an object without a
154
+ # script answers null: casting that to Script is the unsafe cast the engine refuses to compile.
155
+ var attached: Variant = value.get_script()
156
+ if attached is Script:
157
+ var script: Script = attached
158
+ var declared: String = String(script.get_global_name())
159
+ if not declared.is_empty():
160
+ described["script_class"] = declared
161
+ elif not script.resource_path.is_empty():
162
+ described["script"] = script.resource_path
163
+ # Only when the game wrote one. The default is "<RefCounted#31098236>", which is the id again
164
+ # in a costume and reads as if the object had been asked and had nothing to say.
165
+ if value.has_method("_to_string"):
166
+ described["says"] = value.to_string()
167
+ described["id"] = value.get_instance_id()
168
+ return described
140
169
 
141
170
 
142
171
  ## Rebuilds a Godot value from the shape serialize gave it.
package/build/index.js CHANGED
@@ -28603,7 +28603,7 @@ var TOOL_SPECS = [
28603
28603
  property: {
28604
28604
  type: "string",
28605
28605
  ops: ["property", "find"],
28606
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
28606
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step that is not there is named, and says what was there instead.'
28607
28607
  },
28608
28608
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28609
28609
  includeProperties: {
@@ -28677,7 +28677,7 @@ var TOOL_SPECS = [
28677
28677
  property: {
28678
28678
  type: "string",
28679
28679
  ops: ["set"],
28680
- description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28680
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28681
28681
  },
28682
28682
  value: {
28683
28683
  ops: ["set"],
@@ -28687,7 +28687,7 @@ var TOOL_SPECS = [
28687
28687
  method: {
28688
28688
  type: "string",
28689
28689
  ops: ["call"],
28690
- description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
28690
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, and a list or a map is stepped into by index or key, "_game:run:roster:0:retire".'
28691
28691
  },
28692
28692
  args: {
28693
28693
  type: "array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",