extends Node class_name MpSteps ## The simulated player actions: reach, grab, carry, drop. ## ## These drive the real godot-xr-tools pickup path rather than teleporting ## objects around, because the point is to test what a player actually does. XR ## gives no tracked controller headlessly, so XRToolsFunctionPickup._process ## bails out on `_controller.get_is_active()` — the grab is therefore driven by ## hand: position the hand, let the grab Area3D register what is in range, ## refresh the closest-object pick, then press grip. ## ## Every action returns {ok: bool, detail: String}. ## Distance under which an object counts as "at" a snap zone. const SNAP_TOLERANCE := 0.12 ## Cooking and washing both take ~3s of station time; allow generously for it. const STATION_WORK_TIMEOUT := 20.0 var view: MpWorldView var report: MpReport var hand: XRToolsFunctionPickup var controller: Node3D var _hand_detached := false func setup(p_view: MpWorldView, p_report: MpReport, p_hand: XRToolsFunctionPickup, p_controller: Node3D) -> void: view = p_view report = p_report hand = p_hand controller = p_controller # --- waiting --------------------------------------------------------------- func wait_frames(n: int) -> void: for i in n: await get_tree().physics_frame ## Poll a condition instead of sleeping a guessed duration, so a slow machine ## does not produce a spurious failure and a fast one does not waste seconds. func wait_until(cond: Callable, what: String, timeout: float) -> bool: var deadline := Time.get_ticks_msec() + int(timeout * 1000.0) while Time.get_ticks_msec() < deadline: if cond.call(): return true await get_tree().process_frame report.log_line(" timed out after %.0fs waiting for %s" % [timeout, what]) return false ## Puts the hand at a world position. ## ## The controller is a child of the XROrigin, whose PlayerBody keeps moving under ## gravity — that drags the hand off any position we place it at. top_level ## detaches it into world space. Done lazily on first use, so simply running the ## scene by hand leaves a real tracked controller alone. func move_hand_to(pos: Vector3) -> void: if not _hand_detached: _hand_detached = true controller.top_level = true report.log_line(" (detached the test hand from the XR rig so it can be driven directly)") controller.global_position = pos controller.force_update_transform() # --- actions --------------------------------------------------------------- func grab(item: Node3D, label: String) -> Dictionary: if not is_instance_valid(item): return {"ok": false, "detail": "'%s' does not exist on this peer" % label} report.log_line(" reaching for %s at %s" % [label, item.global_position]) move_hand_to(item.global_position) await wait_frames(4) hand._update_closest_object() var closest := hand.closest_object report.log_line(" hand at %s, closest grabbable = %s" % [hand.global_position, closest]) if not is_instance_valid(closest): return {"ok": false, "detail": "hand found nothing to grab. %s" % view.diag(item, hand)} report.log_line(" pressing grip") hand._on_grip_pressed() await wait_frames(4) if not item.is_picked_up(): return {"ok": false, "detail": "grip pressed on %s but %s not picked up. %s" % [closest.name, label, view.diag(item, hand)]} if item.get_picked_up_by() != hand: return {"ok": false, "detail": "%s is held by %s, not our hand. %s" % [label, item.get_picked_up_by(), view.diag(item, hand)]} # Grabbing is predicted locally and confirmed by the server handing us the # object's NetXform. Until that lands the server is still driving it, so # waiting here is what makes the following carry meaningful. report.log_line(" holding %s; waiting for the server to hand over NetXform..." % label) if not await wait_until(func(): return view.xform_authority(item) == multiplayer.get_unique_id(), "NetXform handoff", 5.0): return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s" % [label, view.xform_authority(item), view.diag(item, hand)]} report.log_line(" NetXform is ours (peer %d)" % multiplayer.get_unique_id()) return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, view.diag(item, hand)]} func drop(item: Node3D, label: String) -> Dictionary: if not is_instance_valid(item): return {"ok": false, "detail": "'%s' does not exist on this peer" % label} if not item.is_picked_up(): return {"ok": false, "detail": "nothing to drop: %s is not held. %s" % [label, view.diag(item, hand)]} report.log_line(" releasing grip (held by %s)" % item.get_picked_up_by()) hand._on_grip_release() await wait_frames(8) if not is_instance_valid(item): return {"ok": true, "detail": "%s was consumed on release" % label} # Explicitly typed: `item` is a Node3D here, so get_picked_up_by()'s return # type is not known statically and := cannot infer it. var by: Node = item.get_picked_up_by() report.log_line(" after release %s is held by %s" % [label, by]) if by == hand: return {"ok": false, "detail": "grip released but our hand still holds %s" % label} # A station catching the object on release is intended — but only on the peer # that owns world logic. A client's own snap zone doing it means that peer is # running station logic it has no authority for. if by is XRToolsSnapZone and not NetworkManager.owns_world(): return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner (zone enabled=%s)" % [by.get_parent().name, label, by.enabled]} if not await wait_until(func(): return not is_instance_valid(item) or view.xform_authority(item) == 1, "NetXform to return to the server", 5.0): return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d" % [label, view.xform_authority(item)]} return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]} ## Grab an object, carry it onto a station's snap zone, and let go there. func place_in_zone(item: Node3D, label: String, station: String) -> Dictionary: var zone := view.zone_of(station) if not zone: return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station} var res := await grab(item, label) if not res["ok"]: return res res = await carry_to(item, label, zone.global_position) if not res["ok"]: return res res = await drop(item, label) if not res["ok"]: return res report.log_line(" waiting for %s to settle onto %s..." % [label, station]) await wait_until(func(): return not is_instance_valid(item) \ or item.global_position.distance_to(zone.global_position) <= SNAP_TOLERANCE, "%s to settle onto %s" % [label, station], 5.0) return {"ok": true, "detail": "placed %s into %s; %s" % [label, station, view.diag(item, hand)]} ## Grab an object and carry it into another, holding it there. Used for the ## combine (buns into the cooked burger) and for putting food on a plate, both of ## which are triggered by an Area3D overlap rather than by dropping. func carry_item_to_item(item: Node3D, label: String, target: Node3D, target_label: String) -> Dictionary: if not is_instance_valid(target): return {"ok": false, "detail": "target '%s' does not exist on this peer" % target_label} var target_pos := target.global_position var res := await grab(item, label) if not res["ok"]: return res res = await carry_to(item, label, target_pos) if not res["ok"] and is_instance_valid(item): return res # Hold it there a moment: the reaction happens on the server, and the object # we are holding may be consumed by it. for i in 60: if not is_instance_valid(item): report.log_line(" %s was consumed (the reaction fired)" % label) return {"ok": true, "detail": "%s consumed on contact with %s" % [label, target_label]} await get_tree().physics_frame # Both uses of this consume the carried object, so still holding it means the # reaction never fired. Let go first, so the next step is not left fighting # our hand. if is_instance_valid(item) and item.is_picked_up(): hand._on_grip_release() await wait_frames(8) return {"ok": false, "detail": "%s reached %s but was never consumed - the reaction did not fire; %s" % [label, target_label, view.diag(item, hand)]} ## Carry an object somewhere clear and put it down, freeing the station it was in. func park(item: Node3D, label: String, pos: Vector3) -> Dictionary: var res := await grab(item, label) if not res["ok"]: return res res = await carry_to(item, label, pos) if not res["ok"]: return res return await drop(item, label) ## Move the held object onto a world position. Closed-loop: the hand moves by ## whatever the object's remaining error is, since the grab point offsets the ## object from the hand by an amount not worth hard-coding. func carry_to(item: Node3D, label: String, target: Vector3) -> Dictionary: if not is_instance_valid(item) or not item.is_picked_up(): return {"ok": false, "detail": "cannot carry: %s is not held" % label} report.log_line(" carrying %s from %s to %s" % [label, item.global_position, target]) for i in 120: if not is_instance_valid(item): return {"ok": true, "detail": "%s was consumed while being carried" % label} var err := item.global_position - target if err.length() <= 0.01: break controller.global_position -= err await get_tree().physics_frame await get_tree().physics_frame if not is_instance_valid(item): return {"ok": true, "detail": "%s was consumed while being carried" % label} var dist := item.global_position.distance_to(target) report.log_line(" %s is now %.3fm from the target" % [label, dist]) if dist > 0.05: return {"ok": false, "detail": "could not carry %s to the target: still %.3fm away" % [label, dist]} return {"ok": true, "detail": "carried %s to within %.3fm" % [label, dist]} ## Wait for a station to produce an object with the given food id (cooking the ## burger, or combining into a hamburger). Server-side: stations only run there. func await_food(food_id: String) -> Dictionary: report.log_line(" waiting for a '%s' to appear..." % food_id) var ok := await wait_until(func(): return view.find_by_food_id(food_id) != null, "a '%s' to be produced" % food_id, STATION_WORK_TIMEOUT) if not ok: return {"ok": false, "detail": "no '%s' was produced within %.0fs" % [food_id, STATION_WORK_TIMEOUT]} # Let the spawn replicate before the checks that follow go looking for it. await wait_frames(30) var made := view.find_by_food_id(food_id) return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]} func await_clean(plate_name: String) -> Dictionary: var plate := view.find(plate_name) if not plate: return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name} var pc := plate.get_node_or_null("PlateController") report.log_line(" waiting for the sink to wash %s (dirty=%s)..." % [plate_name, pc.is_dirty if pc else "?"]) var ok := await wait_until(func(): return pc and not pc.is_dirty, "the sink to wash the plate", STATION_WORK_TIMEOUT) await wait_frames(30) if not ok: return {"ok": false, "detail": "%s was still dirty after %.0fs in the sink; %s" % [plate_name, STATION_WORK_TIMEOUT, view.diag(plate, hand)]} return {"ok": true, "detail": "%s was washed clean" % plate_name}