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

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1
fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever,

View File

@@ -265,6 +265,7 @@ func _body(world: Node3D) -> void:
A.eat(player, 0)
check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health)
sv.health = 60.0
sv.sick = 0.0 # (raw meat may have made you ill: tests/test_food.gd covers that)
_hold(player, "cooked_meat")
A.eat(player, 0)
check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed")

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

289
tests/test_hazards.gd Normal file
View File

@@ -0,0 +1,289 @@
extends SceneTree
## P4 weather hazards (docs/PROGRESSION_DESIGN.md §5): hard winters every 12
## days (warnings, bitter cold, fewer animals, the lake's edge freezing),
## blizzards that freeze you outdoors but not in a hearth room, and
## lightning that sets trees (never buildings) on fire.
## Run: godot --headless --path . --script res://tests/test_hazards.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 W: Script
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")
W = load("res://scripts/world/weather.gd")
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("Hazards", 1337)
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
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)
world.hazards.set_physics_process(false)
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 _set_time(day: int, hours: float) -> void:
world.day_night.day = day
world.day_night.time = hours / 24.0
world.day_night.apply(true)
func _toast_has(s: String) -> bool:
for t in _toasts:
if t.contains(s):
return true
return false
func _body() -> void:
_calendar()
await _blizzard()
_animals()
await _ice()
await _lightning()
_finished = true
# --- hard winters --------------------------------------------------------------------------
func _calendar() -> void:
print("hard winters")
var hw := []
for d in range(1, 40):
if W.hard_winter(d):
hw.append(d)
check(hw == [12, 13, 14, 24, 25, 26, 36, 37, 38], "every 12 days, three in a row: %s" % [hw])
check(W.next_hard_winter(3) == 12 and W.next_hard_winter(15) == 24 and W.next_hard_winter(25) == 25, "(the next one from any day)")
var wx: Node = world.weather
check(wx.is_cold_day(12) and wx.is_cold_day(13) and wx.is_cold_day(14), "they're all cold days")
_set_time(13, 12.0)
wx.force("clear")
var bitter: float = wx.warmth
var cold := -1
for d in range(2, 12):
if wx.is_cold_day(d):
cold = d
check(cold > 0, "(an ordinary cold day for comparison: day %d)" % cold)
_set_time(cold, 12.0)
wx.force("clear")
check(bitter < wx.warmth - 5.0, "colder than an ordinary cold day (%.0f against %.0f)" % [bitter, wx.warmth])
_set_time(13, 12.0)
wx.force("clear")
check(wx.label().contains("hard winter"), "the HUD says so: \"%s\"" % wx.label())
var wet := 0
var wet_hw := 0
for i in range(600):
var t := (11.0 + i / 600.0 * 3.0) * 1200.0
var t2 := (2.0 + i / 600.0 * 3.0) * 1200.0
if wx.state_at(t) in ["snow", "blizzard"]:
wet_hw += 1
if wx.state_at(t2) in ["rain", "storm", "snow", "blizzard"]:
wet += 1
check(wet_hw > wet, "more snow in a hard winter (%d%% of the time, against %d%% wet weather on days 3-5)" % [wet_hw / 6, wet / 6])
print("warnings")
_toasts.clear()
world.hazards._on_new_day(11)
check(_toast_has("hard winter is coming"), "the day before: %s" % [_toasts])
_toasts.clear()
world.hazards._on_new_day(12)
check(_toast_has("has set in"), "on the day: %s" % [_toasts])
_toasts.clear()
world.hazards._on_new_day(15)
check(_toast_has("is over"), "and when it's over")
_toasts.clear()
world.hazards._on_new_day(8)
check(_toasts.is_empty(), "(an ordinary day says nothing)")
# --- blizzard ----------------------------------------------------------------------------
func _blizzard() -> void:
print("a blizzard")
var bs: Node = world.build
var sv: Node = player.survival
_set_time(13, 15.0)
var wx: Node = world.weather
wx.force("blizzard")
check(wx.fog >= 6.0 and wx.light < 0.5, "you can barely see (fog x%.1f)" % wx.fog)
var site: Vector3 = bs.debug_find_site(player.global_position - player.global_basis.z * 16.0, 3.0)
var yaw: float = player.rotation.y
var hut: Dictionary = await bs.debug_build_hut(site, yaw, "stone")
for _i in range(3):
await physics_frame
check(hut.placed == 20, "a stone hut (%d pieces)" % hut.placed)
var b := Basis(Vector3.UP, yaw)
var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1)
var hearth: Node3D = world.spawn_piece(DB.get_item("stone_hearth"), Transform3D(b, center + b * Vector3(0, 0, -1.25)))
hearth.fuel = hearth.MAX_FUEL
var inside: Vector3 = center + b * Vector3(0.6, 0.05, 0.8)
var outside: Vector3 = site + b * Vector3(9, 0, 9)
outside.y = world.terrain.sample_height(outside.x, outside.z)
player.inventory.load_data(Inventory.new(40).to_data())
# Outside: no fire, no coat.
player.global_position = outside
sv.warmth = 70.0
sv.wet = 0.0
sv._shelter_left = 0.0
for _i in range(15):
sv.step(1.0)
var lost: float = 70.0 - sv.warmth
check(lost > 45.0, "outside, a blizzard strips your warmth: %.0f lost in 15 s" % lost)
for _i in range(30):
sv.step(1.0)
check(sv.warmth < 12.0 and "Freezing" in sv.statuses, "soon you're Freezing (%.0f)" % sv.warmth)
player.worn = "hide_cloak"
for _i in range(40):
sv.step(1.0)
check(sv.warmth >= 12.0 and not "Freezing" in sv.statuses, "a hide cloak keeps you out of Freezing (%.0f)" % sv.warmth)
player.worn = ""
var air_out: float = world.air_warmth(outside, false)
wx.force("snow")
var air_snow: float = world.air_warmth(outside, false)
wx.force("blizzard")
check(air_out < air_snow - 10.0, "the wind makes it far colder than plain snow (%.0f against %.0f)" % [air_out, air_snow])
# Inside the stone hut by the hearth.
player.global_position = inside
sv.warmth = 20.0
sv._shelter_left = 0.0
for _i in range(30):
sv.step(1.0)
check(sv.sheltered and "Warm" in sv.statuses and sv.warmth > 60.0,
"in the hearth room it's Sheltered and Warm: %.0f (%s)" % [sv.warmth, sv.statuses])
hearth._set_lit(false)
sv._shelter_left = 0.0
sv.step(0.5)
check(world.air_warmth(inside, true) > air_out + 30.0, "even with the fire out, walls keep the wind off (%.0f inside, %.0f out)" % [world.air_warmth(inside, true), air_out])
sv.health = 100.0
wx.force("")
for p in hut.pieces:
if is_instance_valid(p):
p.queue_free()
hearth.queue_free()
for _i in range(3):
await physics_frame
func _animals() -> void:
print("animals")
var sp: Node = world.creatures
_set_time(5, 12.0)
var normal: int = sp.winter_budget("deer", 1)
_set_time(13, 12.0)
var winter: int = sp.winter_budget("deer", 1)
check(winter * 2 == normal, "half as many deer in a hard winter (%d, not %d)" % [winter, normal])
check(sp.winter_budget("boar", 1) >= 1, "(but never none)")
func _ice() -> void:
print("the lake freezes")
var hz: Node = world.hazards
var t: Node3D = world.terrain
var c: Vector2 = t.lake_center
_set_time(5, 12.0)
hz.step(0.1)
check(not hz.lake_frozen(), "not on an ordinary day")
# Somewhere in the lake near its edge, and its middle.
var edge := Vector3.INF
for i in range(72):
var a := TAU * i / 72.0
for k in [0.95, 0.9, 0.85, 0.8, 0.75]:
var x: float = c.x + cos(a) * t.lake_radius * k
var z: float = c.y + sin(a) * t.lake_radius * k
if t.sample_height(x, z) < t.water_level - 0.3:
edge = Vector3(x, t.water_level, z)
break
if edge != Vector3.INF:
break
var mid := Vector3(c.x, t.water_level, c.y)
check(edge != Vector3.INF and not world.water_at(edge.x, edge.z).is_empty(), "(open water by the edge, %.0f m out)" % Vector2(edge.x - c.x, edge.z - c.y).length())
_set_time(13, 12.0)
hz.step(0.1)
await physics_frame
await physics_frame
check(hz.lake_frozen(), "in a hard winter it's frozen over at the edge")
check(world.water_at(edge.x, edge.z).is_empty(), "no fishing through the ice there")
check(not world.water_at(mid.x, mid.z).is_empty() or t.sample_height(mid.x, mid.z) >= t.water_level, "but the middle is still open water")
var q := PhysicsRayQueryParameters3D.create(edge + Vector3(0, 3, 0), edge + Vector3(0, -3, 0), 1)
var hit := world.get_world_3d().direct_space_state.intersect_ray(q)
check(not hit.is_empty() and absf(hit.position.y - t.water_level) < 0.1, "and you can walk on the ice")
_set_time(16, 12.0)
hz.step(0.1)
await physics_frame
await physics_frame
hit = world.get_world_3d().direct_space_state.intersect_ray(q)
check(not hz.lake_frozen() and (hit.is_empty() or hit.position.y < t.water_level - 0.2), "after it, the ice is gone")
# --- lightning -------------------------------------------------------------------------------
func _lightning() -> void:
print("lightning")
var hz: Node = world.hazards
var wx: Node = world.weather
_set_time(6, 14.0)
# Stand among trees near the rim.
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 world.props._near_rim(a.global_position.x, a.global_position.z) > world.props._near_rim(b.global_position.x, b.global_position.z))
var at: Vector3 = trees[0].global_position + Vector3(4, 0, 0)
at.y = world.terrain.sample_height(at.x, at.z)
player.global_position = at
wx.force("clear")
hz._strike_left = 0.0
hz.step(0.1)
check(hz.strikes == 0, "no lightning on a clear day")
wx.force("storm")
_toasts.clear()
var before: int = world.all_dropped().filter(func(d): return d.item_id == "charcoal").size()
hz._strike_left = 0.0
hz.step(0.1)
check(hz.strikes == 1 and hz._burning.size() == 1, "in a storm, a bolt hits a tree")
var tree: Node3D = hz._burning.keys()[0]
check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at))
check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts])
check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it")
check(not tree.depleted, "still standing while it burns")
for _i in range(41):
hz.step(1.0)
await physics_frame
check(tree.depleted and not hz.burning(tree), "then it falls (%s)" % ("felled" if tree.depleted else "standing"))
var coal: Array = world.all_dropped().filter(func(d): return d.item_id == "charcoal")
check(coal.size() == before + 1 and int(coal.back().amount) == 2 and coal.back().global_position.distance_to(tree.global_position) < 2.0, "leaving 2 charcoal at its foot")
print("a long storm")
var n0: int = hz.strikes
var pieces0: int = world.all_pieces().size()
var wall: Node3D = world.spawn_piece(DB.get_item("wood_wall"), Transform3D(Basis(), at + Vector3(0, 0, 3)))
for _i in range(600):
hz.step(1.0)
var n: int = hz.strikes - n0
check(n >= 4 and n <= 13, "about one strike a minute or so (%d in 10 minutes)" % n)
check(is_instance_valid(wall) and not wall.is_queued_for_deletion() and world.all_pieces().size() == pieces0 + 1, "it never strikes a building")
wx.force("")

