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:
@@ -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,
|
||||
|
||||
@@ -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
365
tests/test_food.gd
Normal 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
289
tests/test_hazards.gd
Normal 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("")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user