ravensight-playtest 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -19,9 +19,18 @@
19
19
  #
20
20
  # Requests are drained in _process, at most 8 per frame, so the scene tree is
21
21
  # read at a consistent point between frames.
22
+ #
23
+ # LOCKSTEP (RAVENSIGHT_DRIVER_LOCKSTEP=1, set by the CLI beside --fixed-fps) is
24
+ # the deterministic mode. The game's clock is stopped from this node's _ready,
25
+ # before the main scene loads, and only moves through the `step` request: apply
26
+ # these inputs, run exactly this many ticks, stop, answer with a digest of the
27
+ # state. `_sim_tick` counts those ticks and nothing else, so the time a persona
28
+ # spends thinking is not in it. Parked frames still run, with a delta of zero:
29
+ # game code that counts frames rather than using delta can still tell how long
30
+ # it was parked, which is one of the limits docs/REPLAY.md names.
22
31
  extends Node
23
32
 
24
- const DRIVER_VERSION := "1.0.0"
33
+ const DRIVER_VERSION := "1.2.0"
25
34
  const DEFAULT_PORT := 47800
26
35
  const FEATURE_TAG := "ravensight_driver"
27
36
  const ADDON_DIR := "ravensight_driver"
@@ -48,6 +57,13 @@ const ERR_HEADLESS := -32010
48
57
  const ERR_NODE_NOT_FOUND := -32011
49
58
  const ERR_ACTION_NOT_DEFINED := -32012
50
59
  const ERR_HOOK := -32020
60
+ const ERR_NOT_LOCKSTEP := -32030
61
+
62
+ const MAX_STEP_TICKS := 3600
63
+ const MAX_STEP_EVENTS := 64
64
+ const MAX_DIGEST_SUBTREES := 64
65
+ const MAX_DRAW_WAIT_FRAMES := 30
66
+ const MAX_DIGEST_STATE_BYTES := 4096
51
67
 
52
68
  var _server: TCPServer
53
69
  var _peer: StreamPeerTCP
@@ -59,6 +75,19 @@ var _pending_waits: Array = []
59
75
  var _last_scene_path := ""
60
76
  var _extra_methods := {}
61
77
 
78
+ var _lockstep := false
79
+ var _sim_tick := 0
80
+ var _seed_value := 0
81
+ var _step_left := 0
82
+ var _step_id: Variant = null
83
+ var _step_digest := true
84
+ var _step_answer_next := false
85
+ var _resume_scale := 1.0
86
+ var _held_keys := {}
87
+ var _held_actions := {}
88
+ var _reasserted := 0
89
+ var _step_clicks: Array = []
90
+
62
91
  ## --- Lifecycle ----------------------------------------------------------------
63
92
 
64
93
  func _ready() -> void:
@@ -68,6 +97,12 @@ func _ready() -> void:
68
97
  queue_free()
69
98
  return
70
99
 
100
+ # Before anything else, and before the main scene exists: a seed set after
101
+ # the first scene's _ready, or a clock stopped after its first frame, would
102
+ # already have let the wall clock into the run.
103
+ if OS.get_environment("RAVENSIGHT_DRIVER_LOCKSTEP") == "1":
104
+ _enter_lockstep(OS.get_environment("RAVENSIGHT_DRIVER_SEED"))
105
+
71
106
  _token = OS.get_environment("RAVENSIGHT_DRIVER_TOKEN")
72
107
  _authed = _token.is_empty()
73
108
 
@@ -145,6 +180,8 @@ func _on_sdk_event(event_name: String, data: Dictionary) -> void:
145
180
  ## --- Socket pump --------------------------------------------------------------
146
181
 
147
182
  func _process(_delta: float) -> void:
183
+ if _lockstep:
184
+ _advance_lockstep()
148
185
  _accept_connections()
149
186
  _read_client()
150
187
  _drain_inbox()
@@ -228,6 +265,7 @@ func _close_client() -> void:
228
265
  _buffer = PackedByteArray()
