extends SceneTree ## T2 (docs/TUTORIAL_DESIGN.md ยง7): the whole tutorial, every chapter, played ## by a bot through Actions on the tutorial world (the Valley island, seed ## 1337): first tools, fire and food, hunting, fishing, shelter and night ## (with the demo wolf), sleeping, and the finish card. Materials it would ## take a while to gather by hand (wood for a hut, say) are put in the bag; ## every goal itself is done the way a player does it. ## Run: godot --headless --path . --script res://tests/test_tutorial_full.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 inv: Inventory var sv: Node var ticked: Array = [] var fire: Node3D var bench: Node3D var _pn: Control 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))) A.food_rng.seed = 3 # (so raw food doesn't make the bot ill at a bad moment) S.root_dir = "user://test_worlds" for w in S.list_worlds(): S.delete_world(w.id) var TS: Script = load("res://scripts/ui/title_screen.gd") check(TS.tutorial_world().is_empty(), "no tutorial world yet") S.current_id = S.create_world("Tutorial", TS.TUTORIAL_SEED, {"tutorial": true}) check(not TS.tutorial_world().is_empty(), "the title screen finds it once it's made (Tutorial, carry on)") var ts: Node = TS.new() var page: Control = ts._main_page() var labels: Array = page.get_children().filter(func(c): return c is Button).map(func(b): return String(b.text)) check(labels.has("Tutorial (carry on)") and not labels.any(func(t): return t.begins_with("Continue")), "and lists it once: no \"Continue: Tutorial\" as well %s" % [labels.slice(0, 3)]) page.free() ts.free() world = load("res://scenes/world.tscn").instantiate() root.add_child(world) for _i in range(5): await physics_frame player = world.player player.set_physics_process(false) player.survival.set_process(false) world.day_night.frozen = true world.weather.set_process(false) world.weather.force("clear") inv = player.inventory sv = player.survival _pn = load("res://scripts/ui/guide.gd").new() root.add_child(_pn) _pn.setup(player) 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) # --- helpers ------------------------------------------------------------------------------ func _at(id: String) -> bool: return String(G.current().get("id", "")) == id func _ground(p: Vector3) -> Vector3: return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z) func _near(p: Vector3, d: float = 1.2) -> void: var to: Vector3 = player.global_position - p to.y = 0.0 player.global_position = _ground(p + (to.normalized() if to.length() > 0.1 else Vector3(1, 0, 0)) * d) 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 _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 ## Puts `id` in hand: swapped into the first hotbar slot (only those can be ## held), like a player dragging it there. func _hold(id: String) -> int: for i in range(inv.size()): var s = inv.get_slot(i) if s != null and s.id == id: var tmp = inv.get_slot(0) inv.set_slot(0, s) inv.set_slot(i, tmp) player.select(0) return 0 return -1 func _fell(n: Node3D) -> void: _near(n.global_position, 1.4) var hits := 0 while not n.depleted and hits < 40: sv.stamina = 100.0 if A.harvest_hit(player, n, n.global_position + Vector3(0, 0.6, 0)): hits += 1 ## Places what's in `id`'s slot on the ground `ahead` metres in front of you. func _place(id: String, ahead: float, side: float = 0.0) -> Node3D: var slot := _hold(id) var f := -player.global_basis.z var r := player.global_basis.x for k in range(12): var at := _ground(player.global_position + f * (ahead + 0.5 * k) + r * side) var xf := Transform3D(Basis(Vector3.UP, player.rotation.y), at) if world.placement_valid(DB.get_item(id), xf) and A.place(player, slot, xf, world): for p in world.all_pieces(): if p.item_id == id and p.global_position.distance_to(at) < 0.05: return p return null func _by_fire() -> void: _near(fire.global_position, 2.0) func _step_done(id: String) -> bool: return ticked.has(id) func _body() -> void: await _tools() await _food() await _hunt() await _fishing() await _night() _finish() _finished = true # --- 1. first tools ------------------------------------------------------------------------- func _tools() -> void: print("1. first tools") check(G.active and _at("look"), "the tutorial world starts the tutorial (Old Tam: \"%s\")" % String(G.current().text).left(40)) check(world.wolves == null or not world.wolves.enabled, "a calm island: no wolf pack") check(world.bears == null, "no bear, nothing stealing food") G.walked(11.0) for id in ["stick", "stick", "stick", "stone", "stone", "stone", "fiber", "flint", "flint"]: _take(id) check(A.craft(player, "stone_axe"), "an axe") _hold("stone_axe") _fell(_node("tree")) while inv.count("stick") < 2: _take("stick") if inv.count("flint") < 1: _take("flint") check(A.craft(player, "stone_pickaxe"), "a pickaxe") _hold("stone_pickaxe") _fell(_node("rock")) check(ticked == ["look", "sticks", "flax", "axe", "tree", "pickaxe"], "chapter 1 done: %s" % [ticked]) # --- 2. fire and food ----------------------------------------------------------------------- func _food() -> void: print("2. fire and food") check(_at("campfire") and _pn._chapter.text.contains("Fire and food"), "next chapter: Fire and food") inv.add("stone", 5) inv.add("stick", 4) check(A.craft(player, "campfire"), "a campfire, by hand") fire = _place("campfire", 2.5) check(fire != null and _step_done("campfire"), "placed: it ticks") fire.fuel = fire.MAX_FUEL print(" berries") G._marker = G.find_marker() var bush: Node3D = null for b in world.props.forage.values(): if is_instance_valid(b) and b.ripe() and (bush == null or b.global_position.distance_to(player.global_position) < bush.global_position.distance_to(player.global_position)): bush = b check(bush != null and G.marker_position().distance_to(bush.global_position) < 0.05, "the marker's on the nearest ripe bush") _near(bush.global_position, 1.2) check(A.forage(player, bush) and inv.count("berries") > 0, "picked") sv.hunger = 40.0 check(A.eat(player, _hold("berries")) and _step_done("berries"), "and eaten: it ticks") print(" a mushroom") check(_take("mushroom"), "found one under the trees") _by_fire() check(A.craft(player, "roasted_mushroom") and _step_done("mushroom"), "roasted at the fire: it ticks") print(" the diet") check(_at("diet") and _pn._progress.text.is_empty(), "Old Tam explains the diet bars") var ev := InputEventKey.new() ev.keycode = KEY_ENTER ev.pressed = true _pn._unhandled_input(ev) check(_step_done("diet"), "Enter carries on") # --- 3. hunting ------------------------------------------------------------------------------ func _hunt() -> void: print("3. hunting") _by_fire() inv.add("wood", 14) inv.add("flint", 2) check(A.craft(player, "workbench"), "a workbench") bench = _place("workbench", 2.5, 2.5) check(bench != null and _step_done("workbench"), "placed") check(A.craft(player, "club") and _step_done("weapon"), "a club: a weapon") check(_at("rabbit"), "now: %s" % G.current().get("title", "?")) var rabbits: Array = world.creatures.alive("rabbit") check(rabbits.size() >= 3, "Old Tam's rabbits are about (%d)" % rabbits.size()) G._marker = G.find_marker() var rb: Node3D = null for r in rabbits: if rb == null or r.global_position.distance_to(player.global_position) < rb.global_position.distance_to(player.global_position): rb = r check(G.marker_position().distance_to(rb.global_position) < 0.05, "the marker's on the nearest one") rb.set_physics_process(false) _near(rb.global_position, 1.5) _hold("club") sv.stamina = 100.0 check(A.melee(player, rb) and rb.is_dead() and _step_done("rabbit"), "one blow with the club") check(A.butcher(player, rb), "butchered") _by_fire() check(A.craft(player, "cooked_meat") and _step_done("cook"), "and the meat cooked: it ticks") G.continue_step() check(_step_done("animals"), "(the tip about deer and boars)") # --- 4. fishing -------------------------------------------------------------------------------- func _fishing() -> void: print("4. fishing") _near(bench.global_position, 1.5) inv.add("stick", 2) inv.add("fiber", 3) inv.add("flint", 1) check(A.craft(player, "fishing_rod") and _step_done("rod"), "a rod at the workbench") G._marker = G.find_marker() var shore: Vector3 = G.marker_position() var t: Node3D = world.terrain check(shore != Vector3.INF and t.sample_height(shore.x, shore.z) >= t.water_level, "the marker shows the lake shore") var lake: Vector2 = t.lake_center var x: float = lake.x while t.sample_height(x, lake.y) < t.water_level + 0.4 and x < lake.x + 60.0: x += 0.5 player.global_position = _ground(Vector3(x + 1.0, 0, lake.y)) _hold("fishing_rod") sv.stamina = 100.0 var origin: Vector3 = player.global_position + Vector3(0, 1.6, 0) check(A.cast_line(player, origin, Vector3(lake.x, t.water_level, lake.y) - origin, world), "cast") var tt := 0.0 while tt < 400.0: var b: Node3D = world.bobber if b == null or b.state == "biting": break b.set_physics_process(false) b.step(0.05) tt += 0.05 check(world.bobber != null and world.bobber.state == "biting", "a bite (after %.0f s)" % tt) check(A.reel(player, world), "landed it") var fish := "perch" if inv.count("perch") > 0 else "trout" _by_fire() check(A.craft(player, "grilled_" + fish) and _step_done("fish"), "grilled: it ticks") # --- 5. shelter and night ---------------------------------------------------------------------- func _night() -> void: print("5. shelter and night") check(_at("hut"), "now: build a hut") print(" the outline") var ol: Node3D = G.outline check(ol != null and ol.slots.size() == 20 and ol.refresh() == 20, "Old Tam marks out a hut: a 20-piece outline") check(ol.center().distance_to(fire.global_position) < 9.0 and ol.center().distance_to(fire.global_position) > 3.0, "near the fire, on clear ground (%.0f m away), not on it" % ol.center().distance_to(fire.global_position)) G._marker = G.find_marker() check(G.marker_position().distance_to(ol.center()) < 0.05, "the marker points at it") var slot0: Transform3D = ol.slots[0][1] var aim := {"position": slot0.origin + Vector3(0.9, 0, -0.7), "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")} var r: Dictionary = world.build.placement(DB.get_item("wood_floor"), aim, player.rotation.y + 0.4) check(r.ok and r.xf.origin.distance_to(slot0.origin) < 0.01, "a floor aimed near its place in the outline goes right in") var yaw: float = ol.origin.basis.get_euler().y var hut: Dictionary = await world.build.debug_build_hut(ol.center(), yaw) for _i in range(3): await physics_frame check(hut.placed == 20, "a hut (the same snapping a player uses: %d pieces)" % hut.placed) check(not is_instance_valid(ol) or ol.is_queued_for_deletion() or ol.refresh() == 0, "every piece of the outline filled") var b := Basis(Vector3.UP, yaw) var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) check(center.distance_to(ol.center() if is_instance_valid(ol) else center) < 0.05, "(built right on it)") for _i in range(3): await physics_frame var under := 0 var space: PhysicsDirectSpaceState3D = world.get_world_3d().direct_space_state for pk in world.props.pickups.values(): if pk.taken or Vector2(pk.global_position.x - center.x, pk.global_position.z - center.z).length() > 5.0: continue var rq := PhysicsRayQueryParameters3D.create(pk.global_position + Vector3(0, 0.02, 0), pk.global_position + Vector3(0, 2.6, 0)) rq.collision_mask = 4 rq.hit_from_inside = true if not space.intersect_ray(rq).is_empty(): under += 1 check(under == 0, "no flax, sticks or stones poking up through the floor (%d left)" % under) player.global_position = center + b * Vector3(0.6, 0.05, -0.6) sv._shelter_left = 0.0 sv.step(0.2) G.poll() check(_step_done("hut"), "stand inside out of the weather: it ticks") print(" the bed") _near(bench.global_position, 1.5) inv.add("wood", 8) inv.add("fiber", 6) check(A.craft(player, "bed"), "a bed at the workbench") player.global_position = center + b * Vector3(0.6, 0.05, 0.9) var slot := _hold("bed") var bxf := Transform3D(b, center + b * Vector3(-0.5, 0, -0.4)) check(world.placement_valid(DB.get_item("bed"), bxf) and A.place(player, slot, bxf, world), "put inside") var bed: Node3D = null for p in world.all_pieces(): if p.item_id == "bed": bed = p check(A.use_bed(player, bed), "E on it: spawn set") G.poll() check(_step_done("bed"), "it ticks") print(" nightfall") check(_at("torch") and world.day_night.hours() >= 18.5, "the sun goes down (%.1f h)" % world.day_night.hours()) inv.add("stick", 1) inv.add("fiber", 2) check(A.craft(player, "torch") and _step_done("torch"), "a torch") print(" the wolf") check(_at("wolf") and G.demo_wolf != null, "a wolf comes out of the dark") var w: Node3D = G.demo_wolf var hp0: float = sv.health var closest := INF var t := 0.0 while is_instance_valid(w) and not w.is_queued_for_deletion() and t < 60.0: w.set_physics_process(false) w.step(0.1) closest = minf(closest, w.global_position.distance_to(player.global_position)) t += 0.1 if int(t * 10) % 10 == 0: await physics_frame check(_step_done("wolf"), "it circles, then slinks off (%.0f s)" % t) check(sv.health == hp0 and closest > 6.0, "never closer than %.0f m, and never bites" % closest) print(" sleep") check(_at("sleep") and world.day_night.hours() >= 20.9, "bedtime (%.1f h)" % world.day_night.hours()) player.global_position = center + b * Vector3(0.6, 0.05, 0.9) check(A.use_bed(player, bed), "into bed") check(_step_done("sleep"), "slept till morning: it ticks") func _finish() -> void: print("finished") var order := [] for s in G.steps: order.append(String(s.id)) check(ticked == order, "all %d steps ticked, in order" % order.size()) check(G.is_finished(), "the tutorial is done") check(_pn.card != null and is_instance_valid(_pn.card), "Old Tam's goodbye card is up") var buttons: Array = _pn.card.find_children("*", "Button", true, false) check(buttons.size() == 2 and buttons[0].text == "Start your own world" and buttons[1].text == "Keep exploring", "with Start your own world and Keep exploring") _pn._on_keep_exploring() check(_pn.card == null and not player.ui_open, "Keep exploring: back to the island") print("skipping") # (With nothing counted yet, so the next step doesn't tick at once.) var idx: int = G.index var saved: Dictionary = G.counters.duplicate() G.counters = {} G.index = 3 G.skip() check(G.index == 4 and String(G.current().id) == "tree", "(Skip step moves on: the pause menu's button)") G.index = idx G.counters = saved