gdharness 1.0.27 → 1.0.28

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/build/cli.js CHANGED
@@ -16538,7 +16538,7 @@ var init_tool_definitions = __esm(() => {
16538
16538
  says: {
16539
16539
  type: "string",
16540
16540
  ops: ["find"],
16541
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
16541
+ description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
16542
16542
  },
16543
16543
  limit: {
16544
16544
  type: "integer",
@@ -16567,7 +16567,7 @@ var init_tool_definitions = __esm(() => {
16567
16567
  operations: {
16568
16568
  tree: { summary: "the live scene tree", requires: [] },
16569
16569
  text: {
16570
- summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
16570
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
16571
16571
  requires: []
16572
16572
  },
16573
16573
  find: {
@@ -16716,7 +16716,7 @@ var init_tool_definitions = __esm(() => {
16716
16716
  type: "string",
16717
16717
  ops: ["text", "choose"],
16718
16718
  blank: true,
16719
- description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
16719
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
16720
16720
  },
16721
16721
  replace: {
16722
16722
  type: "boolean",
@@ -16778,7 +16778,7 @@ var init_tool_definitions = __esm(() => {
16778
16778
  requires: ["keycode"]
16779
16779
  },
16780
16780
  text: {
16781
- summary: "type a string into the field being edited, a character at a time, and say what it landed in",
16781
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
16782
16782
  requires: ["text"]
16783
16783
  },
16784
16784
  mouse_click: {
@@ -10,6 +10,7 @@ const Values = preload("runtime_values.gd")
10
10
  ## reports are the same place by construction rather than by agreement.
11
11
  const Queries = preload("runtime_queries.gd")
12
12
  const Read = preload("reading.gd")
13
+ const Words = preload("runtime_words.gd")
13
14
 
14
15
  ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
15
16
  const TO_SMALL: int = 32
@@ -239,11 +240,12 @@ static func _select_everything(focused: Control) -> bool:
239
240
  ## What the field says now, or null where the focus is not a field at all.
240
241
  ##
241
242
  ## A spin box rewrites its own text out of the number it parsed, so this is the only thing that
242
- ## says whether what was typed became what the field means.
243
+ ## says whether what was typed became what the field means. A secret field answers with its mask,
244
+ ## which still says how many characters it took.
243
245
  static func _what_it_holds(focused: Control) -> Variant:
244
246
  var field: LineEdit = focused as LineEdit
245
247
  if field != null:
246
- return field.text
248
+ return Words.masked(field) if field.secret else field.text
247
249
  var box: TextEdit = focused as TextEdit
248
250
  if box == null:
249
251
  return null
@@ -666,7 +668,7 @@ func choose(params: Dictionary) -> Dictionary:
666
668
  if menu.is_item_disabled(index):
667
669
  return {
668
670
  "type": "error",
669
- "message": "%s item %d, %s, is disabled" % [node_path, index, menu.get_item_text(index)]
671
+ "message": "%s item %d, %s, is disabled" % [node_path, index, Words.item_says(menu, index)]
670
672
  }
671
673
 
672
674
  # Shown first, because a menu nobody has opened has no focus to move and Enter would go to
@@ -689,7 +691,7 @@ func choose(params: Dictionary) -> Dictionary:
689
691
  "type": "chosen",
690
692
  "path": node_path,
691
693
  "index": index,
692
- "text": menu.get_item_text(index),
694
+ "text": Words.item_says(menu, index),
693
695
  "id": menu.get_item_id(index),
694
696
  "opened": opened,
695
697
  "menu": str(menu.get_path()),
@@ -699,7 +701,7 @@ func choose(params: Dictionary) -> Dictionary:
699
701
  var chooser: OptionButton = node as OptionButton
700
702
  if chooser != null:
701
703
  answer["selected"] = chooser.get_selected()
702
- answer["shows"] = chooser.text
704
+ answer["shows"] = Words.said_by(chooser)
703
705
  return answer
704
706
 
705
707
 
@@ -718,6 +720,11 @@ static func _menu_of(node: Node) -> PopupMenu:
718
720
 
719
721
  ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
720
722
  ## Minus one when neither names one that is there.
723
+ ##
724
+ ## `text` is matched against the words the item shows before the ones it holds. In a game with
725
+ ## translations they differ, and the shown ones are what a caller has read off the screen: an item
726
+ ## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
727
+ ## still count after them, for a caller that has the key from the source.
721
728
  static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
722
729
  if params.has("index"):
723
730
  var asked: int = Read.as_int(params.get("index", -1), -1)
@@ -725,20 +732,26 @@ static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
725
732
  var wanted: String = str(params.get("text", ""))
726
733
  if wanted.is_empty():
727
734
  return -1
735
+ var shown: Array[String] = []
736
+ var held: Array[String] = []
728
737
  for index: int in menu.get_item_count():
729
- if menu.get_item_text(index) == wanted:
730
- return index
731
- for index: int in menu.get_item_count():
732
- if menu.get_item_text(index).nocasecmp_to(wanted) == 0:
733
- return index
738
+ shown.append(Words.item_says(menu, index))
739
+ held.append(menu.get_item_text(index))
740
+ for words: Array[String] in [shown, held]:
741
+ for index: int in words.size():
742
+ if words[index] == wanted:
743
+ return index
744
+ for index: int in words.size():
745
+ if words[index].nocasecmp_to(wanted) == 0:
746
+ return index
734
747
  return -1
735
748
 
736
749
 
737
- ## What the menu says, for a refusal that names the choices rather than the miss.
750
+ ## What the menu shows, for a refusal that names the choices rather than the miss.
738
751
  static func _items_of(menu: PopupMenu) -> Array[String]:
739
752
  var said: Array[String] = []
740
753
  for index: int in menu.get_item_count():
741
- said.append("%d: %s" % [index, menu.get_item_text(index)])
754
+ said.append("%d: %s" % [index, Words.item_says(menu, index)])
742
755
  return said
743
756
 
744
757
 
@@ -6,6 +6,7 @@ extends RefCounted
6
6
  const Paths = preload("runtime_paths.gd")
7
7
  const Read = preload("reading.gd")
8
8
  const Values = preload("runtime_values.gd")
9
+ const Words = preload("runtime_words.gd")
9
10
 
10
11
  ## The most nodes one find answers with, unless asked for fewer: enough for any real query and
11
12
  ## far short of the tree dump a query exists to avoid.
@@ -251,7 +252,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
251
252
  # What this node says, rather than everything said underneath it. A row is then found by the
252
253
  # label in it, and the path answered is that label's, which is where the words a caller is
253
254
  # looking at actually are: matching every container above it would answer with the screen.
254
- if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
255
+ if not wanted["says"].is_empty() and not _says(Words.said_by(node), wanted["says"]):
255
256
  return false
256
257
  var script: Variant = node.get_script()
257
258
  if not wanted["script"].is_empty():
@@ -346,8 +347,7 @@ func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]
346
347
  if not include_hidden and not _drawn(node):
347
348
  return 0
348
349
  var left_out: int = 0
349
- var said: String = said_by(node)
350
- if not said.is_empty():
350
+ for said: String in Words.lines_said_by(node):
351
351
  if into.size() < most:
352
352
  into.append(said)
353
353
  else:
@@ -373,25 +373,6 @@ static func _drawn(node: Node) -> bool:
373
373
  return window == null or window.visible
374
374
 
375
375
 
376
- ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
377
- ## is every label, button and field the interface is built out of.
378
- ##
379
- ## Public because three questions are the same question: what a screen reads as, which nodes say a
380
- ## given word, and whether anything has come to say it yet. Two copies of what a node says is how
381
- ## the three of them come to disagree about a SpinBox.
382
- ##
383
- ## Read rather than looked up in the property list, which the engine builds afresh on every call: a
384
- ## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
385
- ## took the game it was watching from 60 frames a second to 11. A node without the property reads
386
- ## as null, which is not a string.
387
- static func said_by(node: Node) -> String:
388
- var text: Variant = node.get("text")
389
- if not text is String:
390
- return ""
391
- var words: String = text
392
- return words.strip_edges()
393
-
394
-
395
376
  func get_rect(params: Dictionary) -> Dictionary:
396
377
  var node_path: String = str(params.get("path", ""))
397
378
  if node_path.is_empty():
@@ -7,6 +7,7 @@ const Paths = preload("runtime_paths.gd")
7
7
  const Queries = preload("runtime_queries.gd")
8
8
  const Read = preload("reading.gd")
9
9
  const Values = preload("runtime_values.gd")
10
+ const Words = preload("runtime_words.gd")
10
11
 
11
12
  ## The longest one wait may last, whatever the request says: past this the server has long
12
13
  ## since given up on the reply.
@@ -291,7 +292,7 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
291
292
  var pending: Array[Node] = [root]
292
293
  while not pending.is_empty():
293
294
  var node: Node = pending.pop_back()
294
- if Queries.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
295
+ if Words.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
295
296
  return true
296
297
  pending.append_array(node.get_children(true))
297
298
  return false
@@ -0,0 +1,163 @@
1
+ extends RefCounted
2
+
3
+ ## What a node says to a player, read the way it is drawn: the parsed words of rich text, as much of
4
+ ## a line as is typed out so far, the translation rather than its key, a mask over a secret, and the
5
+ ## items of the controls that draw a list.
6
+ ##
7
+ ## One module because three questions are the same question: what a screen reads as, which nodes say
8
+ ## a given word, and whether anything has come to say it yet. Two copies of what a node says is how
9
+ ## the three of them come to disagree about a SpinBox.
10
+
11
+
12
+ ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
13
+ ## is every label, button and field the interface is built out of.
14
+ ##
15
+ ## Read rather than looked up in the property list, which the engine builds afresh on every call: a
16
+ ## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
17
+ ## took the game it was watching from 60 frames a second to 11. A node without the property reads
18
+ ## as null, which is not a string.
19
+ ##
20
+ ## A label is asked about first and read directly, because a screen is mostly labels and this runs
21
+ ## for each of them on every look a wait takes.
22
+ static func said_by(node: Node) -> String:
23
+ var label: Label = node as Label
24
+ if label != null:
25
+ return _label_says(label)
26
+ if _draws_a_list(node):
27
+ return "\n".join(lines_said_by(node))
28
+ return _text_of(node)
29
+
30
+
31
+ ## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
32
+ ## item of a control that draws a list of them. Tab titles, list items, tree rows, menu titles and
33
+ ## the items of an open menu are held by the control rather than in any `text`, so a screen read as
34
+ ## every label on it and none of those, and a wait for a tab's title or a list item was never met.
35
+ static func lines_said_by(node: Node) -> Array[String]:
36
+ var lines: Array[String] = []
37
+ if node is TabBar:
38
+ var bar: TabBar = node
39
+ for tab: int in bar.tab_count:
40
+ if not bar.is_tab_hidden(tab):
41
+ _keep(node.atr(bar.get_tab_title(tab)), lines)
42
+ elif node is MenuBar:
43
+ var menus: MenuBar = node
44
+ for menu: int in menus.get_menu_count():
45
+ if not menus.is_menu_hidden(menu):
46
+ _keep(node.atr(menus.get_menu_title(menu)), lines)
47
+ elif node is ItemList:
48
+ var list: ItemList = node
49
+ for item: int in list.item_count:
50
+ _keep(_as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)), lines)
51
+ elif node is PopupMenu:
52
+ var menu: PopupMenu = node
53
+ for item: int in menu.item_count:
54
+ if not menu.is_item_separator(item):
55
+ _keep(item_says(menu, item), lines)
56
+ elif node is Tree:
57
+ var tree: Tree = node
58
+ if tree.column_titles_visible:
59
+ var titles: Array[String] = []
60
+ for column: int in tree.columns:
61
+ _keep(node.atr(tree.get_column_title(column)), titles)
62
+ _keep(" ".join(titles), lines)
63
+ _tree_rows(tree, tree.get_root(), tree.hide_root, lines)
64
+ else:
65
+ _keep(_text_of(node), lines)
66
+ return lines
67
+
68
+
69
+ ## What item [param index] of [param menu] shows: the translation where the item has one. A menu is
70
+ ## chosen from by these words, since they are the ones a caller has read off the screen.
71
+ static func item_says(menu: PopupMenu, index: int) -> String:
72
+ return _as_drawn(menu, menu.get_item_text(index), menu.get_item_auto_translate_mode(index))
73
+
74
+
75
+ ## What a secret [param field] draws: one mask character for each character it holds. Reading the
76
+ ## words back would hand a password to whoever reads the answer, which the player cannot see either.
77
+ static func masked(field: LineEdit) -> String:
78
+ return field.secret_character.repeat(field.text.length())
79
+
80
+
81
+ static func _draws_a_list(node: Node) -> bool:
82
+ return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
83
+
84
+
85
+ static func _keep(line: String, into: Array[String]) -> void:
86
+ var trimmed: String = line.strip_edges()
87
+ if not trimmed.is_empty():
88
+ into.append(trimmed)
89
+
90
+
91
+ ## [param words] as [param node] draws them, which for a game with translations is the translation
92
+ ## rather than the key it holds: a control set to `UI_HIRE` shows "Hire", and a wait for "Hire" was
93
+ ## never met. [param mode] is an item's own setting, which the engine reads before the node's.
94
+ static func _as_drawn(
95
+ node: Node, words: String, mode: Node.AutoTranslateMode = Node.AUTO_TRANSLATE_MODE_INHERIT
96
+ ) -> String:
97
+ match mode:
98
+ Node.AUTO_TRANSLATE_MODE_ALWAYS:
99
+ return node.tr(words)
100
+ Node.AUTO_TRANSLATE_MODE_DISABLED:
101
+ return words
102
+ return node.atr(words)
103
+
104
+
105
+ ## The rows of a tree from [param item] down, a row being its columns' text side by side, and
106
+ ## nothing under a collapsed row or a hidden one, since neither is drawn. A hidden root is only the
107
+ ## place its children hang from.
108
+ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[String]) -> void:
109
+ if item == null or not item.visible:
110
+ return
111
+ if not skip:
112
+ var cells: Array[String] = []
113
+ for column: int in tree.columns:
114
+ _keep(_as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column)), cells)
115
+ _keep(" ".join(cells), into)
116
+ if item.collapsed:
117
+ return
118
+ var child: TreeItem = item.get_first_child()
119
+ while child != null:
120
+ _tree_rows(tree, child, false, into)
121
+ child = child.get_next()
122
+
123
+
124
+ ## What a node with a `text` says, as it is drawn.
125
+ static func _text_of(node: Node) -> String:
126
+ var label: Label = node as Label
127
+ if label != null:
128
+ return _label_says(label)
129
+ # A RichTextLabel's text is its markup when it reads BBCode, and nobody reads the tags: a
130
+ # dossier line came back as "[b][color=#4fc2d4]Mollum Telken[/color], [/b]..." and a phrase
131
+ # running across a tag could not be found or waited for. The parsed text is what is drawn, and
132
+ # it also holds what a game added with append_text(), which the property never shows. It is
133
+ # parsed from the translation already.
134
+ if node is RichTextLabel:
135
+ var rich: RichTextLabel = node
136
+ return _shown_part(rich.get_parsed_text(), rich.visible_characters)
137
+ var text: Variant = node.get("text")
138
+ if not text is String:
139
+ return ""
140
+ var words: String = text
141
+ # What somebody typed is drawn as typed, never translated.
142
+ if node is LineEdit:
143
+ var field: LineEdit = node
144
+ return masked(field) if field.secret else words.strip_edges()
145
+ if node is TextEdit:
146
+ return words.strip_edges()
147
+ words = node.atr(words)
148
+ if node is Label3D:
149
+ var sign_text: Label3D = node
150
+ return (words.to_upper() if sign_text.uppercase else words).strip_edges()
151
+ return words.strip_edges()
152
+
153
+
154
+ static func _label_says(label: Label) -> String:
155
+ var words: String = label.atr(label.text)
156
+ return _shown_part(words.to_upper() if label.uppercase else words, label.visible_characters)
157
+
158
+
159
+ ## As much of [param words] as is drawn: a label typing a line out shows [param drawn] characters
160
+ ## of it, and -1 is all of them. Read whole, a words wait was met on the first frame of a line being
161
+ ## typed out, before the player could read any of it.
162
+ static func _shown_part(words: String, drawn: int) -> String:
163
+ return (words if drawn < 0 else words.substr(0, drawn)).strip_edges()
@@ -0,0 +1 @@
1
+ uid://u3ml7sk81frh
package/build/index.js CHANGED
@@ -30545,7 +30545,7 @@ var TOOL_SPECS = [
30545
30545
  says: {
30546
30546
  type: "string",
30547
30547
  ops: ["find"],
30548
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
30548
+ description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
30549
30549
  },
30550
30550
  limit: {
30551
30551
  type: "integer",
@@ -30574,7 +30574,7 @@ var TOOL_SPECS = [
30574
30574
  operations: {
30575
30575
  tree: { summary: "the live scene tree", requires: [] },
30576
30576
  text: {
30577
- summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
30577
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
30578
30578
  requires: []
30579
30579
  },
30580
30580
  find: {
@@ -30723,7 +30723,7 @@ var TOOL_SPECS = [
30723
30723
  type: "string",
30724
30724
  ops: ["text", "choose"],
30725
30725
  blank: true,
30726
- description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
30726
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
30727
30727
  },
30728
30728
  replace: {
30729
30729
  type: "boolean",
@@ -30785,7 +30785,7 @@ var TOOL_SPECS = [
30785
30785
  requires: ["keycode"]
30786
30786
  },
30787
30787
  text: {
30788
- summary: "type a string into the field being edited, a character at a time, and say what it landed in",
30788
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
30789
30789
  requires: ["text"]
30790
30790
  },
30791
30791
  mouse_click: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
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",