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

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