View File

@@ -59,20 +59,27 @@ func _weather_model() -> void:
var cold_days := 0
var snow_on_warm_day := false
var days := 60
var samples := 0
var t := 0.0
while t < days * 1200.0:
var s: String = wa.state_at(t)
if s != wb.state_at(t):
same = false
counts[s] = counts.get(s, 0) + 1
var day := int(t / 1200.0) + 1
if not wa.hard_winter(day): # (the mix over ordinary days; hard winters lean snowy)
counts[s] = counts.get(s, 0) + 1
samples += 1
if s in ["snow", "blizzard"] and not wa.is_cold_day(day):
snow_on_warm_day = true
t += 20.0
var ordinary := 0
for d in range(1, days + 1):
if wa.hard_winter(d):
continue # (P4's hard winters are always cold: tests/test_hazards.gd)
ordinary += 1
if wa.is_cold_day(d):
cold_days += 1
var total := float(days * 60)
var total := float(samples)
var frac := func(names: Array) -> float:
var n := 0
for k in names:
@@ -84,7 +91,7 @@ func _weather_model() -> void:
check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100))
var hard: float = frac.call(["storm", "blizzard"])
check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100))
check(cold_days >= 8 and cold_days <= 24, "about a quarter of days are cold (%d of %d)" % [cold_days, days])
check(cold_days >= ordinary * 0.12 and cold_days <= ordinary * 0.4, "about a quarter of ordinary days are cold (%d of %d)" % [cold_days, ordinary])
check(not snow_on_warm_day, "it only snows in the valley on cold days")
check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather")
var other: Node = load("res://scripts/world/weather.gd").new()