M1b: survival stats, day/night, fire fuel, cooking, foraging, torches, death + grave

- Health, stamina, hunger, warmth and wet with HUD bars, status tags and clock
- 20-minute day/night cycle with moon, stars and lit clouds
- Campfire fuel (2 min start, 15 min max), heat radius, cooking needs a lit fire
- Berry bushes (regrow 10 min), mushrooms, roasted mushroom, torch
- Stamina costs for sprint, jump and harvest hits
- Death respawns at island start and leaves a grave bag
- Saves include stats, time of day, fire fuel, regrowth timers and graves
- New test_survival_stats suite; tests fail if a script error aborts them
This commit is contained in:
Paul Bailey
2026-11-04 01:19:41 +00:00
parent 2b1afc86ba
commit fc7eb3cfed
28 changed files with 1326 additions and 97 deletions

View File

@@ -8,6 +8,7 @@ extends SceneTree
## Run: godot --headless --path . --script res://tests/test_save_load.gd
var _fails := 0
var _finished := false
var A: Node # Actions
var S: Node # Saves
var DB: Node # ItemDB
@@ -114,9 +115,11 @@ func _run() -> void:
check(chest2 != null and chest2.inventory.count("flint") == 7 and chest2.inventory.count("wood") == 12, "chest rebuilt with its contents")
check(S.read_meta(id).play_time >= 0.0, "meta updated")
_finished = true
world.queue_free()
S.delete_world(id)
DirAccess.remove_absolute(S.root_dir)
check(_finished, "test ran to the end (no script errors)")
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)

View File

@@ -6,6 +6,7 @@ extends SceneTree
## Run: godot --headless --path . --script res://tests/test_survival_core.gd
var _fails := 0
var _finished := false ## set at the very end; a script error aborts before it
var _actions: Node
var _db: Node
@@ -28,6 +29,7 @@ func _run() -> void:
for _i in range(5):
await physics_frame
await _test_world(world)
check(_finished, "test ran to the end (no script errors)")
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
@@ -134,6 +136,7 @@ func _test_world(world: Node3D) -> void:
var moving: int = inv.get_slot(wood_slot).count
_actions.transfer(player, inv, wood_slot, chest.inventory, chest)
check(chest.inventory.count("wood") == moving, "shift-click stored %d wood in the chest" % moving)
_finished = true
func ItemDB_get(_p, id: String) -> Dictionary:
return _db.get_item(id)

View File

