extends SceneTree ## P4 food (docs/PROGRESSION_DESIGN.md ยง5): food spoils on schedule wherever ## it is (bag, chest, the ground), stacks average their freshness, the ## smoking rack and salt preserve it, raw meat and fish can make you ill, ## and a world (or server) can make food rot slowly or not at all. ## Run: godot --headless --path . --script res://tests/test_food.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 var inv: Inventory const DAY := 1200.0 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("Food", 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) Inventory.rot_speed = 1.0 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 world.bears.enabled = false if world.bears.bear != null: world.bears.bear.queue_free() world.thieves.enabled = false player = world.player player.set_physics_process(false) player.survival.set_process(false) world.day_night.frozen = true world.weather.set_process(false) inv = player.inventory func _reload() -> void: check(world.save_now(), "saved") world.queue_free() await process_frame await _load() func _clear() -> void: inv.load_data(Inventory.new(40).to_data()) func _slot_of(id: String) -> int: for i in range(inv.size()): var s = inv.get_slot(i) if s != null and s.id == id: return i return -1 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)) ## Moves the world on `secs` (like sleeping): food rots in the meantime. func _pass(secs: float) -> void: world.skip_time(secs) func _body() -> void: _data() _stamps() await _rotting() await _in_chests_and_on_the_ground() await _keeping() await _poisoning() await _settings() await _saving() _finished = true # --- what keeps how long -------------------------------------------------------------- func _data() -> void: print("how long food keeps") var want := {"raw_meat": 2, "perch": 2, "trout": 2, "berries": 3, "mushroom": 4, "cooked_meat": 4, "grilled_trout": 4, "roasted_mushroom": 6, "smoked_meat": 12, "smoked_fish": 12, "salted_meat": 20, "salted_fish": 20} var wrong := [] for id in want: if absf(Inventory.keeps_days(id) - float(want[id])) > 0.01: wrong.append("%s=%s" % [id, Inventory.keeps_days(id)]) check(wrong.is_empty(), "raw meat and fish 2 days, berries 3, cooked 4, smoked 12, salted 20 (wrong: %s)" % [wrong]) check(Inventory.keeps_days("wood") == 0.0 and Inventory.keeps_days("rotten_food") == 0.0, "wood (and rotten food) never spoil") check(DB.get_item("rotten_food").get("food") == null, "rotten food isn't food") for m in ["rotten_food", "salt", "smoking_rack", "smoked_meat", "smoked_fish", "salted_meat", "salted_fish"]: check(DB.mesh_for(m).get_aabb().size.length() > 0.05 and DB.mesh_for(m).get_aabb().size.length() != DB.mesh_for("nonexistent_x").get_aabb().size.length(), "%s has its own mesh" % m) func _stamps() -> void: print("freshness") _clear() world._tick_clock() var t0: float = Inventory.clock inv.add("raw_meat", 4) var s = inv.get_slot(_slot_of("raw_meat")) check(s.has("rot") and absf(float(s.rot) - (t0 + 2.0 * DAY)) < 1.5, "raw meat put in your bag rots in 2 days (stamp %.0f, now %.0f)" % [float(s.get("rot", 0)), t0]) check(Inventory.freshness(s) > 0.99 and absf(Inventory.hours_left(s) - 48.0) < 0.1, "fresh: 48 hours left") check(not inv.get_slot(_slot_of("raw_meat")).has("dur"), "(and no wear)") inv.add("wood", 5) check(not inv.get_slot(_slot_of("wood")).has("rot"), "wood gets no stamp") # A day later, 4 more: the stack is halfway between. Inventory.clock = t0 + DAY inv.add("raw_meat", 4, -1.0) s = inv.get_slot(_slot_of("raw_meat")) check(int(s.count) == 8 and absf(float(s.rot) - (t0 + 2.5 * DAY)) < 1.5, "4 fresh added a day later: 8 that rot in 1.5 days (the average)") var Slot: Script = load("res://scripts/ui/slot.gd") check(Slot.freshness_text(s).begins_with("Going off") or Slot.freshness_text(s).begins_with("Fresh"), "the tooltip says so: \"%s\"" % Slot.freshness_text(s)) print("splitting and moving") var cursor = inv.click(_slot_of("raw_meat"), null, true) # (right-click: pick up half) check(cursor != null and int(cursor.count) == 4 and float(cursor.rot) == float(s.rot), "half a stack keeps its freshness") var empty := inv.slots.find(null) cursor = inv.click(empty, cursor, true) # (right-click: put one down) check(inv.get_slot(empty).has("rot") and float(inv.get_slot(empty).rot) == float(s.rot), "so does one put down on its own") var fresh := {"id": "raw_meat", "count": 3, "rot": Inventory.clock + 2.0 * DAY} var other := inv.slots.find(null) inv.set_slot(other, fresh) cursor = inv.click(other, cursor, false) # (merge the 3 left on the cursor into the fresh 3) check(cursor == null and int(inv.get_slot(other).count) == 6 and absf(float(inv.get_slot(other).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5, "dropping older food onto fresher food averages them") var box := Inventory.new(8) inv.quick_move(other, box) check(box.get_slot(0) != null and absf(float(box.get_slot(0).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5, "shift-click into a chest: same freshness") Inventory.clock = t0 _clear() func _rotting() -> void: print("rotting") _clear() world._tick_clock() inv.add("raw_meat", 3) inv.add("cooked_meat", 2) inv.add("berries", 5) inv.add("wood", 4) _pass(DAY * 1.9) check(inv.count("raw_meat") == 3, "a little under 2 days: the raw meat is still good") check(Inventory.freshness(inv.get_slot(_slot_of("raw_meat"))) < 0.1, "but nearly rotten") _pass(DAY * 0.2) check(inv.count("raw_meat") == 0 and inv.count("rotten_food") == 3, "past 2 days: 3 rotten food") check(inv.count("cooked_meat") == 2 and inv.count("berries") == 5 and inv.count("wood") == 4, "cooked meat and berries keep longer (and wood forever)") _pass(DAY * 1.1) check(inv.count("berries") == 0 and inv.count("cooked_meat") == 2, "berries go after 3 days") _pass(DAY * 1.0) check(inv.count("cooked_meat") == 0 and inv.count("rotten_food") == 10, "cooked meat after 4 (10 rotten food now)") _clear() func _in_chests_and_on_the_ground() -> void: print("in a chest") world._tick_clock() var chest := _place("chest", 3.0) chest.inventory.add("trout", 2) _pass(DAY * 2.1) check(chest.inventory.count("trout") == 0 and chest.inventory.count("rotten_food") == 2, "food rots in a chest too") world.remove_piece(chest) print("on the ground") _clear() world._tick_clock() inv.add("cooked_meat", 2) var st: Dictionary = inv.get_slot(_slot_of("cooked_meat")) var stamp := float(st.rot) _pass(DAY * 1.0) var at: Vector3 = player.global_position - player.global_basis.z * 1.2 + Vector3(0, 0.2, 0) var dropped: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st)) _clear() _pass(DAY * 1.0) check(not dropped.taken and float(dropped.extra.rot) == stamp, "dropped, it keeps its stamp (a day in the bag, a day on the ground)") check(A.pickup(player, dropped), "picked up again") check(float(inv.get_slot(_slot_of("cooked_meat")).rot) == stamp, "and it's just as old as it was") var st2: Dictionary = inv.get_slot(_slot_of("cooked_meat")) var d2: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st2)) _clear() _pass(DAY * 2.1) check(d2.taken or not is_instance_valid(d2), "left on the ground past its time, it rots away") func _keeping() -> void: print("the smoking rack") _clear() world._tick_clock() inv.add("stick", 6) inv.add("wood", 6) inv.add("fiber", 4) check(A.craft(player, "smoking_rack"), "made by hand (6 sticks, 4 wood, 4 fibre)") var rack := _place("smoking_rack", -3.0) check(rack != null and rack.get("inventory") != null, "placed, it opens like a chest") rack.inventory.add("raw_meat", 4) check(not rack.working() and rack.interact_prompt(player).contains("wood"), "no wood, no smoke: \"%s\"" % rack.interact_prompt(player)) rack.inventory.add("wood", 2) check(rack.working(), "with wood it gets to work") rack.step(59.0) check(rack.inventory.count("smoked_meat") == 0, "not yet at 59 s") rack.step(62.0) check(rack.inventory.count("smoked_meat") == 4 and rack.inventory.count("raw_meat") == 0 and rack.inventory.count("wood") == 0, "2 meat and a wood a minute: 4 smoked meat") rack.inventory.add("perch", 2) rack.inventory.add("trout", 1) rack.inventory.add("wood", 2) rack.step(121.0) check(rack.inventory.count("smoked_fish") == 4, "and fish (2 perch, or 1 trout, make 2 smoked fish)") _pass(DAY * 5.0) check(rack.inventory.count("smoked_meat") == 4 and Inventory.freshness(rack.inventory.get_slot(0)) > 0.5, "smoked meat keeps on the rack: still good after 5 days") _pass(DAY * 7.5) check(rack.inventory.count("smoked_meat") == 0, "(but not forever: 12 days)") world.remove_piece(rack) print("salt") var salt: Array = world.props.pickups.values().filter(func(p): return p.item_id == "salt") check(salt.size() >= 20, "%d salt crusts on the island" % salt.size()) var by_sea := salt.filter(func(p): return world.terrain.sea_weight(p.global_position.x, p.global_position.z) > 0.5).size() check(by_sea == salt.size(), "all of them up above the sea cliffs") check(world.props.pickups.has("p0") and world.props.pickups["p0"].item_id == "stick", "(older pickups keep their ids)") _clear() world._tick_clock() inv.add("raw_meat", 2) inv.add("salt", 1) inv.add("perch", 2) inv.add("trout", 1) inv.add("salt", 2) check(A.craft(player, "salted_meat") and inv.count("salted_meat") == 2, "2 raw meat and a salt: 2 salted meat") check(A.craft(player, "salted_fish") and A.craft(player, "salted_trout") and inv.count("salted_fish") == 4, "and salted fish (perch or trout)") _pass(DAY * 15.0) check(inv.count("salted_meat") == 2 and inv.count("salted_fish") == 4, "15 days on, still good") _clear() func _poisoning() -> void: print("food poisoning") var sv: Node = player.survival A.food_rng.seed = 12345 _clear() world._tick_clock() inv.add("cooked_meat", 20) var bad := 0 for _i in range(20): sv.hunger = 10.0 sv.sick = 0.0 A.eat(player, _slot_of("cooked_meat")) if sv.sick > 0.0: bad += 1 check(bad == 0, "20 cooked meals: never ill") inv.add("raw_meat", 20) var tries := 0 sv.sick = 0.0 while sv.sick <= 0.0 and tries < 20: sv.hunger = 10.0 A.eat(player, _slot_of("raw_meat")) tries += 1 check(sv.sick > 0.0, "raw meat: ill after %d" % tries) sv._update_statuses() check("Food poisoning" in sv.statuses, "the HUD says Food poisoning (%s)" % [sv.statuses]) var n := inv.count("raw_meat") _toasts.clear() sv.hunger = 10.0 check(not A.eat(player, _slot_of("raw_meat")) and inv.count("raw_meat") == n, "you can't eat while you're ill") check(_toasts.any(func(t): return t.contains("too sick")), "\"%s\"" % [_toasts]) sv.stamina = 0.0 sv._stamina_wait = 0.0 sv.step(1.0) var ill_gain: float = sv.stamina sv.sick = 0.0 sv.stamina = 0.0 sv._stamina_wait = 0.0 sv.step(1.0) check(ill_gain > 0.0 and absf(ill_gain * 2.0 - sv.stamina) < 0.05, "and stamina comes back at half speed (%.2f vs %.2f a second)" % [ill_gain, sv.stamina]) sv.poison() for _i in range(61): sv.step(1.0) check(sv.sick == 0.0 and A.eat(player, _slot_of("raw_meat")) or sv.hunger >= A.FULL_HUNGER, "a minute later you can eat again") var risky := 0 var tried := 0 A.food_rng.seed = 1 for _i in range(200): if A.food_rng.randf() < 0.35: risky += 1 tried += 1 check(risky > 50 and risky < 90, "(about 1 raw meat in 3 makes you ill: %d of %d)" % [risky, tried]) for id in ["perch", "trout"]: check(float(DB.get_item(id).food.get("risk", 0.0)) > 0.0, "raw %s is risky too" % id) for id in ["smoked_meat", "salted_meat", "smoked_fish", "salted_fish", "berries"]: check(float(DB.get_item(id).food.get("risk", 0.0)) == 0.0, "%s is safe" % id) sv.sick = 0.0 _clear() func _settings() -> void: print("rot settings") check(Inventory.rot_speed == 1.0, "a new world rots normally") var meta: Dictionary = S.read_meta(S.current_id) meta["rot"] = "slow" S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) world._apply_world_rot() check(Inventory.rot_speed == 0.5, "slow: half speed") _clear() world._tick_clock() inv.add("raw_meat", 2) check(absf(Inventory.hours_left(inv.get_slot(0)) - 96.0) < 0.1, "raw meat now lasts 4 days") _pass(DAY * 3.0) check(inv.count("raw_meat") == 2, "and is still good after 3") meta["rot"] = "off" S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) world._apply_world_rot() _pass(DAY * 10.0) check(inv.count("raw_meat") == 2 and Inventory.rot_speed == 0.0, "off: nothing rots, ever") inv.add("berries", 3) check(not inv.get_slot(_slot_of("berries")).has("rot"), "(and new food isn't even stamped)") meta["rot"] = "normal" S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) world._apply_world_rot() var id: String = S.create_world("Slow Island", 99, {"rot": "slow"}) check(String(S.read_meta(id).get("rot", "")) == "slow", "a new world remembers its choice") S.delete_world(id) var cfg: RefCounted = load("res://scripts/net/server_config.gd").new() cfg.data = cfg.DEFAULTS.duplicate(true) check(String(cfg.data.rot) == "normal", "a server rots normally unless told") cfg.path = "user://test_worlds/_cfg.json" check(cfg.set_rule("rot", "slow") == "" and String(cfg.v("rot")) == "slow", "an admin can /set rot slow") check(cfg.set_rule("rot", "fast") != "", "(but not to nonsense)") check("rot" in cfg.PUBLIC, "and players are told the server's setting") DirAccess.remove_absolute("user://test_worlds/_cfg.json") _clear() func _saving() -> void: print("saving") world._tick_clock() _clear() inv.add("raw_meat", 3) var stamp := float(inv.get_slot(0).rot) var chest := _place("chest", 3.0) chest.inventory.add("berries", 4) var cstamp := float(chest.inventory.get_slot(0).rot) var at: Vector3 = chest.global_position await _reload() check(inv.count("raw_meat") == 3 and float(inv.get_slot(_slot_of("raw_meat")).rot) == stamp, "your food is as fresh after a reload as before") var back: Node3D = null for p in world.all_pieces(): if p.item_id == "chest" and p.global_position.distance_to(at) < 0.1: back = p check(back != null and float(back.inventory.get_slot(0).rot) == cstamp, "and the chest's") _pass(DAY * 2.1) check(inv.count("rotten_food") == 3, "and rots on the same schedule")