gdharness 1.1.24 → 1.1.25

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
@@ -17874,7 +17874,7 @@ var init_tool_definitions = __esm(() => {
17874
17874
  },
17875
17875
  {
17876
17876
  name: "runtime_input",
17877
- description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
17877
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: the pointer ops answer with pointer_note there, because get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
17878
17878
  parameters: {
17879
17879
  projectPath: RUNNING_PROJECT_PATH,
17880
17880
  pid: RUNNING_PID,
@@ -17914,7 +17914,7 @@ var init_tool_definitions = __esm(() => {
17914
17914
  type: "string",
17915
17915
  ops: ["text", "choose"],
17916
17916
  blank: true,
17917
- 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.'
17917
+ 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. An item whose whole words are these comes first; otherwise the words match as says does anywhere, a plain word as part of the item, a glob, or alternatives split at |, enabled items ahead of disabled ones, and several matching equally are refused with each listed.'
17918
17918
  },
17919
17919
  replace: {
17920
17920
  type: "boolean",
@@ -21,6 +21,10 @@ const TO_SMALL: int = 32
21
21
  ## reason a control is out of reach. Only worth telling somebody when it is what they have.
22
22
  const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
23
23
 
24
+ ## Set, to the desktop's name, for a process gdharness started on a desktop of its own and for
25
+ ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
26
+ const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
27
+
24
28
  var _host: Node
25
29
  var _values: Values
26
30
 
@@ -45,6 +49,28 @@ func _init(host: Node, values: Values) -> void:
45
49
  _values = values
46
50
 
47
51
 
52
+ ## [param answer], saying what a desktop of gdharness's own does to the game's own reading of the
53
+ ## pointer when the game is on one.
54
+ ##
55
+ ## For the root window the engine asks Windows where the pointer is, and GetCursorPos refuses a
56
+ ## process on a desktop that is not in use: measured on one of gdharness's own, it answered false
57
+ ## with access denied and left the point unwritten. The engine does not check, so a game driven
58
+ ## there read get_mouse_position() as x 836852416 after a motion to 1310, 314. Nothing here can
59
+ ## put a pointer on that desktop: Windows moves one only on the desktop in use, and making this one
60
+ ## that would take the screen. What reads the injected event is right, tooltips included, since the
61
+ ## engine places a tooltip from the motion that reached the control.
62
+ static func _pointer_noted(answer: Dictionary) -> Dictionary:
63
+ if OS.get_environment(DESKTOP_VARIABLE) != "":
64
+ answer["pointer_note"] = (
65
+ "This game is on gdharness's own desktop, where Windows refuses to say where the"
66
+ + " pointer is, so get_mouse_position() on the root window, and get_global_mouse_position()"
67
+ + " and get_local_mouse_position() on what is drawn there, read a meaningless position"
68
+ + " rather than where this put it. What reads the event itself gets this position: the"
69
+ + " control under the pointer, its hover and tooltip, and the game's input handlers."
70
+ )
71
+ return answer
72
+
73
+
48
74
  ## The movement a pointer arriving at [param position] carries, which is the distance from where
49
75
  ## the last injected event put it, and none at all for the first. The arrival is recorded, with how
50
76
  ## long it took: at least a frame, since two motions sent in one frame did not cross the screen in
@@ -238,15 +264,17 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
238
264
  Input.parse_input_event(_button(position, button, false, false))
239
265
  await _host.get_tree().process_frame
240
266
 
241
- return {
242
- "type": "input_injected",
243
- "input_type": "mouse_click",
244
- "position": [position.x, position.y],
245
- "button": button,
246
- "pressed": held and not whole,
247
- "whole": whole,
248
- "double": double
249
- }
267
+ return _pointer_noted(
268
+ {
269
+ "type": "input_injected",
270
+ "input_type": "mouse_click",
271
+ "position": [position.x, position.y],
272
+ "button": button,
273
+ "pressed": held and not whole,
274
+ "whole": whole,
275
+ "double": double
276
+ }
277
+ )
250
278
 
251
279
 
252
280
  ## Moves the pointer to a position, with the movement the event carries taken from where the
@@ -275,12 +303,14 @@ func inject_mouse_motion(params: Dictionary) -> Dictionary:
275
303
 
276
304
  Input.parse_input_event(_motion(position, relative))
277
305
 
278
- return {
279
- "type": "input_injected",
280
- "input_type": "mouse_motion",
281
- "position": [position.x, position.y],
282
- "relative": [relative.x, relative.y]
283
- }
306
+ return _pointer_noted(
307
+ {
308
+ "type": "input_injected",
309
+ "input_type": "mouse_motion",
310
+ "position": [position.x, position.y],
311
+ "relative": [relative.x, relative.y]
312
+ }
313
+ )
284
314
 
285
315
 
286
316
  ## Whether [param centre] is somewhere a click can reach it: inside the viewport, and inside every
@@ -289,11 +319,11 @@ static func _in_sight(control: Control, viewport: Viewport, centre: Vector2) ->
289
319
  return viewport.get_visible_rect().has_point(centre) and _clipped_by(control, centre) == null
290
320
 
291
321
 
292
- ## The ScrollContainer between [param control] and the root that clips [param centre] away from it,
293
- ## or null when none does.
322
+ ## The ScrollContainer between [param control] and its viewport that clips [param centre] away from
323
+ ## it, or null when none does. Not past the viewport: one holding a dialog is in another space.
294
324
  static func _clipped_by(control: Control, centre: Vector2) -> ScrollContainer:
295
325
  var walking: Node = control.get_parent()
296
- while walking != null:
326
+ while walking != null and not (walking is Viewport):
297
327
  var holder: ScrollContainer = walking as ScrollContainer
298
328
  # Carried into the same space the centre is in, which is the canvas rather than the
299
329
  # container's own: the two are only the same while nothing above it is transformed.
@@ -312,11 +342,12 @@ static func _clipped_by(control: Control, centre: Vector2) -> ScrollContainer:
312
342
  ## next layout pass rather than inside the call, and the next one out works out where the control is
313
343
  ## from where it is drawn, so asked in the same frame it scrolled to where the control had been.
314
344
  ## Moved means a scroll value changed: a container asked to show what it already shows moves
315
- ## nothing, and answering true for it said the view had moved when it had not.
345
+ ## nothing, and answering true for it said the view had moved when it had not. Only the containers in
346
+ ## the control's own viewport: one holding a dialog scrolls the page behind it, not the dialog.
316
347
  func _scroll_into_view(control: Control) -> bool:
317
348
  var moved: bool = false
318
349
  var walking: Variant = control.get_parent()
319
- while walking != null:
350
+ while walking != null and not (walking is Viewport):
320
351
  var step: Node = walking
321
352
  var holder: ScrollContainer = step as ScrollContainer
322
353
  if holder != null:
@@ -466,7 +497,7 @@ func click(params: Dictionary) -> Dictionary:
466
497
  }
467
498
  if found != null:
468
499
  answer["found"] = found
469
- return answer
500
+ return _pointer_noted(answer)
470
501
 
471
502
 
472
503
  ## Why [param control] would take no click a player gave it, or "" when it would.
@@ -636,19 +667,21 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
636
667
  _press_button(arriving, _button(position, button, false, false))
637
668
  await _host.get_tree().process_frame
638
669
 
639
- return {
640
- "type": "clicked",
641
- "path": node_path,
642
- "position": _values.serialize(position),
643
- "button": button,
644
- "double": double,
645
- "hovered": hovered_path,
646
- # The interface did not take it, so it reached the game's own input. As close to "it
647
- # landed" as anything outside the game can get, and said in the same word the Control
648
- # click says it in.
649
- "landed": through["landed"],
650
- "camera": found["camera"],
651
- }
670
+ return _pointer_noted(
671
+ {
672
+ "type": "clicked",
673
+ "path": node_path,
674
+ "position": _values.serialize(position),
675
+ "button": button,
676
+ "double": double,
677
+ "hovered": hovered_path,
678
+ # The interface did not take it, so it reached the game's own input. As close to "it
679
+ # landed" as anything outside the game can get, and said in the same word the Control
680
+ # click says it in.
681
+ "landed": through["landed"],
682
+ "camera": found["camera"],
683
+ }
684
+ )
652
685
 
653
686
 
654
687
  ## Whether a pointer on its way out through [param out], the steps [method Screen.steps_out]
@@ -4,6 +4,7 @@ extends RefCounted
4
4
  ## where it is in the list.
5
5
 
6
6
  const Read = preload("reading.gd")
7
+ const Says = preload("runtime_says.gd")
7
8
  const Words = preload("runtime_words.gd")
8
9
 
9
10
 
@@ -20,20 +21,28 @@ static func menu_of(node: Node) -> PopupMenu:
20
21
  return null
21
22
 
22
23
 
23
- ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
24
- ## Minus one when neither names one that is there.
24
+ ## The items asked for, best first: the one at `index`; else the one whose whole words are `text`,
25
+ ## exactly and then case-insensitively; else every item `text` matches by the rules `says` has, a
26
+ ## plain word as a contains, a glob, and alternatives split at `|`, the enabled ones when any are.
27
+ ## Empty when nothing matches, and more than one when several match equally well, which the caller
28
+ ## refuses rather than guesses between.
29
+ ##
30
+ ## By the same rules as `says` because a caller who has learnt them from a click, a find and a wait
31
+ ## writes the same pattern here: "Leave*" was refused against a menu holding "Leave the hall", and
32
+ ## the item had to be written out in full on a second call. The whole words still come first, so an
33
+ ## item named in full is chosen over another whose words contain it.
25
34
  ##
26
35
  ## `text` is matched against the words the item shows before the ones it holds. In a game with
27
36
  ## translations they differ, and the shown ones are what a caller has read off the screen: an item
28
37
  ## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
29
38
  ## still count after them, for a caller that has the key from the source.
30
- static func wanted_item(menu: PopupMenu, params: Dictionary) -> int:
39
+ static func wanted_items(menu: PopupMenu, params: Dictionary) -> Array[int]:
31
40
  if params.has("index"):
32
41
  var asked: int = Read.as_int(params.get("index", -1), -1)
33
- return asked if asked >= 0 and asked < menu.get_item_count() else -1
42
+ return _items([asked] if asked >= 0 and asked < menu.get_item_count() else [])
34
43
  var wanted: String = str(params.get("text", ""))
35
44
  if wanted.is_empty():
36
- return -1
45
+ return _items([])
37
46
  # The items before the separators, because a heading can carry the same words as an item under
38
47
  # it, and finding the heading first refused the choice the caller meant. A separator is still
39
48
  # looked for after, so a heading asked for by name is refused as one rather than as missing.
@@ -50,11 +59,33 @@ static func wanted_item(menu: PopupMenu, params: Dictionary) -> int:
50
59
  for words: Array[String] in [shown, held]:
51
60
  for at: int in words.size():
52
61
  if words[at] == wanted:
53
- return order[at]
62
+ return _items([order[at]])
54
63
  for at: int in words.size():
55
64
  if words[at].nocasecmp_to(wanted) == 0:
56
- return order[at]
57
- return -1
65
+ return _items([order[at]])
66
+ # Separators are left out here: a heading is refused when named in full, and a pattern that
67
+ # matched one beside the item it heads would tie the two.
68
+ for words: Array[String] in [shown, held]:
69
+ var matched: Array[int] = []
70
+ var enabled: Array[int] = []
71
+ for at: int in words.size():
72
+ var index: int = order[at]
73
+ if menu.is_item_separator(index) or not Says.matches(words[at], wanted):
74
+ continue
75
+ matched.append(index)
76
+ if not menu.is_item_disabled(index):
77
+ enabled.append(index)
78
+ if not matched.is_empty():
79
+ return enabled if not enabled.is_empty() else matched
80
+ return _items([])
81
+
82
+
83
+ ## [param indices] as a list of item numbers: a literal list is untyped, and handed back where a
84
+ ## typed one is declared it is refused when the call is made, not when the script is read.
85
+ static func _items(indices: Array) -> Array[int]:
86
+ var typed: Array[int] = []
87
+ typed.assign(indices)
88
+ return typed
58
89
 
59
90
 
60
91
  ## What the menu shows, for a refusal that names the choices rather than the miss: a titled
@@ -216,13 +216,21 @@ static func can_read(holder: Variant, named: String) -> bool:
216
216
  ## A step is text, and a map is keyed by whatever the game put in it: text, a StringName, or a
217
217
  ## number, which a map of units by id is. Keyed by 3, "3" is not a key of it, measured on 4.7.2, so a
218
218
  ## step that reads as a number is tried as one after the two spellings of text.
219
+ ##
220
+ ## A map typed by its key is asked only in that type. It checks every key it is asked about and
221
+ ## reports an engine error for one of another type, so a step into a map typed by an enum read the
222
+ ## right entry and left four errors in the game's log for each of the two walks, and the run was
223
+ ## counted as unclean for a read.
219
224
  static func _key_in(map: Dictionary, named: String) -> Array:
220
225
  var spellings: Array = [named, StringName(named)]
221
226
  if named.is_valid_int():
222
227
  spellings.append(int(named))
223
228
  if named.is_valid_float():
224
229
  spellings.append(float(named))
230
+ var typed: int = map.get_typed_key_builtin() if map.is_typed_key() else TYPE_NIL
225
231
  for key: Variant in spellings:
232
+ if typed != TYPE_NIL and typeof(key) != typed:
233
+ continue
226
234
  if map.has(key):
227
235
  return [key]
228
236
  return []
@@ -229,7 +229,7 @@ static func _covered_note(covered: int, cover: Node) -> String:
229
229
  ## its words means.
230
230
  static func _pressed_through(control: Control) -> Control:
231
231
  var walk: Node = control
232
- while walk != null:
232
+ while walk != null and not (walk is Viewport):
233
233
  if walk is BaseButton:
234
234
  var pressed: BaseButton = walk
235
235
  return pressed
@@ -376,7 +376,7 @@ static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
376
376
  var half: Vector2 = (drawn * Rect2(Vector2.ZERO, target.size)).size * 0.5
377
377
  var landed: Vector2 = point
378
378
  var walk: Node = target.get_parent()
379
- while walk != null:
379
+ while walk != null and not (walk is Viewport):
380
380
  var holder: ScrollContainer = walk as ScrollContainer
381
381
  if holder != null:
382
382
  var shown: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
@@ -405,9 +405,13 @@ static func _off_screen(target: Control) -> bool:
405
405
 
406
406
 
407
407
  ## Whether a container clipping what it holds cuts [param point] off from [param control].
408
+ ##
409
+ ## Only up to the viewport [param control] is in. A dialog is a window, and the containers the game
410
+ ## added it under are in another space: judged against a clipping one of those, every button of a
411
+ ## confirmation was refused as off the screen while it sat in plain view.
408
412
  static func _clipped_away(control: Control, point: Vector2) -> bool:
409
413
  var walk: Node = control.get_parent()
410
- while walk != null:
414
+ while walk != null and not (walk is Viewport):
411
415
  var holder: Control = walk as Control
412
416
  if holder != null and holder.clip_contents and not _holds(holder, point):
413
417
  return true
@@ -261,11 +261,24 @@ func choose(params: Dictionary) -> Dictionary:
261
261
  var opened: bool = Menus.open_the_menu(node, menu)
262
262
  await _host.get_tree().process_frame
263
263
 
264
- var index: int = Menus.wanted_item(menu, params)
264
+ var found: Array[int] = Menus.wanted_items(menu, params)
265
+ if found.size() > 1:
266
+ await _shut(menu, opened)
267
+ var tied: Array[String] = []
268
+ for at: int in found:
269
+ tied.append("%d: %s" % [at, Words.item_says(menu, at)])
270
+ return {
271
+ "type": "error",
272
+ "message":
273
+ (
274
+ "%s has %d items that say %s, so nothing was chosen: %s. Name one by its whole text or by index"
275
+ % [node_path, found.size(), JSON.stringify(str(params.get("text", ""))), ", ".join(tied)]
276
+ )
277
+ }
278
+ var index: int = found[0] if found.size() == 1 else -1
265
279
  var refused: String = _not_a_choice(node_path, menu, index)
266
280
  if not refused.is_empty():
267
- if opened and is_instance_valid(menu):
268
- menu.hide()
281
+ await _shut(menu, opened)
269
282
  return {"type": "error", "message": refused}
270
283
 
271
284
  # Read before the press, because the press can take the menu away: a game that rebuilds its
@@ -298,8 +311,7 @@ func choose(params: Dictionary) -> Dictionary:
298
311
  menu.index_pressed.disconnect(hear)
299
312
 
300
313
  if not fired.has(index):
301
- if opened and is_instance_valid(menu) and menu.visible:
302
- menu.hide()
314
+ await _shut(menu, opened)
303
315
  return {
304
316
  "type": "error",
305
317
  "message":
@@ -322,6 +334,16 @@ func choose(params: Dictionary) -> Dictionary:
322
334
  return answer
323
335
 
324
336
 
337
+ ## Shuts a menu this call opened, and lets the frame pass in which the engine finishes shutting it.
338
+ ## A choice asked for in the frame a refusal shut the menu found it half shut, and the press it sent
339
+ ## went nowhere: measured on 4.7.2, a MenuButton refused and at once asked again answered that the
340
+ ## menu did not take the item.
341
+ func _shut(menu: PopupMenu, opened: bool) -> void:
342
+ if opened and is_instance_valid(menu) and menu.visible:
343
+ menu.hide()
344
+ await _host.get_tree().process_frame
345
+
346
+
325
347
  ## Why item [param index] of [param menu] is not something to choose, or "" when it is.
326
348
  static func _not_a_choice(node_path: String, menu: PopupMenu, index: int) -> String:
327
349
  if index < 0:
package/build/index.js CHANGED
@@ -31858,7 +31858,7 @@ var TOOL_SPECS = [
31858
31858
  },
31859
31859
  {
31860
31860
  name: "runtime_input",
31861
- description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
31861
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: the pointer ops answer with pointer_note there, because get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
31862
31862
  parameters: {
31863
31863
  projectPath: RUNNING_PROJECT_PATH,
31864
31864
  pid: RUNNING_PID,
@@ -31898,7 +31898,7 @@ var TOOL_SPECS = [
31898
31898
  type: "string",
31899
31899
  ops: ["text", "choose"],
31900
31900
  blank: true,
31901
- 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.'
31901
+ 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. An item whose whole words are these comes first; otherwise the words match as says does anywhere, a plain word as part of the item, a glob, or alternatives split at |, enabled items ahead of disabled ones, and several matching equally are refused with each listed.'
31902
31902
  },
31903
31903
  replace: {
31904
31904
  type: "boolean",
package/build/keeper.js CHANGED
@@ -10,6 +10,7 @@ import { setTimeout as delay } from "node:timers/promises";
10
10
  // src/desktop.ts
11
11
  import { createHash } from "node:crypto";
12
12
  import { writeFileSync } from "node:fs";
13
+ var DESKTOP_VARIABLE = "GDHARNESS_DESKTOP";
13
14
  var SOURCE = `using System;
14
15
  using System.ComponentModel;
15
16
  using System.IO;
@@ -565,7 +566,8 @@ async function startThroughHelper(spec, output, stopFile) {
565
566
  error: errorFile,
566
567
  stop: stopFile
567
568
  });
568
- const started = await startDirectly({ ...spec, command: wrapper.command, args: wrapper.args }, output, true, false, "ignore");
569
+ const envChanges = spec.desktop === undefined ? spec.envChanges : { ...spec.envChanges, [DESKTOP_VARIABLE]: spec.desktop };
570
+ const started = await startDirectly({ ...spec, command: wrapper.command, args: wrapper.args, envChanges }, output, true, false, "ignore");
569
571
  if ("error" in started) {
570
572
  return started;
571
573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.24",
3
+ "version": "1.1.25",
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",