From 9b83fe4ec8b59621bd41dd037cc100702e9cf1b0 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:50:53 -0400 Subject: [PATCH 1/2] Document machinery interaction hints scope --- docs/development/machinery-interaction-hints-milestone.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/development/machinery-interaction-hints-milestone.md diff --git a/docs/development/machinery-interaction-hints-milestone.md b/docs/development/machinery-interaction-hints-milestone.md new file mode 100644 index 0000000..0bf1dfc --- /dev/null +++ b/docs/development/machinery-interaction-hints-milestone.md @@ -0,0 +1,7 @@ +# Machinery interaction hints milestone + +Scope: concise binding-aware interaction guidance in the existing contextual machinery card. Own machinery_context_card.gd, machinery_preview.gd and focused tests only. Preserve observed identity, uncertainty, paid command transport, persistence and collision geometry. No changes to inventory hints, walker, bootstrap/input/camera or simulation contracts. + +Acceptance: paid no-port actual-shell hover, selection, reset and disconnect; GUI focus; remapped/unbound controls; narrow wrapping; canonical and geometry invariance. Windows and Linux CI must execute. Native stills only if tests leave material uncertainty; no video, uploads, releases, deployment or self-merge. + +Verification: implementation and focused checks pending. From 9819631f4d39d425985dee9070bae024cbacd658 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:57:53 -0400 Subject: [PATCH 2/2] Show binding-aware machinery interaction hints --- .../machinery-interaction-hints-milestone.md | 20 +++- .../production/machinery_context_card.gd | 11 +- .../production/machinery_preview.gd | 26 ++++- tests/godot/machinery_context_shell_test.gd | 100 +++++++++++++++++- 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/docs/development/machinery-interaction-hints-milestone.md b/docs/development/machinery-interaction-hints-milestone.md index 0bf1dfc..ca9a2ad 100644 --- a/docs/development/machinery-interaction-hints-milestone.md +++ b/docs/development/machinery-interaction-hints-milestone.md @@ -1,7 +1,21 @@ # Machinery interaction hints milestone -Scope: concise binding-aware interaction guidance in the existing contextual machinery card. Own machinery_context_card.gd, machinery_preview.gd and focused tests only. Preserve observed identity, uncertainty, paid command transport, persistence and collision geometry. No changes to inventory hints, walker, bootstrap/input/camera or simulation contracts. +The existing card now explains the currently bound mouse selection and cursor controls. Selecting a machine opens Construction for that exact observed assembly without submitting a machine intent. Keyboard/controller bindings truthfully state the existing static-body picking limit and direct the player to Construction's chooser; unbound selection retains that route. Unbound cursor controls describe release-camera or Controls settings routes. -Acceptance: paid no-port actual-shell hover, selection, reset and disconnect; GUI focus; remapped/unbound controls; narrow wrapping; canonical and geometry invariance. Windows and Linux CI must execute. Native stills only if tests leave material uncertainty; no video, uploads, releases, deployment or self-merge. +Hints occupy a wrapping fifth row in the existing anchored card; no persistent world labels or new shortcuts are added. The hint is hidden while Construction is visible or GUI/settings owns input. Observed identity, energy, job details and fresh/stale/blocked/paused/outcome-unknown copy remain independent of the hint. The card fits its content height within the scene. Bindings are read live without preference writes. -Verification: implementation and focused checks pending. +## Ownership and contracts + +MachineryPreview and MachineryContextCard own only local presentation and per-frame hint refresh. Canonical production state remains in ProductionSimulationSystem and persisted records. Tick cadence, schemas, authenticated command validation/transport, pending identities, receipts, persistence, paid resource accounting and collision/mesh construction are unchanged. Existing bounded activity telemetry remains the profiling signal; no new simulation work occurs. + +Scope is machinery_context_card.gd, machinery_preview.gd, the existing focused machinery_context_shell_test.gd and this document. Inventory hints PR125, walker PR124, shared bootstrap/input/camera and authority boundaries are outside scope. + +## Verification + +Godot 4.7.2 on Windows: repository runner import and startup checks, paid no-port actual-client-shell regression and machinery_preview_test pass. The shell uses authenticated real-domain commands to acquire/assemble machines and exercise running, timeout-stale, host-blocked, saved/restored paused and outcome-unknown states. It injects mouse motion/button input through the actual root window and confirms exact-machine Construction selection and zero outbound machine intents. It covers hover/exit/reset/disconnect, GUI focus and suspended input, drawer/tab transitions, live in-memory mouse/keyboard/controller/unbound bindings, canonical snapshot and collider identity/transform/bounds invariance. Narrow widths include 640, 480 and 360 px with full wrapped hint and uncertainty line bounds. + +Adjacent machinery_activity_animation_test and integrated_panel_layout_test regressions pass. Windows/Linux PR CI execution and exact tested synthetic merge SHA are pending; the documentation-only initial draft CI is not runtime evidence. + +## Limitations + +Keyboard/controller world selection currently routes only Area3D; machinery uses StaticBody3D. This PR describes the existing Construction chooser route and does not repair shared picking. Input/cursor descriptions are tested through bindings and injected mouse events; headless checks do not establish native capture, physical controller/VR or human gameplay acceptance. Screenshots are unnecessary where meaningful runtime/layout tests and source review establish this presentation change; no media, ports, releases or deployment are used. This task does not self-merge. Worktree remains active for coordinator review. diff --git a/src/presentation/production/machinery_context_card.gd b/src/presentation/production/machinery_context_card.gd index 7b86f55..85ad089 100644 --- a/src/presentation/production/machinery_context_card.gd +++ b/src/presentation/production/machinery_context_card.gd @@ -22,11 +22,11 @@ func _ready() -> void: var rows := VBoxContainer.new() rows.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_child(rows) - for index in range(4): + for index in range(5): var line := Label.new() line.mouse_filter = Control.MOUSE_FILTER_IGNORE line.add_theme_font_size_override("font_size", 16) - if index == 3: + if index >= 3: line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART else: line.clip_text = true @@ -35,10 +35,12 @@ func _ready() -> void: _lines.append(line) visible = false -func present(proxy: Node3D, lines: PackedStringArray) -> void: +func present(proxy: Node3D, lines: PackedStringArray, hint: String = "") -> void: target = proxy for index in range(_lines.size()): _lines[index].text = lines[index] if index < lines.size() else "" + _lines[4].text = hint + _lines[4].visible = not hint.is_empty() _process(0.0) func _process(_delta: float) -> void: @@ -57,7 +59,8 @@ func _process(_delta: float) -> void: visible = Rect2(Vector2.ZERO, extent).has_point(_anchor) if not visible: return size = extent - _panel.size = Vector2(minf(420, extent.x - MARGIN * 2), 116) + _panel.size = Vector2(minf(420, extent.x - MARGIN * 2), 0) + _panel.size.y = maxf(116, _panel.get_combined_minimum_size().y) _panel.position = Vector2(MARGIN, extent.y - _panel.size.y - MARGIN) queue_redraw() diff --git a/src/presentation/production/machinery_preview.gd b/src/presentation/production/machinery_preview.gd index ec5800f..5b69899 100644 --- a/src/presentation/production/machinery_preview.gd +++ b/src/presentation/production/machinery_preview.gd @@ -344,4 +344,28 @@ func _present_context() -> void: if not _labels.has(id): _context_card.present(null, PackedStringArray()) return - _context_card.present(_labels[id]["body"], _context_lines.get(id, PackedStringArray())) \ No newline at end of file + _context_card.present(_labels[id]["body"], _context_lines.get(id, PackedStringArray()), _interaction_hint()) + +## Describe only existing routes. Machinery bodies currently have native mouse +## picking; the shared keyboard/controller ray router accepts Area3D only. +func _interaction_hint() -> String: + if not WorldSelectionInput.gameplay_allowed(get_viewport()): return "" + var ancestor: Node = get_parent() + while ancestor != null: + if ancestor is FirstPlayableBootstrap: + var panel: MachineryPanel = ancestor._machinery_panel + if is_instance_valid(panel) and panel.is_visible_in_tree(): return "" + break + ancestor = ancestor.get_parent() + var controls := PlayerInputSettings.instance() + var events: Array[InputEvent] = InputMap.action_get_events("world_select") + if events.is_empty(): return "World select unbound · choose machine in Construction" + var binding: String = controls.binding_label("world_select") + if not events[0] is InputEventMouseButton: + return "%s cannot pick machinery · choose machine in Construction" % binding + var hint: String = "%s: select machine · opens Construction" % binding + var cursor_events: Array[InputEvent] = InputMap.action_get_events("ui_cursor") + if cursor_events.is_empty(): + if InputMap.action_get_events("release_cursor").is_empty(): return "Cursor controls unbound · open Controls settings" + return "%s: release camera · then %s" % [controls.binding_label("release_cursor"), hint] + return "Hold %s for cursor · %s" % [controls.binding_label("ui_cursor"), hint] diff --git a/tests/godot/machinery_context_shell_test.gd b/tests/godot/machinery_context_shell_test.gd index 77f76be..12ffda0 100644 --- a/tests/godot/machinery_context_shell_test.gd +++ b/tests/godot/machinery_context_shell_test.gd @@ -27,6 +27,24 @@ func snap(name: String) -> void: DirAccess.make_dir_recursive_absolute("res://var/ci/machinery-context-stills") check(root.get_texture().get_image().save_png("res://var/ci/machinery-context-stills/" + name + ".png") == OK, "native shell still") +func click_machine() -> void: + var surface: SubViewportContainer = client._habitat.get_viewport().get_parent() + var point: Vector2 = surface.get_global_rect().get_center() + root.notify_mouse_entered() + var motion := InputEventMouseMotion.new() + motion.position = point + motion.global_position = point + root.push_input(motion, true) + for pressed in [true, false]: + var event := InputEventMouseButton.new() + event.position = point + event.global_position = point + event.window_id = root.get_window_id() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + root.push_input(event, true) + await settle() + func finish() -> void: PlayerInputSettings.instance()._reset_map() root.content_scale_mode = Window.CONTENT_SCALE_MODE_DISABLED @@ -55,6 +73,9 @@ func finish() -> void: check(hit.get("collider") == body, "actual center ray picks unchanged exact machine body") var card: MachineryContextCard = preview._context_card var before: String = world.capture_world_snapshot_json().value + var geometry: Array = [] + for shape: CollisionShape3D in preview.find_children("*", "CollisionShape3D", true, false): + geometry.append([shape.get_instance_id(), shape.global_transform, shape.shape.get_debug_mesh().get_aabb()]) preview.clear_context() check(not card.visible, "no persistent card without context") for entry in preview._labels.values(): check(not entry["label"].visible, "machine world captions hidden") @@ -63,18 +84,75 @@ func finish() -> void: body.mouse_entered.emit() check(card.target == body and card.visible, "native collider hover discovers exact machine") check(card._lines[0].text.contains(id.right(8)), "stable identity discoverable") + check(card._lines[4].visible and card._lines[4].text.contains(PlayerInputSettings.instance().binding_label("world_select")) and card._lines[4].text.contains("opens Construction"), "default mouse binding describes existing inspection route") + var controls := PlayerInputSettings.instance() + var remapped := InputEventMouseButton.new() + remapped.button_index = MOUSE_BUTTON_MIDDLE + PlayerInputSettings._set_binding("world_select", remapped) + preview._process(0.0) + check(card._lines[4].text.contains(controls.binding_label("world_select")) and not card._lines[4].text.contains("Left"), "visible card refreshes remapped mouse binding without observation") + var keyboard := InputEventKey.new() + keyboard.physical_keycode = KEY_H + PlayerInputSettings._set_binding("world_select", keyboard) + preview._process(0.0) + check(card._lines[4].text.contains(controls.binding_label("world_select")) and card._lines[4].text.contains("cannot pick machinery") and card._lines[4].text.contains("Construction"), "keyboard guidance states current static-body picking limit") + var controller := InputEventJoypadButton.new() + controller.button_index = JOY_BUTTON_A + PlayerInputSettings._set_binding("world_select", controller) + preview._process(0.0) + check(card._lines[4].text.contains(controls.binding_label("world_select")) and card._lines[4].text.contains("cannot pick machinery"), "controller guidance does not promise unsupported activation") + InputMap.action_erase_events("world_select") + preview._process(0.0) + check(card._lines[4].text == "World select unbound · choose machine in Construction", "unbound selection keeps existing chooser route") + controls._reset_map() + PlayerInputSettings._set_binding("ui_cursor", keyboard) + preview._set_hover(id) + preview._process(0.0) + check(card._lines[4].text.contains("Hold " + controls.binding_label("ui_cursor")), "guidance refreshes live cursor binding") + InputMap.action_erase_events("ui_cursor") + preview._process(0.0) + check(card._lines[4].text.contains(controls.binding_label("release_cursor")) and card._lines[4].text.contains("release camera"), "unbound cursor describes existing capture release") + InputMap.action_erase_events("release_cursor") + preview._process(0.0) + check(card._lines[4].text == "Cursor controls unbound · open Controls settings", "fully unbound cursor gives settings route") + controls._reset_map() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + preview._process(0.0) + check(world.capture_world_snapshot_json().value == before and remote.sent.is_empty(), "binding advice queues no commands and changes no canonical state") var shapes: Array = preview.find_children("*", "CollisionShape3D", true, false) await snap("hover") body.mouse_exited.emit() check(not card.visible, "hover exit clears unselected details") - preview.machine_selected.emit(id) + await click_machine() + check(client._machinery_panel._selected(client._machinery_panel._machine) == id and client._machinery_panel.is_visible_in_tree(), "native shell mouse selection opens Construction for exact machine") + check(not card._lines[4].visible and card._lines[4].text.is_empty(), "open Construction suppresses duplicate hint") + check(remote.sent.is_empty(), "selection opens inspection without submitting machine intent") client._machinery_panel._refresh_placement() preview._process(0.0) check(card.target == body and card.visible, "existing panel selection displays exact picked assembly") client._machinery_panel._machine.grab_focus() body.mouse_entered.emit() check(preview._hovered_id.is_empty() and card.target == body, "GUI focus blocks hover while selected identity persists") + check(not card._lines[4].visible and not card._lines[3].text.is_empty(), "GUI ownership hides hints while observed uncertainty remains") client._machinery_panel._machine.release_focus() + client._set_drawer_open(false) + preview._set_hover(id) + preview._process(0.0) + check(card._lines[4].visible, "closing Construction restores hover guidance") + client._open_panel(1) + preview._process(0.0) + check(card._lines[4].visible, "unrelated open panel does not suppress machinery guidance") + client._drawer_toggle.grab_focus() + preview._process(0.0) + check(not card._lines[4].visible, "GUI focus suppresses actionable gameplay hint") + client._drawer_toggle.release_focus() + client._set_drawer_open(false) + preview._set_hover(id) + PlayerInputSettings.instance().suspend_navigation(true) + preview._process(0.0) + check(not card._lines[4].visible, "suspended input cannot advertise activation") + PlayerInputSettings.instance().suspend_navigation(false) + preview._set_hover(id) move([-6000, 0, -1000]) command("machine_run", {"machine_id": id, "recipe": "extract.ice"}) show_observation() @@ -105,10 +183,30 @@ func finish() -> void: await settle() preview._process(0.0) card._process(0.0) + check(card._lines[4].visible, "narrow hover retains interaction guidance") + check(card._lines[4].get_line_count() == card._lines[4].get_visible_line_count(), "narrow guidance is fully readable") check(card.visible and Rect2(Vector2.ZERO, card.size).encloses(card._panel.get_rect()), "card remains bounded in actual narrow shell viewport") check(card._lines[3].get_line_count() == card._lines[3].get_visible_line_count(), "narrow uncertainty statement remains fully readable") await snap("narrow") + for width in [480, 360]: + root.size = Vector2i(width, 720) + for mode in ["mouse", "keyboard", "controller", "unbound"]: + controls._reset_map() + if mode == "keyboard": PlayerInputSettings._set_binding("world_select", keyboard) + elif mode == "controller": PlayerInputSettings._set_binding("world_select", controller) + elif mode == "unbound": InputMap.action_erase_events("world_select") + preview._set_hover(id) + preview._process(0.0) + await settle() + check(card.visible and Rect2(Vector2.ZERO, card.size).encloses(card._panel.get_rect()), "narrow %d %s hint and card bounded" % [width, mode]) + for line: Label in [card._lines[3], card._lines[4]]: + check(line.get_line_count() == line.get_visible_line_count() and card._panel.get_global_rect().encloses(line.get_global_rect()), "narrow %d %s uncertainty and hint fully readable" % [width, mode]) + controls._reset_map() check(preview.find_children("*", "CollisionShape3D", true, false) == shapes, "context preserves exact colliders") + var after_geometry: Array = [] + for shape: CollisionShape3D in preview.find_children("*", "CollisionShape3D", true, false): + after_geometry.append([shape.get_instance_id(), shape.global_transform, shape.shape.get_debug_mesh().get_aabb()]) + check(after_geometry == geometry, "guidance preserves collider identities transforms and bounds") var snapshot: String = world.capture_world_snapshot_json().value preview.clear_context() body.mouse_entered.emit()