229
266
  _inbox.clear()
230
267
  _pending_waits.clear()
268
+ _step_id = null
231
269
  _authed = _token.is_empty()
232
270
 
233
271
 
@@ -277,6 +315,9 @@ func _handle_line(line: String) -> void:
277
315
  "key": _rpc_key(id, params)
278
316
  "click": _rpc_click(id, params)
279
317
  "wait_frames": _rpc_wait_frames(id, params)
318
+ "seed": _rpc_seed(id, params)
319
+ "step": _rpc_step(id, params)
320
+ "digest": _send_result(id, _lockstep_answer(true))
280
321
  "screenshot": _rpc_screenshot(id, params)
281
322
  "set_time_scale": _rpc_set_time_scale(id, params)
282
323
  "list_actions": _send_result(id, {"actions": _action_names()})
@@ -348,6 +389,11 @@ func _rpc_ping(id: Variant) -> void:
348
389
  "frame": Engine.get_process_frames(),
349
390
  "time_scale": Engine.time_scale,
350
391
  "main_scene": scene.scene_file_path if scene != null else "",
392
+ "lockstep": _lockstep,
393
+ "user_dir": OS.get_user_data_dir(),
394
+ "tick": _sim_tick,
395
+ "seed": _seed_value,
396
+ "ticks_per_second": Engine.physics_ticks_per_second,
351
397
  })
352
398
 
353
399
 
@@ -443,6 +489,11 @@ func _rpc_key(id: Variant, params: Dictionary) -> void:
443
489
  if keycode == KEY_NONE:
444
490
  _send_error(id, ERR_INVALID_PARAMS, "unknown keycode: %s" % str(params.get("keycode")))
445
491
  return
492
+ var event := _send_key_event(keycode, params)
493
+ _send_result(id, {"ok": true, "keycode": keycode, "pressed": event.pressed})
494
+
495
+
496
+ func _send_key_event(keycode: int, params: Dictionary) -> InputEventKey:
446
497
  var event := InputEventKey.new()
447
498
  event.keycode = keycode
448
499
  event.physical_keycode = keycode
@@ -453,7 +504,7 @@ func _rpc_key(id: Variant, params: Dictionary) -> void:
453
504
  if unicode != 0:
454
505
  event.unicode = unicode
455
506
  Input.parse_input_event(event)
456
- _send_result(id, {"ok": true, "keycode": keycode, "pressed": event.pressed})
507
+ return event
457
508
 
458
509
 
459
510
  func _rpc_click(id: Variant, params: Dictionary) -> void:
@@ -470,13 +521,40 @@ func _rpc_click(id: Variant, params: Dictionary) -> void:
470
521
  var pos := Vector2.ZERO
471
522
  if params.has("node_path"):
472
523
  var target := get_node_or_null(NodePath(str(params.get("node_path"))))
473
- if target == null or not (target is Control):
524
+ var at: Variant = _click_position(target)
525
+ if at == null:
474
526
  _send_error(id, ERR_NODE_NOT_FOUND, "clickable node not found: %s" % str(params.get("node_path")))
475
527
  return
476
- pos = (target as Control).get_global_rect().get_center()
528
+ pos = at
477
529
  else:
478
530
  pos = Vector2(float(params.get("x", 0.0)), float(params.get("y", 0.0)))
531
+ _send_click_events(pos, params)
532
+ _send_result(id, {"ok": true, "position": [pos.x, pos.y]})
533
+
534
+
535
+ ## Where a Control's centre is in the window. Mouse events are in window pixels and
536
+ ## a stretched project draws its canvas at another size, so the canvas position alone
537
+ ## landed somewhere else on any game with a stretch mode.
538
+ func _window_position(control: Control) -> Vector2:
539
+ return get_window().get_final_transform() * control.get_global_rect().get_center()
540
+
479
541
 
