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