extends SceneTree ## P1 iron (docs/PROGRESSION_DESIGN.md ยง2): iron nuggets, clay and rusty ## boulders on the island; the charcoal kiln, smelter and forge; iron tools, ## arrows and the lantern; tools wearing out and being repaired. ## Run: godot --headless --path . --script res://tests/test_iron.gd var _fails := 0 var _finished := false var A: Node var S: Node var DB: Node var _toasts: Array[String] = [] var world: Node3D var player: CharacterBody3D 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") A.notify.connect(func(t): _toasts.append(t)) S.root_dir = "user://test_worlds" for w in S.list_worlds(): S.delete_world(w.id) S.current_id = S.create_world("Iron", 1337) 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) func _body() -> void: await _resources() await _mining() await _kiln_and_smelter() await _forge_and_tools() await _wear_and_repair() await _arrows_and_spears() await _saving() _finished = true # --- on the island -------------------------------------------------------------- func _resources() -> void: print("iron and clay on the island") var nuggets: Array = world.props.pickups.values().filter(func(p): return p.item_id == "iron_nugget") var clay: Array = world.props.pickups.values().filter(func(p): return p.item_id == "clay") var rusty: Array = world.props.resource_nodes.values().filter(func(n): return n.is_iron) check(nuggets.size() >= 40, "%d iron nuggets lying about" % nuggets.size()) var by_rim := nuggets.filter(func(p): return world.props._near_rim(p.global_position.x, p.global_position.z) > 0.2).size() check(by_rim > nuggets.size() / 2, "mostly in the scree under the mountains (%d of %d)" % [by_rim, nuggets.size()]) var lake: Vector2 = world.terrain.lake_center var at_lake := clay.filter(func(p): return Vector2(p.global_position.x - lake.x, p.global_position.z - lake.y).length() < world.terrain.lake_radius * 1.7).size() check(clay.size() >= 30 and at_lake == clay.size(), "%d lumps of clay, all on the lake shore" % clay.size()) check(rusty.size() >= 4, "%d rusty boulders (of %d)" % [rusty.size(), world.props.resource_nodes.values().filter(func(n): return n.kind == "rock").size()]) var total: int = world.props.pickups.size() check(world.props.pickups.has("p0") and world.props.pickups["p0"].item_id == "stick", "the older pickups keep their ids (%d in all; the new kinds come last)" % total) func _mining() -> void: print("mining iron ore") var rusty: Array = world.props.resource_nodes.values().filter(func(n): return n.is_iron and not n.depleted) var b: Node3D = rusty[0] _stand_by(b.global_position, 1.4) _give("stone_pickaxe", 1) player.select(_slot_of("stone_pickaxe")) var hits := 0 while not b.depleted and hits < 20: player.survival.stamina = 100.0 if A.harvest_hit(player, b, b.global_position + Vector3(0, 0.4, 0)): hits += 1 check(b.depleted and player.inventory.count("iron_ore") >= 2, "a rusty boulder breaks into %d iron ore (and stone), %d hits" % [player.inventory.count("iron_ore"), hits]) var pick = player.inventory.get_slot(_slot_of("stone_pickaxe")) check(DB.durability(pick) == 60 - hits, "the stone pickaxe wore down: %d of 60 uses left" % DB.durability(pick)) # --- kiln and smelter ----------------------------------------------------------- var smelter: Node3D func _kiln_and_smelter() -> void: print("the charcoal kiln") var kiln := _place("charcoal_kiln", 3.0) check(kiln != null and kiln.get("inventory") != null, "a kiln (it opens like a chest)") kiln.inventory.add("wood", 5) check(kiln.working(), "with wood in it, it gets to work") kiln.step(19.0) check(kiln.inventory.count("charcoal") == 0, "nothing yet after 19 s") kiln.step(25.0) check(kiln.inventory.count("charcoal") == 2 and kiln.inventory.count("wood") == 1, "after 44 s: 2 charcoal from 4 wood (1 left over)") check(not kiln.working(), "then it's idle (one wood isn't enough)") print("the smelter") smelter = _place("smelter", -3.0) check(smelter != null, "a smelter") smelter.inventory.add("iron_ore", 2) smelter.inventory.add("charcoal", 2) smelter.step(30.0) check(smelter.inventory.count("iron_bar") == 1, "ore and charcoal: an iron bar after 30 s") smelter.step(30.0) check(smelter.inventory.count("iron_bar") == 2 and smelter.inventory.count("iron_ore") == 0, "and another") smelter.inventory.add("iron_nugget", 3) smelter.inventory.add("charcoal", 1) smelter.step(31.0) check(smelter.inventory.count("iron_bar") == 3 and smelter.inventory.count("iron_nugget") == 0, "three nuggets and a charcoal make a bar too") smelter.inventory.add("iron_ore", 1) smelter.inventory.add("charcoal", 1) world.skip_time(40.0) check(smelter.inventory.count("iron_bar") == 4, "it works through the night (sleeping)") ## A piece of `id` on the ground beside the player (`side` metres to the right). func _place(id: String, side: float) -> Node3D: var at: Vector3 = player.global_position + player.global_basis.x * side - player.global_basis.z * 3.0 at.y = world.terrain.sample_height(at.x, at.z) return world.spawn_piece(DB.get_item(id), Transform3D(Basis(), at)) # --- the forge and iron tools --------------------------------------------------- func _forge_and_tools() -> void: print("the forge") _clear() _give("iron_bar", 11) _give("wood", 10) _give("fiber", 2) check(not A.can_craft(player, "iron_axe"), "no iron axe without a forge") var forge := _place("forge", 0.0) check(forge != null and A.station_near(player, "forge"), "a forge") check(A.craft(player, "iron_axe"), "an iron axe") check(A.craft(player, "iron_pickaxe") and A.craft(player, "lantern") and A.craft(player, "iron_spear"), "an iron pickaxe, a lantern and an iron spear") _give("stick", 4) _give("feather", 4) check(A.craft(player, "iron_arrow") and player.inventory.count("iron_arrow") == 10, "10 iron arrows") check(A.craft(player, "nails") and player.inventory.count("nails") == 10, "10 nails") print("iron is better") var trees: Array = world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted) trees.sort_custom(func(a, b): return a.global_position.distance_to(player.global_position) < b.global_position.distance_to(player.global_position)) var stone_hits := await _fell(trees[0], "stone_axe") var iron_hits := await _fell(trees[1], "iron_axe") check(iron_hits * 2 <= stone_hits + 1, "an iron axe fells a tree in %d hits, a stone one in %d" % [iron_hits, stone_hits]) check(DB.get_item("lantern").has("light"), "the lantern gives light (held like a torch)") func _fell(tree: Node3D, axe: String) -> int: _stand_by(tree.global_position, 1.5) if player.inventory.count(axe) == 0: _give(axe, 1) player.select(_slot_of(axe)) var hits := 0 while not tree.depleted and hits < 30: player.survival.stamina = 100.0 if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)): hits += 1 return hits # --- wear and repair ---------------------------------------------------------------- func _wear_and_repair() -> void: print("tools wear out") _clear() _give("stone_axe", 1) var slot := _slot_of("stone_axe") player.inventory.set_slot(slot, {"id": "stone_axe", "count": 1, "dur": 1}) player.select(slot) var trees: Array = world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted) var t: Node3D = trees[0] _stand_by(t.global_position, 1.5) _toasts.clear() player.survival.stamina = 100.0 A.harvest_hit(player, t, t.global_position + Vector3(0, 1.0, 0)) check(player.inventory.count("stone_axe") == 0 and _toasts.has("Your stone axe broke"), "an axe on its last use breaks (%s)" % [_toasts]) print("repairs") _give("stone_pickaxe", 1) slot = _slot_of("stone_pickaxe") player.inventory.set_slot(slot, {"id": "stone_pickaxe", "count": 1, "dur": 12}) check(not A.can_repair(player, slot), "a worn pickaxe can't be repaired without a workbench and flint") var wb := _place("workbench", 3.0) _give("flint", 1) check(A.can_repair(player, slot) and A.repair(player, slot), "at a workbench, with a flint, it can") check(DB.durability(player.inventory.get_slot(slot)) == 60 and player.inventory.count("flint") == 0, "and it's as good as new (60), for 1 flint") check(not A.can_repair(player, slot), "(a tool that isn't worn has nothing to repair)") _give("iron_axe", 1) var islot := _slot_of("iron_axe") player.inventory.set_slot(islot, {"id": "iron_axe", "count": 1, "dur": 50}) _give("iron_bar", 1) check(A.can_repair(player, islot) == A.station_near(player, "forge"), "iron tools are repaired at the forge, with an iron bar") wb.queue_free() print("wear sticks with the item") var chest := _place("chest", -3.0) await physics_frame player.inventory.set_slot(islot, {"id": "iron_axe", "count": 1, "dur": 77}) A.transfer(player, player.inventory, islot, chest.inventory, chest) var in_chest = null for s in chest.inventory.slots: if s != null and s.id == "iron_axe": in_chest = s check(in_chest != null and DB.durability(in_chest) == 77, "moved into a chest, the axe is still at 77 uses") var cursor = chest.inventory.click(chest.inventory.slots.find(in_chest), null, false) cursor = player.inventory.click(10, cursor, true) # (right-click: put down one) check(cursor == null and DB.durability(player.inventory.get_slot(10)) == 77, "picked up and put down by hand, still 77") var data: Array = player.inventory.to_data() var inv2 := Inventory.new(40) inv2.load_data(data) check(DB.durability(inv2.get_slot(10)) == 77, "and through saving and loading") # --- arrows and spears --------------------------------------------------------------- func _arrows_and_spears() -> void: print("iron arrows") _clear() _give("bow", 1) _give("arrow", 5) _give("iron_arrow", 3) player.select(_slot_of("bow")) var eye: Vector3 = player.global_position + Vector3(0, 1.6, 0) var before := world.get_children().size() check(A.can_draw(player) and A.shoot(player, eye, -player.global_basis.z, 1.0, world), "the bow shoots") check(player.inventory.count("iron_arrow") == 2 and player.inventory.count("arrow") == 5, "an iron arrow first") var pr: Node = world.get_children().back() check(pr.get("item_id") == "iron_arrow" and absf(float(pr.damage) - 32.0 * 1.5) < 0.01, "for half as much again: %.0f damage at full draw" % float(pr.get("damage"))) check(DB.durability(player.inventory.get_slot(_slot_of("bow"))) == 99, "and the bow wears a little") print("a thrown spear keeps its wear") _give("spear", 1) var ss := _slot_of("spear") player.inventory.set_slot(ss, {"id": "spear", "count": 1, "dur": 20}) player.select(ss) player.survival.stamina = 100.0 var drops_before: int = world.all_dropped().size() A.throw_spear(player, ss, eye, (-player.global_basis.z + Vector3(0, -0.6, 0)).normalized(), world) var t := 0.0 while world.all_dropped().size() == drops_before and t < 5.0: await physics_frame t += 1.0 / 60.0 var landed: Array = world.all_dropped().filter(func(d): return d.item_id == "spear") check(not landed.is_empty() and int(landed[0].extra.get("dur", -1)) == 19, "it lands with 19 uses left") if not landed.is_empty(): _stand_by(landed[0].global_position, 1.0) A.pickup(player, landed[0]) check(DB.durability(player.inventory.get_slot(_slot_of("spear"))) == 19, "and picked up again, it still has 19 (no free repairs)") # --- saving ------------------------------------------------------------------------ func _saving() -> void: print("saving") _clear() _give("iron_pickaxe", 1) player.inventory.set_slot(_slot_of("iron_pickaxe"), {"id": "iron_pickaxe", "count": 1, "dur": 123}) smelter.inventory.load_data(Inventory.new(6).to_data()) smelter.inventory.add("iron_ore", 3) smelter.inventory.add("charcoal", 3) smelter.step(12.0) var at: Vector3 = smelter.global_position check(world.save_now(), "saved") world.queue_free() await process_frame await _load() var sm: Node3D = null for p in world.all_pieces(): if p.item_id == "smelter" and p.global_position.distance_to(at) < 0.1: sm = p check(sm != null and sm.inventory.count("iron_ore") == 3 and absf(float(sm.progress) - 12.0) < 0.5, "the smelter comes back with its ore, 12 s into the batch (%s ore, %.1f s)" % [ sm.inventory.count("iron_ore") if sm != null else -1, float(sm.progress) if sm != null else -1.0]) check(DB.durability(player.inventory.get_slot(_slot_of("iron_pickaxe"))) == 123, "and the pickaxe with 123 uses left") # --- helpers ----------------------------------------------------------------------- func _stand_by(pos: Vector3, d: float) -> void: var dir: Vector3 = player.global_position - pos dir.y = 0.0 if dir.length() < 0.1: dir = Vector3(1, 0, 0) var at: Vector3 = pos + dir.normalized() * d at.y = world.terrain.sample_height(at.x, at.z) player.global_position = at func _give(id: String, n: int) -> void: player.inventory.add(id, n) func _clear() -> void: player.inventory.load_data(Inventory.new(40).to_data()) func _slot_of(id: String) -> int: for i in range(player.inventory.size()): var s = player.inventory.get_slot(i) if s != null and s.id == id: return i return -1