extends SceneTree ## T1 (docs/TUTORIAL_DESIGN.md §7): the Guide, data/tutorial.json, Actions' ## events, the guide panel and the marker. Plays the first chapter by ## calling Actions, the way a player would, and checks every step ticks in ## order; a step done early ticks as soon as it comes up; progress is saved. ## Run: godot --headless --path . --script res://tests/test_tutorial.gd var _fails := 0 var _finished := false var A: Node var S: Node var DB: Node var G: Node var world: Node3D var player: CharacterBody3D var ticked: Array = [] func check(cond: bool, what: String) -> void: print((" ok " if cond else " FAIL ") + what) if not cond: _fails += 1 func _initialize() -> void: _run.call_deferred() func _run() -> void: A = root.get_node("Actions") S = root.get_node("Saves") DB = root.get_node("ItemDB") G = root.get_node("Guide") G.step_done.connect(func(_i, s): ticked.append(String(s.id))) S.root_dir = "user://test_worlds" for w in S.list_worlds(): S.delete_world(w.id) S.current_id = S.create_world("Tutorial", 1337, {"tutorial": true}) await _load() await _body() check(_finished, "test ran to the end (no script errors)") for w in S.list_worlds(): S.delete_world(w.id) DirAccess.remove_absolute(S.root_dir) print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) func _load() -> void: world = load("res://scenes/world.tscn").instantiate() root.add_child(world) for _i in range(5): await physics_frame world.creatures.enabled = false for c in world.creatures.alive(): c.queue_free() if world.wolves != null: world.wolves.enabled = false player = world.player player.set_physics_process(false) player.survival.set_process(false) func _reload() -> void: check(world.save_now(), "saved") world.queue_free() await process_frame await _load() ## The guide panel. (Headless runs have no HUD, so the test makes one, as ## the HUD does.) var _pn: Control = null func _panel() -> Control: if _pn == null or not is_instance_valid(_pn): _pn = load("res://scripts/ui/guide.gd").new() root.add_child(_pn) _pn.setup(player) return _pn ## Stands next to the nearest `id` pickup and picks it up. Returns false if ## there's none. func _take(id: String) -> bool: var best: Node3D = null var best_d := INF for pk in world.props.pickups.values(): if is_instance_valid(pk) and not pk.taken and pk.item_id == id: var d: float = pk.global_position.distance_to(player.global_position) if d < best_d: best_d = d best = pk if best == null: return false player.global_position = best.global_position + Vector3(0.8, 0.0, 0) return A.pickup(player, best) func _nearest_node(kind: String) -> Node3D: var best: Node3D = null var best_d := INF for n in world.props.resource_nodes.values(): if is_instance_valid(n) and n.kind == kind and not n.depleted: var d: float = n.global_position.distance_to(player.global_position) if d < best_d: best_d = d best = n return best func _hold(id: String) -> void: for i in range(player.inventory.size()): var s = player.inventory.get_slot(i) if s != null and s.id == id: player.select(i) return ## Hits `n` until it's down (chopped or broken). func _fell(n: Node3D) -> int: var to: Vector3 = player.global_position - n.global_position to.y = 0.0 player.global_position = n.global_position + (to.normalized() if to.length() > 0.1 else Vector3(1, 0, 0)) * 1.4 var hits := 0 while not n.depleted and hits < 40: player.survival.stamina = 100.0 if A.harvest_hit(player, n, n.global_position + Vector3(0, 0.6, 0)): hits += 1 return hits func _body() -> void: await _starts() await _chapter_one() await _saving() _finished = true func _starts() -> void: print("the tutorial starts") check(G.active and G.world == world, "a tutorial world starts the guide") var ch1: int = G.steps.filter(func(st): return st.chapter == "tools").size() check(ch1 == 6 and G.index == 0 and String(G.current().id) == "look", "chapter 1: six steps, starting with Look around") var pn := _panel() check(pn != null and pn.visible and pn._box.visible, "the guide panel is up") check(pn._title.text == "Look around" and pn._chapter.text.contains("First tools") and pn._chapter.text.contains("step 1 of %d" % G.steps.size()), "\"%s\" / \"%s\"" % [pn._chapter.text, pn._title.text]) check(G.guide_name == "Old Tam" and pn._text.text.begins_with("Old Tam: \""), "Old Tam the hermit talks you through it: %s" % pn._text.text) check(G.find_marker() == Vector3.INF, "(no marker for that one)") func _chapter_one() -> void: print("1. look around") var start: Vector3 = player.global_position for i in range(14): player.global_position = start + Vector3(1.0 * (i + 1), 0, 0) await process_frame await process_frame check(ticked == ["look"], "walk 10 m and it ticks (%s; walked %.0f m)" % [ticked, float(G.counters.get("walk", 0.0))]) player.global_position = start + Vector3(0, 0, 50) await process_frame check(float(G.counters.get("walk", 0.0)) < 15.0, "(a jump isn't counted as walking)") player.global_position = start await process_frame print("2. sticks and stones") check(String(G.current().id) == "sticks", "next: %s" % G.current().get("title", "?")) await process_frame G._marker = G.find_marker() var m: Vector3 = G.marker_position() var near_stick := false for pk in world.props.pickups.values(): if pk.item_id == "stick" and not pk.taken and pk.global_position.distance_to(m) < 0.01: near_stick = true check(m != Vector3.INF and near_stick, "the marker stands on the nearest stick") # Flax first, out of order: step 3 will tick the moment it comes up. check(_take("fiber"), "(pick some flax early)") check(_take("stick") and _take("stick"), "two sticks") check(G.progress_text().contains("Sticks 2/3") and G.progress_text().contains("Stone 0/3"), "the panel counts them: \"%s\"" % G.progress_text()) check(_panel()._progress.text == G.progress_text(), "(shown on the panel)") check(_take("stick") and _take("stone") and _take("stone"), "a third, and two stones") check(String(G.current().id) == "sticks", "not done yet (2 of 3 stones)") check(_take("stone"), "the third stone") check(ticked == ["look", "sticks", "flax"], "sticks and stones tick, and so does the flax picked earlier (%s)" % [ticked]) print("4. the axe") check(String(G.current().id) == "axe" and _panel()._title.text == "Craft a stone axe", "next: craft a stone axe") G._marker = G.find_marker() check(G.marker_position() != Vector3.INF, "the marker shows where flint is") check(_take("flint") and _take("flint"), "two flint") check(G.find_marker() == Vector3.INF, "with two in your bag, the marker goes") check(A.craft(player, "stone_axe"), "craft it") check(ticked.back() == "axe", "it ticks") print("5. a tree") G._marker = G.find_marker() var tree := _nearest_node("tree") check(G.marker_position().distance_to(tree.global_position) < 0.01, "the marker's on the nearest tree") _hold("stone_axe") var hits := _fell(tree) check(tree.depleted and ticked.back() == "tree", "it ticks when the tree comes down (%d hits)" % hits) print("6. pickaxe and boulder") check(String(G.current().id) == "pickaxe", "next: the pickaxe") while player.inventory.count("stick") < 2: _take("stick") if player.inventory.count("flint") < 1: _take("flint") check(A.craft(player, "stone_pickaxe"), "craft it") check(String(G.current().id) == "pickaxe" and G.progress_text().contains("✓ Stone Pickaxe"), "half done: \"%s\"" % G.progress_text()) var rock := _nearest_node("rock") _hold("stone_pickaxe") _fell(rock) check(rock.depleted and ticked.back() == "pickaxe", "break a boulder and it ticks") check(ticked == ["look", "sticks", "flax", "axe", "tree", "pickaxe"], "every step ticked, in order") check(String(G.current().get("chapter", "")) == "food" and _panel()._chapter.text.contains("Fire and food"), "chapter 1's done: on to Fire and food") func _saving() -> void: print("saving") var id: String = S.create_world("Tutorial 2", 1337, {"tutorial": true}) world.queue_free() await process_frame S.current_id = id ticked.clear() await _load() check(G.active and G.index == 0, "a fresh tutorial world starts at the beginning") G.walked(12.0) check(_take("stick") and _take("stone"), "a step and a bit in") check(G.index == 1 and int(G.counters.get("gained:stick", 0)) == 1, "(on step 2, one stick so far)") await _reload() check(G.active and G.index == 1 and String(G.current().id) == "sticks", "after a reload it's still on step 2") check(int(G.counters.get("gained:stick", 0)) == 1 and int(G.counters.get("gained:stone", 0)) == 1, "with what you'd gathered") print("other worlds") var plain: String = S.create_world("Ordinary", 1337) world.queue_free() await process_frame S.current_id = plain await _load() _pn.queue_free() _pn = null check(not G.active and not _panel().visible, "an ordinary world has no tutorial")