P4 hazards: food spoilage with rot speed setting, smoking rack and salt, food poisoning, harsher blizzards, hard winters with lake ice, lightning on trees, snow on roofs (protocol 11)

This commit is contained in:
Paul Bailey
2026-11-05 11:49:13 +00:00
parent 657c3140fe
commit 3389ec9a21
27 changed files with 1541 additions and 56 deletions

365
tests/test_food.gd Normal file
View File

@@ -0,0 +1,365 @@
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")