gdharness 1.1.45 → 1.1.47

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.
@@ -233,13 +233,17 @@ func call_method(params: Dictionary) -> Dictionary:
233
233
  var read_from: int = Time.get_ticks_usec()
234
234
  var answered: Variant = Paths.read_under(reached["holder"], spelled)
235
235
  var read_for: int = Time.get_ticks_usec() - read_from
236
- return {
236
+ var listed: Dictionary = {
237
237
  "type": "method_result",
238
238
  "path": node_path,
239
239
  "method": method,
240
240
  "result": _values.serialize(answered),
241
241
  "elapsed_usec": read_for,
242
242
  }
243
+ var asked_of_it: Variant = params.get("properties")
244
+ if _reads_the_result(asked_of_it):
245
+ listed.merge(_read_off_result(answered, asked_of_it))
246
+ return listed
243
247
  var why: String = (
244
248
  "%s:%s takes no arguments" % [reached["called"], spelled]
245
249
  if Paths.can_read(reached["holder"], spelled)
@@ -258,10 +262,11 @@ func call_method(params: Dictionary) -> Dictionary:
258
262
  # The method a call ends on is the one being called whether or not it carries the brackets a
259
263
  # step along the way would: "get_viewport():gui_get_focus_owner()" is the same call as without
260
264
  # the last pair.
265
+ var not_called: String = _not_a_method_to_call(holder, named, str(reached["called"]))
266
+ if not not_called.is_empty():
267
+ return {"type": "error", "message": not_called}
261
268
  if not Paths.method_of(named).is_empty():
262
269
  named = Paths.method_of(named)
263
- if not holder.has_method(named):
264
- return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
265
270
 
266
271
  # Checked before the call rather than left to it. `callv` raises inside the game when an
267
272
  # argument cannot be converted, and an error raised in a game somebody is playing holds it at a
@@ -360,9 +365,98 @@ func call_method(params: Dictionary) -> Dictionary:
360
365
  answer["errors"] = caught.raised.slice(0, ERRORS_KEPT)
361
366
  if caught.raised.size() > ERRORS_KEPT:
362
367
  answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
368
+ var asked: Variant = params.get("properties")
369
+ if _reads_the_result(asked):
370
+ answer.merge(_read_off_result(result, asked))
363
371
  return answer
364
372
 
365
373
 
374
+ ## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
375
+ ## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
376
+ ## taken from args alone, so a call is never made with half of them in one place and half in the
377
+ ## other, or with the bracketed ones quietly dropped.
378
+ static func _not_a_method_to_call(holder: Object, named: String, called: String) -> String:
379
+ var method: String = named
380
+ if not Paths.method_of(named).is_empty():
381
+ var bracketed: Dictionary = Paths.arguments_of(named)
382
+ var in_brackets: Array = bracketed.get("args", [])
383
+ if bracketed.has("message") or not in_brackets.is_empty():
384
+ return (
385
+ "%s:%s: the method a call ends on takes its arguments under args, not in its brackets"
386
+ % [called, named]
387
+ )
388
+ method = Paths.method_of(named)
389
+ return "" if holder.has_method(method) else "%s has no method %s" % [called, method]
390
+
391
+
392
+ ## Whether a call's `properties` asks for its result to be read: true, or a list of names.
393
+ static func _reads_the_result(asked: Variant) -> bool:
394
+ return asked is Array or (asked is bool and asked)
395
+
396
+
397
+ ## What [param asked] reads off the object a call answered with: every variable its script declares
398
+ ## when [param asked] is true, or the properties a list names, each a path walked from the object.
399
+ ##
400
+ ## In the same answer because an object handed back is otherwise only its class and an id, and
401
+ ## reading one field of it meant finding the same object again by a path into the game's private
402
+ ## state, which works only where the caller already knows a key. A name that is not there is listed
403
+ ## rather than refusing the answer, since the call has run by then and its result is what was asked.
404
+ func _read_off_result(result: Variant, asked: Variant) -> Dictionary:
405
+ if result is Object and not is_instance_valid(result):
406
+ return {
407
+ "properties_note": "the call answered an object that has since been freed, so nothing was read"
408
+ }
409
+ if not result is Object:
410
+ return {
411
+ "properties_note":
412
+ "the call answered %s, which has no properties to read" % type_string(typeof(result))
413
+ }
414
+ var object: Object = result
415
+ var wanted: Array[String] = []
416
+ if asked is Array:
417
+ for name: Variant in asked:
418
+ wanted.append(str(name))
419
+ else:
420
+ wanted = _script_variables(object)
421
+ var read: Dictionary = {}
422
+ var missing: Array[String] = []
423
+ for path: String in wanted:
424
+ var walked: Dictionary = _walk_from(object, path)
425
+ if walked.has("message"):
426
+ missing.append(str(walked["message"]))
427
+ else:
428
+ read[path] = _values.serialize(walked["value"])
429
+ var answered: Dictionary = {"result_properties": read}
430
+ if not missing.is_empty():
431
+ answered["result_properties_missing"] = missing
432
+ return answered
433
+
434
+
435
+ ## The variables the script attached to [param object] declares, in the order it declares them.
436
+ static func _script_variables(object: Object) -> Array[String]:
437
+ var names: Array[String] = []
438
+ for entry: Dictionary in object.get_property_list():
439
+ var usage: int = entry.get("usage", 0)
440
+ if usage & PROPERTY_USAGE_SCRIPT_VARIABLE != 0:
441
+ names.append(str(entry.get("name", "")))
442
+ return names
443
+
444
+
445
+ ## What [param path] reads walked from [param object], as {"value"}, or {"message"} naming the step
446
+ ## that is not there, by the same rules a path read from a node follows.
447
+ static func _walk_from(object: Object, path: String) -> Dictionary:
448
+ var holder: Variant = object
449
+ var called: String = "the result"
450
+ for named: String in Paths.steps_of(path):
451
+ if not (holder is Object or holder is Array or holder is Dictionary or Paths.packed(holder)):
452
+ return {"message": "%s holds no object to read %s off" % [called, named]}
453
+ if not Paths.can_read(holder, named):
454
+ return {"message": Paths.nothing_there(holder, named, called)}
455
+ holder = Paths.read_under(holder, named)
456
+ called = "%s:%s" % [called, named]
457
+ return {"value": holder}
458
+
459
+
366
460
  ## Why [param given] arguments are the wrong number for [param method], or "" when they are not.
367
461
  ##
368
462
  ## Asked before the call, because `callv` given the wrong number refuses inside the game and returns
@@ -25,6 +25,9 @@ const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
25
25
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
26
26
  const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
27
27
 
28
+ ## Whether this run has had the pointer note in full; the game process is the run.
29
+ static var _pointer_explained: bool = false
30
+
28
31
  var _host: Node
29
32
  var _values: Values
30
33
 
@@ -60,13 +63,21 @@ func _init(host: Node, values: Values) -> void:
60
63
  ## that would take the screen. What reads the injected event is right, tooltips included, since the
61
64
  ## engine places a tooltip from the motion that reached the control.
62
65
  static func _pointer_noted(answer: Dictionary) -> Dictionary:
63
- if OS.get_environment(DESKTOP_VARIABLE) != "":
66
+ if OS.get_environment(DESKTOP_VARIABLE) == "":
67
+ return answer
68
+ # The fact on every answer and the explanation on the first of the run: a session sends dozens of
69
+ # pointer calls to one game, and the same four hundred characters on each was most of what came
70
+ # back.
71
+ answer["pointer_on_hidden_desktop"] = true
72
+ if not _pointer_explained:
73
+ _pointer_explained = true
64
74
  answer["pointer_note"] = (
65
75
  "This game is on gdharness's own desktop, where Windows refuses to say where the"
66
76
  + " pointer is, so get_mouse_position() on the root window, and get_global_mouse_position()"
67
77
  + " and get_local_mouse_position() on what is drawn there, read a meaningless position"
68
78
  + " 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."
79
+ + " control under the pointer, its hover and tooltip, and the game's input handlers. Said"
80
+ + " once a run; pointer_on_hidden_desktop marks every answer it applies to."
70
81
  )
71
82
  return answer
72
83
 
@@ -61,7 +61,7 @@ static func wanted_items(menu: PopupMenu, params: Dictionary) -> Array[int]:
61
61
  if words[at] == wanted:
62
62
  return _items([order[at]])
63
63
  for at: int in words.size():
64
- if words[at].nocasecmp_to(wanted) == 0:
64
+ if Says.spaced(words[at]).nocasecmp_to(Says.spaced(wanted)) == 0:
65
65
  return _items([order[at]])
66
66
  # Separators are left out here: a heading is refused when named in full, and a pattern that
67
67
  # matched one beside the item it heads would tie the two.
@@ -83,19 +83,21 @@ const READS_ONLY: String = ", and a path, which a wait reads every frame, only c
83
83
  ## at a time rather than handed to that method, which answers null for a path that goes wrong
84
84
  ## halfway along and for one that ends on null.
85
85
  ##
86
- ## A step written as a call, "get_viewport()", calls a method of that name that takes no arguments
87
- ## and walks into what it returned. Some of what hangs off a node is only reachable by asking:
88
- ## which control holds the focus is a question for the viewport a control is in, and the viewport
89
- ## is a method's answer rather than a property. Spelled with the brackets so a reader sees a call
90
- ## where one happens, and so a method can never shadow a property of the same name. A list or a map
91
- ## answers the calls that read it, "board:size()" or "inbox:keys()", listed in [constant LIST_CALLS]
92
- ## and [constant MAP_CALLS].
86
+ ## A step written as a call, "get_viewport()", calls a method of that name and walks into what it
87
+ ## returned. Some of what hangs off a node is only reachable by asking: which control holds the
88
+ ## focus is a question for the viewport a control is in, and the viewport is a method's answer
89
+ ## rather than a property. Spelled with the brackets so a reader sees a call where one happens, and
90
+ ## so a method can never shadow a property of the same name. A list or a map answers the calls that
91
+ ## read it, "board:size()" or "inbox:keys()", listed in [constant LIST_CALLS] and
92
+ ## [constant MAP_CALLS]. An engine method that reads takes literal arguments in the brackets,
93
+ ## "get_theme_stylebox(\"panel\"):content_margin_top", since what a control's theme gives it is only
94
+ ## reachable by naming what is asked for; see [method arguments_of].
93
95
  ##
94
96
  ## Answers with a `message` instead when a step along the way is not there or holds something that
95
97
  ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
96
98
  ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
97
99
  static func walk_to(node: Node, node_path: String, reaching: String) -> Dictionary:
98
- var parts: PackedStringArray = reaching.split(":")
100
+ var parts: PackedStringArray = steps_of(reaching)
99
101
  var holder: Variant = node
100
102
  var called: String = node_path
101
103
  for step: int in parts.size() - 1:
@@ -187,26 +189,33 @@ static func nothing_under(holder: Variant, named: String, called: String) -> Str
187
189
  ## first one. An index reads the way a property does, and a negative one counts from the end the
188
190
  ## way GDScript's own does, so reading the last of something does not mean asking how many first.
189
191
  static func can_read(holder: Variant, named: String) -> bool:
192
+ var method: String = method_of(named)
193
+ var given: Dictionary = arguments_of(named) if not method.is_empty() else {"args": []}
194
+ if given.has("message"):
195
+ return false
196
+ var args: Array = given["args"]
190
197
  if holder is Array:
191
198
  var items: Array = holder
192
- var list_call: String = method_of(named)
193
- if not list_call.is_empty():
194
- return list_call in LIST_CALLS and not (items.is_empty() and list_call in NEEDS_AN_ELEMENT)
199
+ if not method.is_empty():
200
+ return (
201
+ args.is_empty()
202
+ and method in LIST_CALLS
203
+ and not (items.is_empty() and method in NEEDS_AN_ELEMENT)
204
+ )
195
205
  return _index_in(named, items.size()) != -1
196
206
  if packed(holder):
197
- if not method_of(named).is_empty():
198
- return method_of(named) in PACKED_CALLS
207
+ if not method.is_empty():
208
+ return args.is_empty() and method in PACKED_CALLS
199
209
  return _index_in(named, len(holder)) != -1
200
210
  if holder is Dictionary:
201
211
  var map: Dictionary = holder
202
- if not method_of(named).is_empty():
203
- return method_of(named) in MAP_CALLS
212
+ if not method.is_empty():
213
+ return args.is_empty() and method in MAP_CALLS
204
214
  return not _key_in(map, named).is_empty()
205
215
  if holder is Object:
206
216
  var object: Object = holder
207
- var method: String = method_of(named)
208
217
  if not method.is_empty():
209
- return call_refused(object, method, "").is_empty()
218
+ return call_refused(object, method, "", args.size()).is_empty()
210
219
  return _has_property(object, named)
211
220
  return false
212
221
 
@@ -236,24 +245,103 @@ static func _key_in(map: Dictionary, named: String) -> Array:
236
245
  return []
237
246
 
238
247
 
239
- ## The method a step written as a call names, "get_viewport()" being get_viewport, or "" for a
240
- ## step that is a name.
248
+ ## [param reaching] split into its steps at each colon, except one inside a call's brackets or a
249
+ ## quoted argument: `get_node("/root/Main"):name` is two steps, and split at every colon a quoted
250
+ ## argument holding one became a step that names nothing.
251
+ static func steps_of(reaching: String) -> PackedStringArray:
252
+ var steps: PackedStringArray = []
253
+ var step: String = ""
254
+ var depth: int = 0
255
+ var quoted: bool = false
256
+ var escaped: bool = false
257
+ for character: String in reaching:
258
+ if quoted:
259
+ if escaped:
260
+ escaped = false
261
+ elif character == "\\":
262
+ escaped = true
263
+ elif character == '"':
264
+ quoted = false
265
+ elif character == '"':
266
+ quoted = true
267
+ elif character == "(":
268
+ depth += 1
269
+ elif character == ")":
270
+ depth = maxi(depth - 1, 0)
271
+ elif character == ":" and depth == 0:
272
+ var _kept: bool = steps.push_back(step)
273
+ step = ""
274
+ continue
275
+ step += character
276
+ var _last: bool = steps.push_back(step)
277
+ return steps
278
+
279
+
280
+ ## The method a step written as a call names, "get_viewport()" being get_viewport and
281
+ ## `get_theme_constant("separation")` get_theme_constant, or "" for a step that is a name.
241
282
  static func method_of(named: String) -> String:
242
- return named.trim_suffix("()") if named.ends_with("()") else ""
283
+ if not named.ends_with(")"):
284
+ return ""
285
+ var opened: int = named.find("(")
286
+ if opened < 1:
287
+ return ""
288
+ var method: String = named.substr(0, opened)
289
+ return method if method.is_valid_ascii_identifier() else ""
243
290
 
244
291
 
245
- ## How many arguments [param method] on [param object] has to be given, or -1 when there is no such
246
- ## method. Read off the method list rather than [method Object.has_method], because a path can only
247
- ## call a method with nothing, and a method with a required argument called with nothing raises
248
- ## inside the game rather than answering.
249
- static func _arguments_required(object: Object, method: String) -> int:
292
+ ## The arguments a call step gives in its brackets, as {"args"}, or {"message"} saying why they are
293
+ ## not ones a path can give.
294
+ ##
295
+ ## Literals only: words in double quotes, numbers, true, false and null, read as JSON reads them, a
296
+ ## whole number as an int. A path names what is read, and an argument that was itself a path or an
297
+ ## object would make a read depend on other state in a way the step does not show.
298
+ static func arguments_of(named: String) -> Dictionary:
299
+ var inside: String = named.substr(named.find("(") + 1, named.length() - named.find("(") - 2).strip_edges()
300
+ if inside.is_empty():
301
+ return {"args": []}
302
+ var reader: JSON = JSON.new()
303
+ var parsed: Variant = reader.data if reader.parse("[%s]" % inside) == OK else null
304
+ if not parsed is Array:
305
+ return {"message": _not_literals(named)}
306
+ var args: Array = []
307
+ for given: Variant in parsed:
308
+ if given is float:
309
+ var number: float = given
310
+ if number == floorf(number) and absf(number) < 9.0e15:
311
+ args.append(int(number))
312
+ else:
313
+ args.append(number)
314
+ elif given == null or given is String or given is bool:
315
+ args.append(given)
316
+ else:
317
+ return {"message": _not_literals(named)}
318
+ return {"args": args}
319
+
320
+
321
+ static func _not_literals(named: String) -> String:
322
+ return (
323
+ "%s: a path gives a call literal arguments only, words in double quotes, numbers, true, false or null"
324
+ % named
325
+ )
326
+
327
+
328
+ ## How many arguments [param method] on [param object] takes, as {"least", "most", "any_more"}, or
329
+ ## an empty map when there is no such method. Read off the method list rather than
330
+ ## [method Object.has_method], because a method given too few or too many raises inside the game
331
+ ## rather than answering.
332
+ static func _arity(object: Object, method: String) -> Dictionary:
250
333
  for entry: Dictionary in object.get_method_list():
251
334
  if str(entry.get("name", "")) != method:
252
335
  continue
253
336
  var params: Array = entry.get("args", [])
254
337
  var defaults: Array = entry.get("default_args", [])
255
- return params.size() - defaults.size()
256
- return -1
338
+ var flags: int = entry.get("flags", 0)
339
+ return {
340
+ "least": params.size() - defaults.size(),
341
+ "most": params.size(),
342
+ "any_more": flags & METHOD_FLAG_VARARG != 0,
343
+ }
344
+ return {}
257
345
 
258
346
 
259
347
  ## Why a path may not call [param method] on [param object], said about [param called_as], or ""
@@ -265,16 +353,36 @@ static func _arguments_required(object: Object, method: String) -> int:
265
353
  ## doing something and answering nothing, and one left untyped is trusted. Without this a find
266
354
  ## with property "queue_free()" freed every node it matched, and a wait on "advance_day()" called
267
355
  ## it once a frame and then reported the state it had driven the game to.
268
- static func call_refused(object: Object, method: String, called_as: String) -> String:
269
- var required: int = _arguments_required(object, method)
270
- if required == -1:
356
+ ##
357
+ ## Given [param given] arguments, a call is the engine's and marked as reading, or it is refused. A
358
+ ## game's method taking arguments is trusted on nothing at all: one answering a value can as well
359
+ ## change what it was asked about, and runtime_invoke call is how one is called on purpose.
360
+ static func call_refused(object: Object, method: String, called_as: String, given: int = 0) -> String:
361
+ var arity: Dictionary = _arity(object, method)
362
+ if arity.is_empty():
271
363
  return "%s has no method %s" % [called_as, method]
272
- if required > 0:
364
+ var least: int = arity["least"]
365
+ var most: int = arity["most"]
366
+ var any_more: bool = arity["any_more"]
367
+ var scripted: Dictionary = _script_method(object, method)
368
+ if not scripted.is_empty() and (given > 0 or least > 0):
369
+ var why: String = (
370
+ "since nothing marks one as only reading%s; runtime_invoke call takes them" % READS_ONLY
371
+ )
273
372
  return (
274
- "%s.%s takes %d argument%s, and a path can only call a method that takes none"
275
- % [called_as, method, required, "" if required == 1 else "s"]
373
+ "%s.%s is the game's own and a path gives a game's method no arguments, %s"
374
+ % [called_as, method, why]
375
+ )
376
+ if given < least or (given > most and not any_more):
377
+ var takes: String = "%d" % least
378
+ if any_more:
379
+ takes = "at least %d" % least
380
+ elif most != least:
381
+ takes = "%d to %d" % [least, most]
382
+ return (
383
+ '%s.%s takes %s argument%s and the path gives it %d, in the brackets as in %s("name")'
384
+ % [called_as, method, takes, "" if takes == "1" else "s", given, method]
276
385
  )
277
- var scripted: Dictionary = _script_method(object, method)
278
386
  if not scripted.is_empty():
279
387
  var returned: Dictionary = scripted.get("return", {})
280
388
  var usage: int = returned.get("usage", 0)
@@ -350,7 +458,8 @@ static func read_under(holder: Variant, named: String) -> Variant:
350
458
  var object: Object = holder
351
459
  var method: String = method_of(named)
352
460
  if not method.is_empty():
353
- return object.call(method)
461
+ var args: Array = arguments_of(named)["args"]
462
+ return object.callv(method, args)
354
463
  return object.get(named)
355
464
 
356
465
 
@@ -374,20 +483,25 @@ static func _index_in(named: String, size: int) -> int:
374
483
  ## the whole path, because the step is the typo.
375
484
  static func nothing_there(holder: Variant, named: String, called_as: String) -> String:
376
485
  var container_call: String = method_of(named)
486
+ var given: Dictionary = arguments_of(named) if not container_call.is_empty() else {"args": []}
487
+ if given.has("message"):
488
+ return "%s:%s" % [called_as, given["message"]]
489
+ var given_args: Array = given["args"]
490
+ var given_count: int = given_args.size()
377
491
  if holder is Array:
378
492
  var items: Array = holder
379
493
  if container_call in NEEDS_AN_ELEMENT and items.is_empty():
380
494
  return "%s is an empty list, so it has no %s" % [called_as, named]
381
495
  if not container_call.is_empty():
382
- return container_calls_refused(called_as, "a list", container_call)
496
+ return container_calls_refused(called_as, "a list", container_call, given_count)
383
497
  return "%s is a list of %d, so there is no %s in it" % [called_as, items.size(), named]
384
498
  if packed(holder):
385
499
  if not container_call.is_empty():
386
- return container_calls_refused(called_as, "a packed list", container_call)
500
+ return container_calls_refused(called_as, "a packed list", container_call, given_count)
387
501
  return "%s is a packed list of %d, so there is no %s in it" % [called_as, len(holder), named]
388
502
  if holder is Dictionary:
389
503
  if not container_call.is_empty():
390
- return container_calls_refused(called_as, "a map", container_call)
504
+ return container_calls_refused(called_as, "a map", container_call, given_count)
391
505
  var map: Dictionary = holder
392
506
  var keys: Array = map.keys()
393
507
  # A key reading as the step and still not reached is one of a type a step cannot spell, and
@@ -410,7 +524,7 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
410
524
  var object: Object = holder
411
525
  var method: String = method_of(named)
412
526
  if not method.is_empty():
413
- return call_refused(object, method, called_as)
527
+ return call_refused(object, method, called_as, given_count)
414
528
  # A method spelled as a property is the likeliest thing behind a name the object has no property
415
529
  # for, and the caller is one pair of brackets away from what they meant.
416
530
  if object.has_method(named):
@@ -422,13 +536,18 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
422
536
 
423
537
 
424
538
  ## Why [param method] is not a call a path makes on [param sort] ("a list", "a packed list" or "a
425
- ## map"), naming the calls it does make.
426
- static func container_calls_refused(called_as: String, sort: String, method: String) -> String:
539
+ ## map"), naming the calls it does make, or that it makes this one with no arguments when
540
+ ## [param given] were.
541
+ static func container_calls_refused(
542
+ called_as: String, sort: String, method: String, given: int = 0
543
+ ) -> String:
427
544
  var calls: Array[String] = MAP_CALLS
428
545
  if sort == "a list":
429
546
  calls = LIST_CALLS
430
547
  elif sort == "a packed list":
431
548
  calls = PACKED_CALLS
549
+ if given > 0 and method in calls:
550
+ return "%s is %s, and a path calls %s() on one with no arguments" % [called_as, sort, method]
432
551
  var spelled: Array[String] = []
433
552
  for each: String in calls:
434
553
  spelled.append(each + "()")
@@ -16,6 +16,9 @@ const SPLITS_KEPT: int = 8
16
16
  ## Each `says` split into alternatives, by what was written; see [method alternatives].
17
17
  static var _splits: Dictionary[String, Array] = {}
18
18
 
19
+ ## Every space separator Unicode has besides the plain space, built once; see [method spaced].
20
+ static var _spaces: RegEx = null
21
+
19
22
 
20
23
  ## Whether [param node]'s own words are what [param wanted] asks for.
21
24
  static func says(node: Node, wanted: String) -> bool:
@@ -35,12 +38,33 @@ static func says(node: Node, wanted: String) -> bool:
35
38
  ## first, and read as one glob with the bars in it, it waited out its whole timeout for words no
36
39
  ## screen says.
37
40
  static func matches(said: String, wanted: String) -> bool:
41
+ var shown: String = spaced(said)
38
42
  for words: String in alternatives(wanted):
39
- if said.containsn(words) if is_plain(words) else said.matchn(words):
43
+ if shown.containsn(words) if is_plain(words) else shown.matchn(words):
40
44
  return true
41
45
  return false
42
46
 
43
47
 
48
+ ## [param text] with every Unicode space separator written as a plain space, which is what a caller
49
+ ## types for any of them. A game keeping a name on one line writes it with a non-breaking space, and
50
+ ## "Deskanem Bonituk" typed with a plain one found nothing while the screen showed exactly those
51
+ ## words. One character for one, so a place found in the result is the same place in [param text].
52
+ static func spaced(text: String) -> String:
53
+ if _spaces == null:
54
+ var separators: String = (
55
+ String.chr(0x00A0)
56
+ + String.chr(0x1680)
57
+ + String.chr(0x2000)
58
+ + "-"
59
+ + String.chr(0x200A)
60
+ + String.chr(0x202F)
61
+ + String.chr(0x205F)
62
+ + String.chr(0x3000)
63
+ )
64
+ _spaces = RegEx.create_from_string("[%s]" % separators)
65
+ return _spaces.sub(text, " ", true)
66
+
67
+
44
68
  ## The alternatives [param wanted] names, each as words on a screen: split at every `|` not written
45
69
  ## as `\|`, which stays a bar in the words. An empty alternative, a stray bar at either end, is
46
70
  ## dropped: it names no words, and a find that came back empty widened it to `**` and suggested a
@@ -110,4 +134,4 @@ static func widened(wanted: String) -> String:
110
134
  ## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
111
135
  ## an n, so the two characters mean the break to everybody who writes them.
112
136
  static func as_said(wanted: String) -> String:
113
- return wanted.replace("\\n", "\n")
137
+ return spaced(wanted.replace("\\n", "\n"))