P3 bear: one bear per island raids unattended food, palisades and closed huts keep it out, warns then charges, hide/fat/meat, fur rug (Cozy), death saved (protocol 8)

This commit is contained in:
Paul Bailey
2026-11-05 07:51:07 +00:00
parent fc58c8d0e1
commit 96aac29606
19 changed files with 1002 additions and 12 deletions

386
tests/test_bear.gd Normal file
View File

@@ -0,0 +1,386 @@
extends SceneTree
## P3 the bear (docs/PROGRESSION_DESIGN.md §4): its home in the forest by the
## rim, raiding unattended food (chests and food left out) but not food
## behind a palisade or in a closed hut, warning you off and charging, its
## drops (hide, fat, meat), the fur rug and coming back after it's killed.
## Run: godot --headless --path . --script res://tests/test_bear.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 bears: Node
var bear: Node3D
var bs: Node
var real_home: Vector3
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("Bear", 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)
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
player = world.player
player.set_physics_process(false)
player.survival.set_process(false)
world.day_night.frozen = true
world.weather.set_process(false)
world.weather.force("clear")
bs = world.build
bears = world.bears
bear = bears.bear
func _reload() -> void:
check(world.save_now(), "saved")
world.queue_free()
await process_frame
await _load()
func _settle() -> void:
for _i in range(3):
await physics_frame
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)
## Runs the bear for up to `secs` (stopping early once `until` is true).
func _sim(secs: float, until: Callable = Callable()) -> float:
var t := 0.0
while t < secs:
bears.step()
if is_instance_valid(bear):
bear.set_physics_process(false)
bear.step(0.1)
t += 0.1
if until.is_valid() and until.call():
break
if int(t * 10) % 10 == 0:
await physics_frame
return t
func _toast_has(s: String) -> bool:
for t in _toasts:
if t.contains(s):
return true
return false
func _ground(p: Vector3) -> Vector3:
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
func _body() -> void:
_home()
await _day_and_night()
await _raids()
await _fenced()
await _crowded()
await _drops_and_rug()
await _comes_back()
_net()
await _saving()
_finished = true
func _saving() -> void:
print("saving")
_set_time(8, 12.0)
bear.hp = 150.0
await _reload()
check(bears.alive() and absf(bear.hp - 150.0) < 0.01, "a hurt bear is still hurt after a reload (%.0f)" % bear.hp)
bear.take_hit(500.0, bear.global_position + Vector3(2, 0, 0))
check(bears.dead_day == 8, "killed on day 8")
await _reload()
check(not bears.alive() and bears.dead_day == 8, "reloaded the same day: still no bear")
_set_time(11, 7.0)
player.global_position = world._spawn
bears.step()
check(bears.alive(), "and it's back three days on, at dawn")
await _reload()
check(bears.alive() and bears.dead_day == -1, "which is saved too")
# --- home ----------------------------------------------------------------------------
func _home() -> void:
print("the bear's home")
check(bears != null and bears.found, "this island has a bear")
bear = bears.bear
check(bear != null and bear.kind == "bear" and bear.hp == 220.0, "one bear, 220 health")
var h: Vector3 = bears.home
real_home = h
var trees := 0
for n in world.props.resource_nodes.values():
if n.kind == "tree" and (n as Node3D).global_position.distance_to(h) < 18.0:
trees += 1
var rim: float = world.props._near_rim(h.x, h.z)
check(trees >= 8 and rim > 0.1, "it lives in thick forest (%d trees within 18 m) near the rim (%.2f)" % [trees, rim])
var spawn: Vector3 = world._spawn
check(Vector2(h.x - spawn.x, h.z - spawn.z).length() > 60.0 and h.distance_to(world.den.position) > 60.0,
"away from the spawn (%.0f m) and the wolf den (%.0f m)" % [Vector2(h.x - spawn.x, h.z - spawn.z).length(), h.distance_to(world.den.position)])
var again: Node = load("res://scripts/creatures/bear_range.gd").new()
again.world = world
again._rng.seed = world.terrain.noise_seed + 881
check(again._find_home() and again.home.distance_to(h) < 0.01, "the same spot every time for this seed")
again.free()
func _day_and_night() -> void:
print("its day")
bears.enabled = false
player.global_position = world._spawn # (well away, on the island)
_set_time(2, 22.0)
await _sim(30.0, func(): return bear.state == "rest")
check(bear.state == "rest" and bear.global_position.distance_to(bears.home) < 3.0, "at night it sleeps at home")
_set_time(2, 12.0)
var far := 0.0
var t := 0.0
while t < 120.0:
await _sim(5.0)
far = maxf(far, bear.global_position.distance_to(bears.home))
t += 5.0
check(bear.state != "rest" and far > 3.0 and far < bears.HOME_RADIUS + 6.0, "by day it ambles round its patch (up to %.0f m out)" % far)
check(not bears.raids_now(), "day 2: no raids yet")
# --- raids ----------------------------------------------------------------------------
var site: Vector3
var yaw := 0.0
## Moves the bear's patch (and the bear) next to the test site, a short
## walk from the food.
func _bear_near(p: Vector3, d: float) -> void:
bears.home = _ground(p + Vector3(-d, 0, 0))
bear.global_position = bears.home
bear.state = "idle"
bear._raid_cd = 0.0
bear._sniff = 0.0
bear._aggro = 0.0
bear.food = null
func _chest(at: Vector3) -> Node3D:
var c: Node3D = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(Vector3.UP, yaw), _ground(at)))
c.inventory.add("berries", 5)
c.inventory.add("raw_meat", 2)
c.inventory.add("wood", 3)
return c
func _raids() -> void:
print("raids")
# Inland, away from the lake the spawn faces (dry, fairly flat ground).
site = bs.debug_find_site(world._spawn + player.global_basis.z * 30.0, 6.0)
yaw = player.rotation.y
check(site.y > world.terrain.water_level + 0.5, "(test site on dry ground, %.1f m up)" % (site.y - world.terrain.water_level))
_set_time(4, 11.0)
check(bears.raids_now(), "from day 3 it goes after food")
var chest := _chest(site)
await _settle()
_bear_near(site, 22.0)
player.global_position = _ground(site + Vector3(30, 0, 0))
_toasts.clear()
var took: float = await _sim(60.0, func(): return chest.inventory.count("berries") == 0)
check(chest.inventory.count("berries") == 0 and chest.inventory.count("raw_meat") == 0, "it smells an unattended chest 22 m off and eats the food (%.0f s)" % took)
check(chest.inventory.count("wood") == 3, "but leaves the wood")
check(_toast_has("bear"), "you hear about it (%s)" % [_toasts])
check(bear.state == "eat", "and sits there eating")
await _sim(10.0)
check(bear.state != "eat", "then wanders off")
print("food left out")
_bear_near(site, 18.0)
var meat: Node3D = world.drop_item("raw_meat", 3, Transform3D(Basis(), _ground(site + Vector3(0, 0, 4)) + Vector3(0, 0.2, 0)))
chest.queue_free()
await _settle()
await _sim(60.0, func(): return not is_instance_valid(meat) or meat.taken)
check(not is_instance_valid(meat) or meat.taken, "meat left on the ground is eaten too")
print("with you there")
_bear_near(site, 20.0)
var guarded := _chest(site + Vector3(0, 0, -3))
player.global_position = _ground(site + Vector3(6, 0, -3))
await _settle()
await _sim(25.0)
check(guarded.inventory.count("berries") == 5, "it leaves a chest alone while you're beside it")
world.remove_piece(guarded)
await _settle()
func _fenced() -> void:
print("a palisade")
var c := site + Vector3(0, 0, 0)
var pdef: Dictionary = DB.get_item("palisade")
for i in [-1, 0, 1]:
for side in [[Vector3(2 * i, 0, -3), 0.0], [Vector3(2 * i, 0, 3), 0.0], [Vector3(3, 0, 2 * i), PI * 0.5], [Vector3(-3, 0, 2 * i), PI * 0.5]]:
var at: Vector3 = c + (side[0] as Vector3)
world.spawn_piece(pdef, bs._free_xf("palisade", at, float(side[1]), true))
var inside := _chest(c)
await _settle()
check(bears.fenced(inside.global_position), "a chest inside a palisade ring is fenced in")
_bear_near(site, 20.0)
player.global_position = _ground(site + Vector3(40, 0, 0))
await _sim(45.0)
check(inside.inventory.count("berries") == 5 and inside.inventory.count("raw_meat") == 2, "the bear can't get at it: the food is all there")
check(bear.food == null, "and it isn't even trying")
for p in world.all_pieces():
if p.global_position.distance_to(c) < 5.0:
world.remove_piece(p)
await _settle()
print("a closed hut")
var hut: Dictionary = await bs.debug_build_hut(site, 0.0)
await _settle()
var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1)
var in_hut: Node3D = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), center + Vector3(-0.8, 0, -0.8)))
in_hut.inventory.add("cooked_meat", 4)
await _settle()
check(bears.fenced(in_hut.global_position) and not bears.raidable(in_hut), "food in a closed hut is safe too")
var door: Node3D = null
for p in hut.pieces:
if p.item_id == "wood_door":
door = p
door.set_open(true, false)
await _settle()
check(bears.raidable(in_hut), "...unless you leave the door open")
door.set_open(false, false)
for p in world.all_pieces():
if p.global_position.distance_to(center) < 6.0:
p.queue_free()
await _settle()
func _crowded() -> void:
print("too close")
_set_time(4, 12.0)
_bear_near(site, 0.0)
player.survival.health = 100.0
var to: Vector3 = bear.global_position
player.global_position = _ground(to + Vector3(7.0, 0, 0))
await _sim(1.0)
check(bear.state == "warn", "at 7 m it rears up and warns you off (%s)" % bear.state)
check(bear.interact_prompt(player).contains("warning"), "\"%s\"" % bear.interact_prompt(player))
player.global_position = _ground(bear.global_position + Vector3(12.0, 0, 0))
await _sim(1.0)
check(bear.state != "warn" and bear.state != "charge" and player.survival.health == 100.0, "back off and it leaves you be")
player.global_position = _ground(bear.global_position + Vector3(4.5, 0, 0))
await _sim(8.0, func(): return player.survival.health < 100.0)
check(player.survival.health < 100.0 and player.survival.health <= 100.0 - 20.0, "stay close and it charges: one blow takes %.0f health" % (100.0 - player.survival.health))
player.survival.health = 100.0
print("a fight")
player.global_position = _ground(bear.global_position + Vector3(25.0, 0, 0))
await _sim(20.0, func(): return bear._aggro <= 0.0)
bear._aggro = 0.0
bear.state = "idle"
bear.take_hit(30.0, player.global_position)
check(bear.state == "charge" and bear._aggro > 0.0, "shoot it and it comes for you")
bear.take_hit(150.0, player.global_position)
check(bear.state == "flee", "badly hurt (%.0f left), it runs" % bear.hp)
func _drops_and_rug() -> void:
print("its hide")
bear.take_hit(500.0, player.global_position)
check(bear.is_dead() and bears.dead_day == 4, "it can be killed")
var got: Dictionary = bear.butcher_drops()
check(int(got.get("bear_hide", 0)) == 1 and int(got.get("bear_fat", 0)) >= 2 and int(got.get("raw_meat", 0)) >= 5,
"butchered: a hide, fat and plenty of meat (%s)" % [got])
check(int(DB.get_item("bear_fat").fuel) == 3 * int(DB.get_item("wood").fuel), "fat burns three times as long as wood")
player.inventory.load_data(Inventory.new(40).to_data())
player.inventory.add("bear_fat", 1)
var fire: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), _ground(site + Vector3(10, 0, 10))))
fire.fuel = 10.0
player.global_position = fire.global_position + Vector3(1.2, 0, 0)
check(A.add_fuel(player, fire) and absf(fire.fuel - 190.0) < 0.1, "and goes on a fire (%.0f s of fuel)" % fire.fuel)
world.remove_piece(fire)
print("a fur rug")
var hut: Dictionary = await bs.debug_build_hut(site, 0.0)
await _settle()
var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1)
player.inventory.add("bear_hide", 1)
player.inventory.add("fiber", 2)
var wb: Node3D = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), _ground(site + Vector3(8, 0, 0))))
player.global_position = wb.global_position + Vector3(0, 0, 1.4)
check(A.craft(player, "fur_rug"), "a fur rug at the workbench (a bear hide, 2 fibre)")
world.remove_piece(wb)
var rdef: Dictionary = DB.get_item("fur_rug")
var rxf := Transform3D(Basis(), center + Vector3(0.0, 0.0, 0.3))
check(world.placement_valid(rdef, rxf), "it goes on the hut floor")
var rug: Node3D = world.spawn_piece(rdef, rxf)
var hearth_fire: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), center + Vector3(0.9, 0, -0.9)))
var sv: Node = player.survival
var spot := center + Vector3(-0.5, 0.05, 0.5)
player.global_position = spot
await _settle()
sv._shelter_left = 0.0
sv.step(0.5)
check(sv.cozy and "Cozy" in sv.statuses, "standing by it in the hut: Cozy (%s)" % [sv.statuses])
sv.rested = 0.0
sv._rest_build = 0.0
var secs := 0
while sv.rested <= 0.0 and secs < 40:
sv.step(1.0)
secs += 1
check(secs <= int(sv.REST_AFTER / 2.0) + 1, "Rested in %d s by the fire (half the usual %d)" % [secs, int(sv.REST_AFTER)])
var q := PhysicsRayQueryParameters3D.create(spot + Vector3(0, 1.0, 0), rug.global_position + Vector3(0, 0.02, 0), 1)
check(world.get_world_3d().direct_space_state.intersect_ray(q).is_empty(), "and it's flat: you walk over it")
rug.queue_free()
hearth_fire.queue_free()
await _settle()
sv._shelter_left = 0.0
sv.step(0.5)
check(not sv.cozy, "no rug, not cozy")
for p in hut.pieces:
if is_instance_valid(p):
p.queue_free()
await _settle()
func _comes_back() -> void:
print("another bear")
bears.home = real_home # (the raids moved it next to the test site)
bear = bears.bear
_set_time(5, 7.0)
bears.step()
check(not bears.alive(), "the next morning: no bear")
_set_time(7, 7.0)
player.global_position = _ground(bears.home + Vector3(15, 0, 0))
bears.step()
check(not bears.alive(), "three days on, not while you're standing at its home")
player.global_position = world._spawn
bears.step()
check(bears.alive() and bears.bear != bear, "but once you've gone, there's a bear again")
bear = bears.bear
func _net() -> void:
print("online")
var CN: Node = root.get_node("CreatureNet")
bear.set_meta("cid", 91)
bear.state = "raid"
var buf := PackedByteArray()
buf.resize(CN.ENTRY)
CN._encode(buf, 0, bear)
var e: Array = CN._decode(buf, 0)
check(e[1] == "bear" and e[4] == "raid", "the bear goes over the network (%s, %s)" % [e[1], e[4]])