542
+ ## Where a click on this node lands in the window, or null when it cannot be clicked.
543
+ ## A Node3D is clicked where the active camera draws its origin; one behind the camera,
544
+ ## or in a scene with no camera, is not on screen to click.
545
+ func _click_position(target: Node) -> Variant:
546
+ if target is Control:
547
+ return _window_position(target as Control)
548
+ if target is Node3D:
549
+ var camera := (target as Node3D).get_viewport().get_camera_3d()
550
+ var at := (target as Node3D).global_position
551
+ if camera == null or camera.is_position_behind(at):
552
+ return null
553
+ return get_window().get_final_transform() * camera.unproject_position(at)
554
+ return null
555
+
556
+
557
+ func _send_click_events(pos: Vector2, params: Dictionary) -> void:
480
558
  var button := MOUSE_BUTTON_LEFT
481
559
  match str(params.get("button", "left")):
482
560
  "right": button = MOUSE_BUTTON_RIGHT
@@ -495,7 +573,6 @@ func _rpc_click(id: Variant, params: Dictionary) -> void:
495
573
  click.pressed = pressed
496
574
  click.double_click = pressed and bool(params.get("double", false))
497
575
  Input.parse_input_event(click)
498
- _send_result(id, {"ok": true, "position": [pos.x, pos.y]})
499
576
 
500
577
 
501
578
  func _rpc_wait_frames(id: Variant, params: Dictionary) -> void:
@@ -518,7 +595,7 @@ func _rpc_screenshot(id: Variant, params: Dictionary) -> void:
518
595
  if _is_headless():
519
596
  _send_error(id, ERR_HEADLESS, "screenshot unavailable in headless mode")
520
597
  return
521
- await RenderingServer.frame_post_draw
598
+ await _await_post_draw()
522
599
  var image := get_viewport().get_texture().get_image()
523
600
  if image == null:
524
601
  _send_error(id, ERR_HEADLESS, "viewport produced no image")
@@ -538,14 +615,34 @@ func _rpc_screenshot(id: Variant, params: Dictionary) -> void:
538
615
  })
539
616
 
540
617
 
618
+ ## The next drawn frame, or the last one. A window the desktop has covered or
619
+ ## moved to another space stops drawing, frame_post_draw never fires, and an
620
+ ## unbounded wait here hung the RPC until the client gave up on the run.
621
+ func _await_post_draw() -> void:
622
+ var drawn := [false]
623
+ var mark := func() -> void: drawn[0] = true
624
+ RenderingServer.frame_post_draw.connect(mark, CONNECT_ONE_SHOT)
625
+ for _i in MAX_DRAW_WAIT_FRAMES:
626
+ await get_tree().process_frame
627
+ if drawn[0]:
628
+ return
629
+ if RenderingServer.frame_post_draw.is_connected(mark):
630
+ RenderingServer.frame_post_draw.disconnect(mark)
631
+
632
+
541
633
  func _rpc_set_time_scale(id: Variant, params: Dictionary) -> void:
542
634
  var problem := _check_params(params, {"scale": "number", "adjust_physics": "boolean"})
543
635
  if not problem.is_empty():
544
636
  _send_error(id, ERR_INVALID_PARAMS, problem)
545
637
  return
546
- var previous := Engine.time_scale
638
+ var previous := _resume_scale if _lockstep else Engine.time_scale
547
639
  var scale := clampf(float(params.get("scale", 1.0)), 0.0, 16.0)
548
- Engine.time_scale = scale
640
+ # In lockstep the clock belongs to `step`, so this names the rate the next
641
+ # step runs at rather than starting the game.
642
+ if _lockstep:
643
+ _resume_scale = scale
644
+ else:
645
+ Engine.time_scale = scale
549
646
  if bool(params.get("adjust_physics", false)) and scale > 0.0:
550
647
  Engine.physics_ticks_per_second = maxi(1, int(round(60.0 * scale)))
551
648
  _send_result(id, {"time_scale": scale, "previous": previous})
@@ -564,6 +661,307 @@ func _rpc_quit(id: Variant, params: Dictionary) -> void:
564
661
  get_tree().quit(code)
565
662
 