@@ -0,0 +1,189 @@
extends SceneTree
## M1b: day/night, hunger, warmth, stamina, campfire fuel + heat + cooking,
## berry bushes, eating, death + grave, and saving all of it.
##
## Fast-forwards time by calling survival.step() / day_night.advance() / the
## fire's _process() directly, so the whole test runs in seconds.
## Run: godot --headless --path . --script res://tests/test_survival_stats.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
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 _spawn_world() -> Node3D:
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(5):
await physics_frame
return w
## Advances the simulation `seconds` in 1 s steps (stats + clock + fires).
func _ff(world: Node3D, seconds: float) -> void:
var player: Node = world.get_node("Player")
for _i in range(int(seconds)):
world.day_night.advance(1.0)
for f in world.get_tree().get_nodes_in_group("heat"):
f._process(1.0)
player.survival.step(1.0)
func _run() -> void:
A = root.get_node("Actions")
S = root.get_node("Saves")
DB = root.get_node("ItemDB")
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
var id: String = S.create_world("Stats", 1337)
S.current_id = id
var world: Node3D = await _spawn_world()
await _body(world, id)
check(_finished, "test ran to the end (no script errors)")
S.delete_world(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 _body(world: Node3D, id: String) -> void:
var player: CharacterBody3D = world.get_node("Player")
var sv: Node = player.survival
var dn: Node = world.day_night
var inv: Inventory = player.inventory
var terrain: Node3D = world.get_node("Terrain")
player.set_physics_process(false) # keep the player put while we fast-forward
sv.set_process(false)
print("day / night")
check(dn.day == 1 and absf(dn.hours() - 8.0) < 0.1, "new world starts on day 1 at 08:00 (%s)" % dn.clock_text())
dn.time = 0.5; dn.apply(true)
check(dn.daylight > 0.9, "noon is bright (daylight %.2f)" % dn.daylight)
dn.time = 0.0; dn.apply(true)
check(dn.daylight < 0.05, "midnight is dark (daylight %.2f)" % dn.daylight)
dn.time = 0.99
dn.advance(dn.day_length * 0.02)
check(dn.day == 2, "clock rolls over to day 2")
print("hunger and eating")
dn.time = 0.5; dn.apply(true)
var h0: float = sv.hunger
_ff(world, 300)
check(sv.hunger < h0 - 15.0, "hunger drains over 5 min (%.0f -> %.0f)" % [h0, sv.hunger])
inv.add("berries", 3)
_hold(player, "berries")
var h1: float = sv.hunger
check(A.eat(player, 0) and sv.hunger > h1 and inv.count("berries") == 2, "eating berries restores food")
print("cold and fire")
dn.time = 21.0 / 24.0; dn.apply(true) # night (fast-forward below stays before dawn)
sv.warmth = 60.0
_ff(world, 240)
check(sv.warmth < 30.0 and "Cold" in sv.statuses, "a night in the open gets cold (warmth %.0f, %s)" % [sv.warmth, sv.statuses])
var spot := player.global_position + Vector3(2.0, 0, 0)
spot.y = terrain.sample_height(spot.x, spot.z)
var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), spot))
_ff(world, 30)
check(sv.warmth > 55.0 and "Warm" in sv.statuses, "sitting by the fire warms you (warmth %.0f)" % sv.warmth)
print("fuel and cooking")
inv.add("mushroom", 2)
check(A.can_craft(player, "roasted_mushroom"), "can roast a mushroom at a lit fire")
fire._process(fire.fuel + 1.0)
check(not fire.is_lit() and fire.heat_at(player.global_position) == 0.0, "the fire burns out when its fuel runs out")
check(not A.can_craft(player, "roasted_mushroom"), "an unlit fire can't cook")
inv.add("wood", 2)
check(A.add_fuel(player, fire) and fire.is_lit() and inv.count("wood") == 1, "adding wood relights it")
check(A.craft(player, "roasted_mushroom") and inv.count("roasted_mushroom") == 1, "roasted a mushroom")
print("stamina")
sv.stamina = 100.0
var swings := 0
while sv.use_stamina(6.0) and swings < 100:
swings += 1
check(swings >= 14 and swings < 30, "about 16 swings empty your stamina (%d)" % swings)
_ff(world, 6)
check(sv.stamina > 90.0, "stamina regenerates after a pause")
print("berry bushes")
var bush: Node3D = world.get_node("Props").forage.values()[0]
player.global_position = bush.global_position + Vector3(1.0, 0.3, 0)
var b0 := inv.count("berries")
check(A.forage(player, bush) and inv.count("berries") > b0 and inv.count("fiber") >= 1, "foraged berries and fiber")
check(not A.forage(player, bush), "a picked bush is bare")
bush._process(bush.regrow_seconds + 1.0)
check(bush.ripe(), "it regrows")
inv.add("stick", 1)
inv.add("fiber", 2)
check(A.craft(player, "torch") and inv.count("torch") == 1, "torch crafted from stick + fiber")
_hold(player, "torch")
check(player.hands.held_heat() > 0.0, "a held torch gives a little warmth")
print("save + load")
sv.hunger = 42.0
sv.warmth = 33.0
fire.fuel = 321.0
A.forage(player, bush)
var regrow_left: float = bush.regrow_left
dn.day = 3; dn.time = 0.75
world.save_now()
world.queue_free()
await process_frame
await process_frame
world = await _spawn_world()
player = world.get_node("Player")
sv = player.survival
check(absf(sv.hunger - 42.0) < 0.5 and absf(sv.warmth - 33.0) < 0.5, "stats restored (food %.0f, warmth %.0f)" % [sv.hunger, sv.warmth])
check(world.day_night.day == 3 and absf(world.day_night.hours() - 18.0) < 0.2, "time of day restored (%s)" % world.day_night.clock_text())
var fire2: Node = null
for p in world.get_node("Pieces").get_children():
if p.item_id == "campfire":
fire2 = p
check(fire2 != null and absf(fire2.fuel - 321.0) < 2.0 and fire2.is_lit(), "campfire fuel restored")
var bush2: Node = world.get_node("Props").forage[bush.uid] if is_instance_valid(bush) else world.get_node("Props").forage.values()[0]
check(absf(bush2.regrow_left - regrow_left) < 2.0, "bush regrow timer restored")
print("death and grave")
player.set_physics_process(false)
sv.set_process(false)
player.inventory.add("wood", 9)
player.inventory.add("stone_axe", 1)
var death_pos := player.global_position
sv.hunger = 0.0
sv.health = 0.5
sv.step(2.0)
check(sv.health == 50.0 and not sv.dead, "you respawn with half health")
check(player.inventory.count("wood") == 0, "your inventory went into the grave")
var grave: Node = null
for p in world.get_node("Pieces").get_children():
if p.item_id == "grave":
grave = p
check(grave != null and grave.inventory.count("wood") >= 9 and grave.inventory.count("stone_axe") == 1, "the grave holds your things")
check(grave != null and grave.global_position.distance_to(death_pos) < 15.0, "grave is where you fell")
player.global_position = grave.global_position + Vector3(1, 0.5, 0)
for i in range(grave.inventory.size()):
if grave.inventory.get_slot(i) != null:
A.transfer(player, grave.inventory, i, player.inventory, grave)
await process_frame
check(player.inventory.count("stone_axe") == 1, "recovered your belongings")
check(not is_instance_valid(grave) or grave.is_queued_for_deletion(), "the empty grave crumbles")
world.queue_free()
_finished = true
func _hold(player: Node, id: String) -> void:
var inv: Inventory = player.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and s.id == id:
var tmp = inv.get_slot(0)
inv.set_slot(0, s)
inv.set_slot(i, tmp)
break
player.select(0)