Files
Cinderwild/tests/test_food.gd

366 lines
15 KiB
GDScript

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")