566
663
 
664
+ ## --- Lockstep -----------------------------------------------------------------
665
+
666
+ ## Stops the clock and takes the seed. Runs in _ready, before the main scene, and
667
+ ## first in every frame's _process (the lowest priority there is), which is what
668
+ ## makes "this many ticks" exact: Engine.time_scale is read once at the top of an
669
+ ## iteration, so a change made here takes effect from the next one, and no node
670
+ ## ran a frame between this request and the stop.
671
+ func _enter_lockstep(seed_text: String) -> void:
672
+ _lockstep = true
673
+ process_priority = -2147483648
674
+ _seed_value = int(seed_text) if seed_text.is_valid_int() else 0
675
+ seed(_seed_value)
676
+ var ticks_text := OS.get_environment("RAVENSIGHT_DRIVER_TICKS")
677
+ if ticks_text.is_valid_int() and int(ticks_text) > 0:
678
+ Engine.physics_ticks_per_second = int(ticks_text)
679
+ Engine.physics_jitter_fix = 0.0
680
+ Engine.max_physics_steps_per_frame = 1
681
+ _resume_scale = Engine.time_scale if Engine.time_scale > 0.0 else 1.0
682
+ Engine.time_scale = 0.0
683
+ _set_physics_running(false)
684
+ # Godot drops every held key when its window loses focus, so a desktop that
685
+ # moved focus during a hold cut the hold short at a tick the wall clock chose.
686
+ # A window that never takes focus has nothing to lose. Input here is synthetic
687
+ # anyway, and the run no longer takes the developer's keyboard.
688
+ get_window().unfocusable = true
689
+
690
+
691
+ ## A physics step with a delta of zero still moves things: a stack of rigid bodies
692
+ ## came out differently depending on how many parked frames it sat through, which
693
+ ## is a number the wall clock chooses. So the servers stop while the game is
694
+ ## parked. A game paused on its own keeps them stopped, as SceneTree does.
695
+ func _set_physics_running(running: bool) -> void:
696
+ var active := running and not get_tree().paused
697
+ PhysicsServer2D.set_active(active)
698
+ PhysicsServer3D.set_active(active)
699
+
700
+
701
+ func _advance_lockstep() -> void:
702
+ if _step_answer_next:
703
+ # The final tick ran to its end in the frame before this one, so the
704
+ # digest reads what that tick left and not what it was halfway through.
705
+ _step_answer_next = false
706
+ var id: Variant = _step_id
707
+ _step_id = null
708
+ _send_result(id, _lockstep_answer(_step_digest))
709
+ return
710
+ if _step_left > 0:
711
+ _reassert_held()
712
+ _sim_tick += 1
713
+ _step_left -= 1
714
+ if _step_left == 0:
715
+ # A rate the game set during the step is the game's own (a hit stop,
716
+ # a slow motion), so the next step resumes at it.
717
+ _resume_scale = Engine.time_scale
718
+ Engine.time_scale = 0.0
719
+ _set_physics_running(false)
720
+ _step_answer_next = true
721
+ return
722
+ if Engine.time_scale != 0.0:
723
+ _resume_scale = Engine.time_scale
724
+ Engine.time_scale = 0.0
725
+ # Nothing can move while parked, and an unthrottled loop would hold a core
726
+ # for as long as a persona thinks.
727
+ OS.delay_msec(2)
728
+
729
+
730
+ ## `seed{value}` reseeds the global generator. The seed the run starts on is the
731
+ ## one from RAVENSIGHT_DRIVER_SEED, taken before the first scene; this is for a
732
+ ## client that needs a known generator state again mid run.
733
+ func _rpc_seed(id: Variant, params: Dictionary) -> void:
734
+ var problem := _check_params(params, {"value": "number"})
735
+ if not problem.is_empty():
736
+ _send_error(id, ERR_INVALID_PARAMS, problem)
737
+ return
738
+ if not params.has("value"):
739
+ _send_error(id, ERR_INVALID_PARAMS, "value is required")
740
+ return
741
+ _seed_value = int(params.get("value"))
742
+ seed(_seed_value)
743
+ _send_result(id, {"seed": _seed_value, "lockstep": _lockstep, "tick": _sim_tick, "ticks_per_second": Engine.physics_ticks_per_second})
744
+
745
+
746
+ ## `step{events, ticks, digest}`: apply the inputs, run exactly `ticks` ticks,
747
+ ## stop, answer. One request, so nothing can land between the input and the first
748
+ ## tick that sees it, on a persona's run or on a replay of it.
749
+ func _rpc_step(id: Variant, params: Dictionary) -> void:
750
+ if not _lockstep:
751
+ _send_error(id, ERR_NOT_LOCKSTEP, "step needs lockstep mode")
752
+ return
753
+ var problem := _check_params(params, {"ticks": "number", "events": "array", "digest": "boolean"})
754
+ if not problem.is_empty():
755
+ _send_error(id, ERR_INVALID_PARAMS, problem)
756
+ return
757
+ if _step_id != null:
758
+ _send_error(id, ERR_BUSY, "a step is already running")
759
+ return
760
+ var ticks: int = int(params.get("ticks", 1))
761
+ if ticks < 1 or ticks > MAX_STEP_TICKS:
762
+ _send_error(id, ERR_INVALID_PARAMS, "ticks must be 1 to %d" % MAX_STEP_TICKS)
763
+ return
764
+ var events: Array = params.get("events", [])
765
+ if events.size() > MAX_STEP_EVENTS:
766
+ _send_error(id, ERR_INVALID_PARAMS, "at most %d events in one step" % MAX_STEP_EVENTS)
767
+ return
768
+ # Every event is checked before the first is applied, so a refused step
769
+ # changes nothing.
770
+ var resolved: Array = []
771
+ for event in events:
772
+ var checked := _check_step_event(event)
773
+ if checked.has("code"):
774
+ _send_error(id, int(checked["code"]), str(checked["message"]), checked.get("data"))
775
+ return
776
+ resolved.append(checked)
777
+ _step_clicks = []
778
+ for checked in resolved:
779
+ _apply_step_event(checked)
780
+ _step_digest = bool(params.get("digest", true))
781
+ _step_id = id
782
+ _step_left = ticks
783
+ # A game that draws randf() in _process whatever its delta (a screen shake)
784
+ # spends the generator on every parked frame, and how many there were is the
785
+ # wall clock's choice. Reseeding from the tick puts the generator back on the
786
+ # one sequence this tick always gets, however long the game sat parked.
787
+ seed(_seed_value * 1000003 + _sim_tick)
788
+ Engine.time_scale = _resume_scale
789
+ _set_physics_running(true)
790
+
791
+
792
+ ## The desktop can take focus from a game mid hold, and Godot then drops every held
793
+ ## key. The trace says the key is down, so it goes back down on the next tick. The
794
+ ## count comes back in the digest: a run where this happened may be off by that much.
795
+ func _reassert_held() -> void:
796
+ for keycode in _held_keys:
797
+ if not Input.is_physical_key_pressed(keycode):
798
+ _reasserted += 1
799
+ _send_key_event(int(keycode), _held_keys[keycode])
800
+ for action in _held_actions:
801
+ if not Input.is_action_pressed(action):
802
+ _reasserted += 1
803
+ Input.action_press(action)
804
+
805
+
806
+ func _check_step_event(raw: Variant) -> Dictionary:
807
+ if typeof(raw) != TYPE_DICTIONARY:
808
+ return {"code": ERR_INVALID_PARAMS, "message": "an event must be an object"}
809
+ var event: Dictionary = raw
810
+ match str(event.get("type", "")):
811
+ "key":
812
+ var keycode := _resolve_keycode(event.get("keycode"))
813
+ if keycode == KEY_NONE:
814
+ return {"code": ERR_INVALID_PARAMS, "message": "unknown keycode: %s" % str(event.get("keycode"))}
815
+ return {"type": "key", "keycode": keycode, "params": event}
816
+ "action":
817
+ var action := str(event.get("action", ""))
818
+ if not InputMap.has_action(action):
819
+ return {"code": ERR_ACTION_NOT_DEFINED, "message": "input action not defined: %s" % action, "data": {"available_actions": _action_names()}}
820
+ return {"type": "action", "action": action, "pressed": bool(event.get("pressed", true))}
821
+ "click":
822
+ var pos := Vector2(float(event.get("x", 0.0)), float(event.get("y", 0.0)))
823
+ if event.has("node_path"):
824
+ var target := get_node_or_null(NodePath(str(event.get("node_path"))))
825
+ var at: Variant = _click_position(target)
826
+ if at == null:
827
+ return {"code": ERR_NODE_NOT_FOUND, "message": "clickable node not found: %s" % str(event.get("node_path"))}
828
+ pos = at
829
+ return {"type": "click", "position": pos, "params": event}
830
+ return {"code": ERR_INVALID_PARAMS, "message": "unknown event type: %s" % str(event.get("type", ""))}
831
+
832
+
833
+ func _apply_step_event(event: Dictionary) -> void:
834
+ match str(event["type"]):
835
+ "key":
836
+ var key_params: Dictionary = event["params"]
837
+ _send_key_event(int(event["keycode"]), key_params)
838
+ if bool(key_params.get("pressed", true)):
839
+ _held_keys[int(event["keycode"])] = key_params
840
+ else:
841
+ _held_keys.erase(int(event["keycode"]))
842
+ "click":
843
+ var at: Vector2 = event["position"]
844
+ _step_clicks.append([at.x, at.y])
845
+ _send_click_events(at, event["params"])
846
+ "action":
847
+ var action := str(event["action"])
848
+ var pressed := bool(event["pressed"])
849
+ if pressed:
850
+ Input.action_press(action)
851
+ _held_actions[action] = true
852
+ else:
853
+ Input.action_release(action)
854
+ _held_actions.erase(action)
855
+ var input := InputEventAction.new()
856
+ input.action = action
857
+ input.pressed = pressed
858
+ Input.parse_input_event(input)
859
+
860
+
861
+ func _lockstep_answer(with_digest: bool) -> Dictionary:
862
+ var out := {"tick": _sim_tick, "frame": Engine.get_process_frames(), "reasserted": _reasserted, "clicks": _step_clicks}
863
+ if with_digest:
864
+ out["digest"] = _state_digest()
865
+ return out
866
+
867
+
868
+ ## What the trace compares: the current scene's nodes and the game's own
869
+ ## _ravensight_state() hooks. Autoloads are left out on purpose. They hold what a
870
+ ## run is allowed to differ on (session ids, an SDK's network queue, audio), and
871
+ ## whatever they do to the game shows up in the scene. Floats are cut to three
872
+ ## places; a name Godot generated (@Sprite2D@123) comes from a process-wide
873
+ ## counter, so it is replaced with the node's position among its siblings.
874
+ func _state_digest() -> Dictionary:
875
+ var scene := get_tree().current_scene
876
+ var out := {"scene": scene.scene_file_path if scene != null else "", "nodes": 0, "truncated": false, "subtrees": {}}
877
+ var budget := {"left": MAX_NODES, "truncated": false, "count": 0}
878
+ var parts := PackedStringArray()
879
+ if scene != null:
880
+ parts.append(_digest_fields(scene, 0))
881
+ budget["count"] = 1
882
+ var index := 0
883
+ for child in scene.get_children():
884
+ if _is_driver_node(child):
885
+ index += 1
886
+ continue
887
+ var own := PackedStringArray()
888
+ _digest_node(child, index, own, budget)
889
+ var joined := "".join(own)
890
+ parts.append(joined)
891
+ if index < MAX_DIGEST_SUBTREES:
892
+ out["subtrees"][_digest_name(child, index)] = joined.sha256_text().substr(0, 12)
893
+ index += 1
894
+ var states := {}
895
+ var hooks: Array = get_tree().get_nodes_in_group(STATE_GROUP)
896
+ for node in hooks:
897
+ if not node.has_method("_ravensight_state"):
898
+ continue
899
+ var raw: Variant = node.call("_ravensight_state")
900
+ if typeof(raw) == TYPE_DICTIONARY:
901
+ states[str(node.get_path())] = _jsonify(raw, 0)
902
+ var state_text := JSON.stringify(states, "", true)
903
+ parts.append(state_text)
904
+ out["nodes"] = budget["count"]
905
+ out["truncated"] = budget["truncated"]
906
+ out["state_hash"] = "".join(parts).sha256_text().substr(0, 32)
907
+ if state_text.to_utf8_buffer().size() <= MAX_DIGEST_STATE_BYTES:
908
+ out["game_state"] = states
909
+ return out
910
+
911
+
912
+ func _digest_node(node: Node, index: int, parts: PackedStringArray, budget: Dictionary) -> void:
913
+ if int(budget["left"]) <= 0:
914
+ budget["truncated"] = true
915
+ return
916
+ budget["left"] = int(budget["left"]) - 1
917
+ budget["count"] = int(budget["count"]) + 1
918
+ parts.append(_digest_fields(node, index))
919
+ var child_index := 0
920
+ for child in node.get_children():
921
+ _digest_node(child, child_index, parts, budget)
922
+ child_index += 1
923
+
924
+
925
+ func _digest_name(node: Node, index: int) -> String:
926
+ var node_name := str(node.name)
927
+ return "#%d" % index if node_name.begins_with("@") else node_name
928
+
929
+
930
+ func _digest_fields(node: Node, index: int) -> String:
931
+ var fields := "%s|%s" % [node.get_class(), _digest_name(node, index)]
932
+ if node is CanvasItem:
933
+ fields += "|v%d" % int((node as CanvasItem).is_visible_in_tree())
934
+ if node is Node2D:
935
+ var n2 := node as Node2D
936
+ fields += "|%.3f,%.3f,%.3f" % [n2.global_position.x, n2.global_position.y, n2.global_rotation]
937
+ elif node is Node3D:
938
+ var n3 := node as Node3D
939
+ var p3 := n3.global_position
940
+ var a3 := n3.global_rotation
941
+ fields += "|v%d|%.3f,%.3f,%.3f|%.3f,%.3f,%.3f" % [int(n3.is_visible_in_tree()), p3.x, p3.y, p3.z, a3.x, a3.y, a3.z]
942
+ elif node is Control:
943
+ var rect := (node as Control).get_global_rect()
944
+ fields += "|%.3f,%.3f,%.3f,%.3f" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y]
945
+ if node is CharacterBody2D:
946
+ var v2 := (node as CharacterBody2D).velocity
947
+ fields += "|%.3f,%.3f" % [v2.x, v2.y]
948
+ elif node is CharacterBody3D:
949
+ var v3 := (node as CharacterBody3D).velocity
950
+ fields += "|%.3f,%.3f,%.3f" % [v3.x, v3.y, v3.z]
951
+ elif node is RigidBody2D:
952
+ var r2 := (node as RigidBody2D).linear_velocity
953
+ fields += "|%.3f,%.3f" % [r2.x, r2.y]
954
+ elif node is RigidBody3D:
955
+ var r3 := (node as RigidBody3D).linear_velocity
956
+ fields += "|%.3f,%.3f,%.3f" % [r3.x, r3.y, r3.z]
957
+ var text := _text_of(node)
958
+ if not text.is_empty():
959
+ fields += "|t:" + text.substr(0, MAX_TEXT_CHARS)
960
+ if node is BaseButton:
961
+ fields += "|d%d" % int((node as BaseButton).disabled)
962
+ return fields + ";"
963
+
964
+
567
965
  func _is_number_quad(value: Variant) -> bool:
568
966
  if typeof(value) != TYPE_ARRAY:
569
967
  return false