P5 the Old Stag: standing stones and altar, three-phase boss with charges, stun window, spirit wolves and stomps; antler pickaxe and trophy; group scaling and credit for every fighter; online 3-player test (protocol 12)

This commit is contained in:
Paul Bailey
2026-11-05 13:39:06 +00:00
parent 3389ec9a21
commit e03411ffdb
24 changed files with 1587 additions and 17 deletions

View File

@@ -138,6 +138,9 @@ func _do(cmd: Dictionary) -> Dictionary:
return {"heard": root.get_node("Chat").heard.duplicate()}
"heard":
return {"heard": root.get_node("Chat").heard.duplicate(), "pvp": bool(net.server_info.get("pvp", false))}
# P5 (tests/test_net_boss.gd): this player's copy of the standing stones.
"stones":
return world.stones.save_data() if world.stones != null else {}
return {"error": "unknown command"}
## What this player's game shows of the server's wildlife.

View File

@@ -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_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
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_boss 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_boss 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,

376
tests/test_boss.gd Normal file
View File

@@ -0,0 +1,376 @@
extends SceneTree
## P5 the Old Stag (docs/PROGRESSION_DESIGN.md §6): the standing stones and
## their altar, setting the barrow key pieces, calling the stag at night, a
## scripted fight through its three phases (charges you can dodge, a stunned
## window, spirit wolves, stomps), its antler and trophy, the antler pickaxe,
## the stag leaving when you do (or at dawn), and saving.
## Run: godot --headless --path . --script res://tests/test_boss.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 st: Node
var stag: Node3D
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("Boss", 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
world.bears.enabled = false
world.thieves.enabled = false
world.barrow.running = 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")
world.hazards.set_physics_process(false)
st = world.stones
st.set_physics_process(false)
func _reload() -> void:
check(world.save_now(), "saved")
world.queue_free()
await process_frame
await _load()
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
## Runs the stones, the stag and its wolves for up to `secs`, stopping once
## `until` is true.
func _sim(secs: float, until: Callable = Callable()) -> float:
var t := 0.0
while t < secs:
st.step(0.05)
for c in st.get_children():
if is_instance_valid(c) and c.has_method("take_hit") and not c.is_queued_for_deletion():
c.set_physics_process(false)
c.step(0.05)
t += 0.05
if until.is_valid() and until.call():
break
if int(t * 20) % 20 == 0:
await physics_frame
return t
## Stand at `lp` (x, z relative to the stones' centre), on the ground.
func _stand(off: Vector3) -> void:
var p: Vector3 = st.center() + off
p.y = world.terrain.sample_height(p.x, p.z)
player.global_position = p
player.velocity = Vector3.ZERO
func _heal() -> void:
player.survival.health = 100.0
player.survival.dead = false
func _body() -> void:
_scenery()
_altar()
await _call()
await _phase1()
await _phase2()
await _phase3()
await _victory()
await _pickaxe()
await _leaving()
await _saving()
_net()
_finished = true
# --- the stones -----------------------------------------------------------------------------
func _scenery() -> void:
print("the standing stones")
check(st.found, "this island has standing stones")
var c: Vector3 = st.center()
var sp: Vector3 = world._spawn
var rim: float = world.props._near_rim(c.x, c.z)
check(rim > 0.1, "at the foot of the rim (%.2f)" % rim)
check(Vector2(c.x - sp.x, c.z - sp.z).length() > 60.0 and c.distance_to(world.den.position) > 60.0 and c.distance_to(world.barrow.position) > 50.0,
"away from the spawn (%.0f m), the den (%.0f m) and the barrow (%.0f m)" % [Vector2(c.x - sp.x, c.z - sp.z).length(), c.distance_to(world.den.position), c.distance_to(world.barrow.position)])
check(Vector2(c.x - world.bears.home.x, c.z - world.bears.home.z).length() > 40.0, "and the bear's home (%.0f m, bear found %s)" % [Vector2(c.x - world.bears.home.x, c.z - world.bears.home.z).length(), world.bears.found])
check(st.get_node("Stones").get_child_count() == st.STONES, "a ring of %d stones" % st.STONES)
var again: Node3D = load("res://scripts/world/standing_stones.gd").new()
again.world = world
again.terrain = world.terrain
check(again._find_site().distance_to(c) < 0.01, "the same place every time for this seed")
again.free()
var trees := 0
for n in world.props.resource_nodes.values():
if Vector2(n.global_position.x - c.x, n.global_position.z - c.z).length() < st.CLEAR_R:
trees += 1
check(trees == 0, "a clearing inside the ring")
func _altar() -> void:
print("the altar")
_set_time(5, 12.0)
_stand(Vector3(0, 0, 1.6).rotated(Vector3.UP, st.yaw))
player.inventory.load_data(Inventory.new(40).to_data())
check(st.altar_prompt().contains("0 of 3"), "\"%s\"" % st.altar_prompt())
_toasts.clear()
check(not A.use_stones(player, st), "no key pieces: nothing happens")
check(_toast_has("Barrow key pieces would fit"), "but it tells you what goes there")
player.inventory.add("barrow_key", 2)
check(A.use_stones(player, st) and st.keys == 2 and player.inventory.count("barrow_key") == 0, "two key pieces go in")
check(st._key_meshes[0].visible and st._key_meshes[1].visible and not st._key_meshes[2].visible, "(you can see them in their hollows)")
player.inventory.add("barrow_key", 1)
_toasts.clear()
check(A.use_stones(player, st) and st.keys == 3, "and the third")
check(_toast_has("begin to hum") and _toast_has("night"), "the stones hum: come back at night (%s)" % [_toasts])
check(not A.use_stones(player, st) and not st.fighting(), "by day, nothing comes")
func _call() -> void:
print("calling the Old Stag")
_set_time(5, 22.0)
_toasts.clear()
check(st.altar_prompt().contains("Call the Old Stag"), "at night: \"%s\"" % st.altar_prompt())
check(A.use_stones(player, st) and st.fighting(), "it comes")
stag = st.stag
check(stag.hp == 900.0 and stag.phase == 1, "900 health")
check(_toast_has("Old Stag has come"), "everyone hears it (%s)" % [_toasts])
check(stag.global_position.distance_to(player.global_position) > 10.0, "out of the mist on the far side of the ring")
check(not A.use_stones(player, st), "(the altar does nothing while it's here)")
# --- the fight -------------------------------------------------------------------------------
func _phase1() -> void:
print("phase 1: charges")
var sv: Node = player.survival
_stand(Vector3(0, 0, 3.5).rotated(Vector3.UP, st.yaw))
_heal()
await _sim(6.0, func(): return stag.state == "telegraph")
check(stag.state == "telegraph", "it circles, then lowers its antlers (the warning)")
check(stag.interact_prompt(player).contains("lowering"), "\"%s\"" % stag.interact_prompt(player))
await _sim(4.5, func(): return stag.state == "stunned")
check(stag.charge_hits == 1 and sv.health <= 70.5, "stand still and the charge hits you for 30 (health %.0f)" % sv.health)
check(stag.state == "stunned", "then it stands there, stunned")
var hp0: float = stag.hp
stag.take_hit(20.0, player.global_position)
check(absf(hp0 - stag.hp - 30.0) < 0.01, "hits while it's stunned do half as much again (20 -> %.0f)" % (hp0 - stag.hp))
_heal()
await _sim(8.0, func(): return stag.state == "charge")
check(stag.state == "charge", "another charge...")
# Step aside: 7 m to the side of its line.
var side: Vector3 = stag._dir.cross(Vector3.UP).normalized() * 7.0
player.global_position += side
player.global_position.y = world.terrain.sample_height(player.global_position.x, player.global_position.z)
await _sim(4.5, func(): return stag.state == "stunned")
check(stag.charge_hits == 1 and sv.health == 100.0, "step aside and it thunders past (health %.0f)" % sv.health)
print(" (with a spear)")
player.inventory.set_slot(0, {"id": "iron_spear", "count": 1})
player.select(0)
_stand(Vector3.ZERO)
stag.global_position = player.global_position + Vector3(2.5, 0, 0)
stag.state = "stunned"
stag._state_t = 2.0
sv.stamina = 100.0
hp0 = stag.hp
check(A.melee(player, stag) and absf(hp0 - stag.hp - 26.0 * 1.5) < 0.01, "an iron spear in the stunned window: %.0f" % (hp0 - stag.hp))
func _phase2() -> void:
print("phase 2: the wolves")
_stand(Vector3(0, 0, 3.5).rotated(Vector3.UP, st.yaw))
_heal()
stag.state = "circle"
stag.hp = 900.0 * 2.0 / 3.0 + 5.0
stag.take_hit(10.0, player.global_position)
_toasts.clear()
await _sim(0.2)
check(stag.phase == 2 and stag.state == "summon", "below 2/3 it rears up and calls...")
check(st.wolves_alive().size() == 3, "three spirit wolves")
check(_toast_has("spirit wolves"), "(%s)" % [_toasts])
var w0: Node3D = st.wolves_alive()[0]
var hp0: float = player.survival.health
await _sim(12.0, func(): return player.survival.health < hp0)
check(player.survival.health < hp0, "they come for you (health %.0f)" % player.survival.health)
w0.take_hit(10.0, player.global_position)
check(w0.state == "charge", "hit one and it keeps coming (spirit wolves don't run)")
for w in st.wolves_alive():
w.take_hit(100.0, player.global_position)
check(st.wolves_alive().is_empty(), "you put them down")
func _phase3() -> void:
print("phase 3: faster, and stomps")
_heal()
stag.state = "circle"
stag.hp = 900.0 / 3.0 + 5.0
stag.take_hit(10.0, player.global_position)
await _sim(0.2)
check(stag.phase == 3 and st.wolves_alive().size() == 2, "below 1/3: two more wolves")
for w in st.wolves_alive():
w.take_hit(100.0, player.global_position)
await _sim(3.0, func(): return stag.state == "circle")
check(stag._times().telegraph < 1.0 and stag._times().stun < 2.0, "quicker warnings, a shorter stun")
# Stay right by it.
var hp0: float = player.survival.health
var stomps0: int = st.stomps
var t := 0.0
while t < 8.0 and st.stomps == stomps0:
player.global_position = stag.global_position + Vector3(3.0, 0, 0)
player.global_position.y = world.terrain.sample_height(player.global_position.x, player.global_position.z)
await _sim(0.25)
t += 0.25
check(st.stomps > stomps0, "stay close and it stamps the ground")
check(player.survival.health <= hp0 - 14.0, "for 15 (health %.0f)" % player.survival.health)
func _victory() -> void:
print("the end")
_heal()
_toasts.clear()
var at: Vector3 = stag.global_position
stag.state = "circle"
stag.take_hit(1000.0, player.global_position)
check(stag.is_dead() and not st.fighting(), "the Old Stag falls")
var antler: Array = world.all_dropped().filter(func(d): return d.item_id == "stag_antler" and d.global_position.distance_to(at) < 3.0)
var trophy: Array = world.all_dropped().filter(func(d): return d.item_id == "stag_trophy" and d.global_position.distance_to(at) < 3.0)
check(antler.size() == 1 and trophy.size() == 1, "it drops its antler and a trophy")
check(st.beaten and st.keys == 0 and st.beaten_day == 5, "the world remembers (beaten on day 5), and the key pieces are spent")
check(_toast_has("Old Stag has fallen"), "(%s)" % [_toasts])
check(st.altar_prompt().contains("0 of 3"), "the altar's empty again")
await _sim(5.0)
check(not is_instance_valid(stag) or stag.is_queued_for_deletion(), "its body fades into the mist")
player.global_position = antler[0].global_position + Vector3(1, 0, 0)
check(A.pickup(player, antler[0]) and A.pickup(player, trophy[0]), "you pick them up")
var tdef: Dictionary = DB.get_item("stag_trophy")
check(tdef.has("place"), "the trophy can be put on show")
func _pickaxe() -> void:
print("the antler pickaxe")
var sv: Node = player.survival
player.inventory.add("iron_bar", 4)
player.inventory.add("wood", 6)
var forge: Node3D = world.spawn_piece(DB.get_item("forge"), Transform3D(Basis(), player.global_position + Vector3(0, 0, 2)))
check(A.craft(player, "antler_pickaxe") and player.inventory.count("stag_antler") == 0, "made at the forge (the antler, an iron bar, 2 wood)")
world.remove_piece(forge)
var ap: Dictionary = DB.get_item("antler_pickaxe")
var ip: Dictionary = DB.get_item("iron_pickaxe")
check(float(ap.tool.damage) >= float(ip.tool.damage) * 1.5, "mines half as fast again as iron (%.1f a hit against %.1f)" % [float(ap.tool.damage), float(ip.tool.damage)])
check(int(ap.durability) > int(ip.durability), "lasts longer (%d uses against %d)" % [int(ap.durability), int(ip.durability)])
var rocks: Array = world.props.resource_nodes.values().filter(func(n): return n.kind == "rock" and not n.depleted)
rocks.sort_custom(func(a, b): return a.global_position.distance_to(player.global_position) < b.global_position.distance_to(player.global_position))
var counts := {}
for id in ["iron_pickaxe", "antler_pickaxe"]:
var rock: Node3D = rocks[0] if id == "iron_pickaxe" else rocks[1]
var to: Vector3 = player.global_position - rock.global_position
to.y = 0
player.global_position = rock.global_position + to.normalized() * 1.4
player.inventory.set_slot(0, {"id": id, "count": 1})
player.select(0)
var hits := 0
var spent := 0.0
while not rock.depleted and hits < 40:
sv.stamina = 100.0
if A.harvest_hit(player, rock, rock.global_position + Vector3(0, 0.4, 0)):
hits += 1
spent += 100.0 - sv.stamina
counts[id] = [hits, spent / maxf(hits, 1)]
check(counts.antler_pickaxe[0] < counts.iron_pickaxe[0], "a boulder in %d hits, against %d with iron" % [counts.antler_pickaxe[0], counts.iron_pickaxe[0]])
check(float(counts.antler_pickaxe[1]) < float(counts.iron_pickaxe[1]) - 1.0, "and less stamina a swing (%.0f against %.0f)" % [float(counts.antler_pickaxe[1]), float(counts.iron_pickaxe[1])])
func _leaving() -> void:
print("losing")
st.keys = 3
st._show()
_set_time(6, 22.0)
_stand(Vector3(0, 0, 1.6).rotated(Vector3.UP, st.yaw))
_toasts.clear()
check(A.use_stones(player, st) and st.fighting(), "three new key pieces: it comes again (there's always a rematch)")
_stand(Vector3(80, 0, 0))
await _sim(6.0, func(): return not st.fighting())
check(not st.fighting() and _toast_has("fades into the mist") and st.keys == 3, "walk away and it goes back into the mist; the key pieces stay")
_stand(Vector3(0, 0, 1.6).rotated(Vector3.UP, st.yaw))
check(A.use_stones(player, st) and st.fighting(), "call it again")
await _sim(1.0)
_set_time(7, 5.6)
_toasts.clear()
await _sim(0.2)
check(not st.fighting() and _toast_has("dawn") and st.keys == 3, "at dawn it fades too")
_set_time(7, 22.0)
_stand(Vector3(0, 0, 1.6).rotated(Vector3.UP, st.yaw))
A.use_stones(player, st)
player.survival.health = 0.0
player.survival.dead = true
await _sim(6.0, func(): return not st.fighting())
check(not st.fighting() and st.keys == 3, "and if it kills you, the key pieces are still there for another night")
_heal()
func _saving() -> void:
print("saving")
st.keys = 2
await _reload()
check(st.beaten and st.beaten_day == 5 and st.keys == 2, "beaten, and two key pieces in the altar, after a reload")
check(st._key_meshes[1].visible and not st._key_meshes[2].visible, "(still showing)")
func _net() -> void:
print("online")
var CN: Node = root.get_node("CreatureNet")
var s: Node3D = st.StagScript.new()
s.setup("old_stag", world, st.center(), 3)
s.stones = st
s.set_physics_process(false)
st.add_child(s)
s.set_meta("cid", 5)
s.state = "telegraph"
var buf := PackedByteArray()
buf.resize(CN.ENTRY)
CN._encode(buf, 0, s)
var e: Array = CN._decode(buf, 0)
check(e[1] == "old_stag" and e[4] == "telegraph" and absf(float(e[6]) - 900.0) < 0.01, "the stag goes over the network (%s, %s, %.0f)" % [e[1], e[4], float(e[6])])
print("groups")
s.scale_for(3)
check(absf(s.max_hp - 1980.0) < 0.01 and s.hp == s.max_hp, "with 3 at the stones it has 1980 health (60% more each)")
check(s._phase_for(1300.0) == 2 and s._phase_for(600.0) == 3, "and its phases follow (2/3 and 1/3 of that)")
CN._encode(buf, 0, s)
check(absf(float(CN._decode(buf, 0)[6]) - 1980.0) < 0.01, "(1980 still fits over the network)")
var J: Script = load("res://scripts/world/standing_stones.gd")
check(J.join_names(["Ana"]) == "Ana" and J.join_names(["Ana", "Bob"]) == "Ana and Bob" and J.join_names(["Ana", "Bob", "Cara"]) == "Ana, Bob and Cara",
"\"Ana, Bob and Cara\"")
s.queue_free()

323
tests/test_net_boss.gd Normal file
View File

@@ -0,0 +1,323 @@
extends SceneTree
## P5 online: the Old Stag on a server, fought by three players. Alice (this
## test, with the real world scene) and two bots, Bob and Cara
## (tests/net_world_bot.gd, each its own process with its own world), on a
## real headless server with --debug-cheats (to set the clock, give items,
## move players and hold the stag still).
##
## Checks: each of them can set a key piece in the shared altar; the stag is
## tougher for three; everyone sees the same stag; all three players' hits
## count; when it falls, all three are named, on every player's game; the
## antler goes to whoever picks it up first.
## Run: godot --headless --path . --script res://tests/test_net_boss.gd
const PORT := 24352
const DIR := "user://test_net"
const SRV := DIR + "/server_boss"
const BOTS := {"Bob": DIR + "/bob_boss", "Cara": DIR + "/cara_boss"}
const SEED := 777
var _fails := 0
var _finished := false
var _pids: Array[int] = []
var Net: Node
var WN: Node
var PN: Node
var CN: Node
var A: Node
var world: Node3D
var player: CharacterBody3D
var _cmd := {"Bob": 0, "Cara": 0}
var _toasts: Array[String] = []
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:
Engine.max_fps = 60
Net = root.get_node("Net")
WN = root.get_node("WorldNet")
PN = root.get_node("PlayerNet")
CN = root.get_node("CreatureNet")
A = root.get_node("Actions")
A.notify.connect(func(t): _toasts.append(t)) # (the server's messages arrive as toasts too)
_rm_tree(SRV)
for n in BOTS:
_rm_tree(BOTS[n])
DirAccess.make_dir_recursive_absolute(BOTS[n])
# (No wildlife: nothing else should join the fight. Day 2: the wolves
# haven't come yet.)
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
"--server-name", "Boss Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"])
await _sleep(4.0)
await _body()
Net.stop()
for pid in _pids:
if OS.is_process_running(pid):
OS.kill(pid)
check(_finished, "test ran to the end (no script errors)")
_rm_tree(SRV)
for n in BOTS:
_rm_tree(BOTS[n])
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body() -> void:
var me = load("res://scripts/net/identity.gd").open(DIR + "/a_boss")
me.name = "Alice"
Net.identity = me
var r := await _join()
check(r == "", "Alice joins (%s)" % r)
if r != "":
return
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
player = world.player
player.set_physics_process(false)
await _until(func(): return WN.received >= 1, 10.0)
for n in BOTS:
_spawn(["--identity", DIR + "/" + n.to_lower() + "_boss_id", "--name", n, "--address", "127.0.0.1:%d" % PORT, "--dir", BOTS[n]],
"res://tests/net_world_bot.gd")
for n in BOTS:
var b0 := await _bot_result(n, 0, 40.0)
check(bool(b0.get("ok", false)), "%s joins (%s)" % [n, b0.get("why", "ok")])
if not bool(b0.get("ok", false)):
return
await _keys()
await _call()
await _fight()
await _prize()
world.queue_free()
await process_frame
Net.leave()
_finished = true
var c := Vector3.ZERO ## the stones' centre
func _ground(p: Vector3) -> Vector3:
return Vector3(p.x, world.terrain.sample_height(p.x, p.z) + 0.05, p.z)
func _keys() -> void:
print("three players, three key pieces")
check(world.stones.found, "Alice's game has the same standing stones")
c = world.stones.center()
PN.ask("debug_time", [2, 22.0])
await _tp(_ground(c + Vector3(2.2, 0, 0)))
await _bot("Bob", {"do": "tp", "to": _v(_ground(c + Vector3(-2.2, 0, 0)))})
await _bot("Cara", {"do": "tp", "to": _v(_ground(c + Vector3(0, 0, 2.2)))})
PN.ask("debug_clear", [])
await _give("barrow_key", 1)
_toasts.clear()
check(A.use_stones(player, world.stones), "Alice sets hers")
await _until(func(): return world.stones.keys == 1, 3.0)
check(world.stones.keys == 1 and _toast_has("1 of 3"), "the altar has one (%d; %s)" % [world.stones.keys, _toasts])
for n in BOTS:
await _bot(n, {"do": "ask", "what": "debug_clear", "args": []})
await _bot(n, {"do": "ask", "what": "debug_give", "args": ["barrow_key", 1]})
await _bot(n, {"do": "ask", "what": "stones", "args": []})
await _until(func(): return world.stones.keys == 3, 4.0)
check(world.stones.keys == 3, "then Bob and Cara set theirs: Alice sees all three")
var bs := await _bot("Bob", {"do": "stones"})
check(int(bs.get("keys", -1)) == 3, "and so does Bob")
func _call() -> void:
print("calling it for three")
_toasts.clear()
check(A.use_stones(player, world.stones), "Alice calls the Old Stag")
var found := {"s": null}
await _until(func():
for cid in CN.puppets:
if CN.puppets[cid].kind == "old_stag" and not CN.puppets[cid].is_dead():
found.s = CN.puppets[cid]
return found.s != null, 6.0)
var s: Node3D = found.s
check(s != null, "it appears on Alice's screen (from the server)")
if s == null:
return
stag_cid = int(s.get_meta("cid"))
check(absf(s.hp - 1980.0) < 0.5, "with 1980 health: 60%% more for each of the two extra players (%.0f)" % s.hp)
check(_toast_has("Old Stag has come"), "everyone's told (%s)" % [_toasts])
var life := await _bot("Cara", {"do": "life"})
var seen: Dictionary = (life.get("puppets", {}) as Dictionary).get(str(stag_cid), {})
check(not seen.is_empty() and seen.kind == "old_stag" and _p(seen.pos).distance_to(s.global_position) < 4.0,
"Cara sees the same stag, in the same place")
var stag_cid := -1
func _stag() -> Node3D:
var s: Variant = CN.puppets.get(stag_cid)
return s if is_instance_valid(s) else null
func _fight() -> void:
print("three players hit it")
var s := _stag()
if s == null:
return
PN.ask("debug_boss", ["hold", 60.0])
await _sleep(0.5)
s = _stag()
var at: Vector3 = s.global_position
await _tp(_ground(at + Vector3(2.4, 0, 0)))
await _bot("Bob", {"do": "tp", "to": _v(_ground(at + Vector3(-2.4, 0, 0)))})
await _bot("Cara", {"do": "tp", "to": _v(_ground(at + Vector3(0, 0, 2.4)))})
PN.ask("debug_clear", [])
var slot := await _give("iron_spear", 1)
player.select(slot)
PN.ask("debug_stats", [{"health": 100.0, "stamina": 100.0}])
await _sleep(0.4)
var hp0: float = s.hp
check(A.melee(player, s), "Alice strikes with an iron spear")
await _until(func(): return _stag() != null and _stag().hp < hp0 - 1.0, 3.0)
check(_stag() != null and absf(hp0 - _stag().hp - 39.0) < 1.0, "39 (26, half as much again while it's stunned): %.0f" % (hp0 - _stag().hp))
for n in BOTS:
await _bot(n, {"do": "ask", "what": "debug_clear", "args": []})
await _bot(n, {"do": "ask", "what": "debug_give", "args": ["iron_spear", 1]})
await _bot(n, {"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]})
await _bot(n, {"do": "ask", "what": "melee", "args": [stag_cid]})
await _until(func(): return _stag() != null and _stag().hp < hp0 - 116.0, 3.0)
check(_stag() != null and absf(hp0 - _stag().hp - 117.0) < 1.5, "Bob's and Cara's blows count too: 117 in all (%.0f)" % (hp0 - _stag().hp))
func _prize() -> void:
print("it falls")
var s := _stag()
if s == null:
return
var at: Vector3 = s.global_position
_toasts.clear()
PN.ask("debug_boss", ["hp", 5.0])
await _sleep(0.3)
await _bot("Cara", {"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]})
await _bot("Cara", {"do": "ask", "what": "melee", "args": [stag_cid]})
await _until(func(): return _toast_has("has fallen"), 4.0)
check(_toast_has("Beaten by Alice, Bob and Cara"), "everyone who fought it is named: %s" % [_toasts])
await _until(func(): return world.stones.beaten, 3.0)
check(world.stones.beaten and world.stones.beaten_by == "Alice, Bob and Cara" and world.stones.fighters.size() == 3,
"Alice's game remembers it: beaten by \"%s\"" % world.stones.beaten_by)
var bs := await _bot("Bob", {"do": "stones"})
check(bool(bs.get("beaten", false)) and String(bs.get("beaten_by", "")) == "Alice, Bob and Cara" and int(bs.get("keys", -1)) == 0,
"so does Bob's, and the key pieces are spent")
var drops := {"antler": "", "trophy": ""}
await _until(func():
for nid in WN.dropped:
var d: Node3D = WN.dropped[nid]
if is_instance_valid(d) and d.global_position.distance_to(at) < 5.0:
if d.item_id == "stag_antler":
drops.antler = "#%d" % nid
elif d.item_id == "stag_trophy":
drops.trophy = "#%d" % nid
return not drops.antler.is_empty() and not drops.trophy.is_empty(), 4.0)
check(not drops.antler.is_empty() and not drops.trophy.is_empty(), "its antler and trophy lie where it fell, for everyone to see")
if drops.antler.is_empty():
return
var took := await _bot("Bob", {"do": "take", "key": drops.antler})
var has := false
for st in took.get("inv", []):
if st != null and String(st.get("id", "")) == "stag_antler":
has = true
check(has, "Bob gets there first: the antler is his")
await _until(func(): return not WN.dropped.has(int(drops.antler.substr(1))), 3.0)
check(not WN.dropped.has(int(drops.antler.substr(1))), "and it's gone from the ground for Alice")
# --- helpers -----------------------------------------------------------------------
func _toast_has(s: String) -> bool:
for t in _toasts:
if t.contains(s):
return true
return false
func _tp(at: Vector3) -> void:
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
player.set_physics_process(false)
await _sleep(0.3)
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)
PN.ask("debug_give", [id, n])
await _until(func(): return player.inventory.count(id) >= before + n, 3.0)
return _slot_of(id)
func _slot_of(id: String) -> int:
for i in range(player.inventory.size()):
var s = player.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.05)
t += 0.05
return cond.call()
static func _v(p: Vector3) -> Array:
return [p.x, p.y, p.z]
static func _p(a: Array) -> Vector3:
return Vector3(a[0], a[1], a[2])
func _bot(n: String, cmd: Dictionary) -> Dictionary:
_cmd[n] += 1
var f := FileAccess.open("%s/cmd_%d.json" % [BOTS[n], _cmd[n]], FileAccess.WRITE)
f.store_string(JSON.stringify(cmd))
f.close()
return await _bot_result(n, _cmd[n], 20.0)
func _bot_result(n: String, i: int, limit: float) -> Dictionary:
var path := "%s/res_%d.json" % [BOTS[n], i]
var t := 0.0
while not FileAccess.file_exists(path) and t < limit:
await _sleep(0.05)
t += 0.05
await _sleep(0.05)
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
return d if d is Dictionary else {"error": "%s gave no answer to command %d" % [n, i], "puppets": {}}
func _join() -> String:
var state := {"done": false, "reason": ""}
var ok := func(): state.done = true
var bad := func(why): state.done = true; state.reason = why
Net.joined.connect(ok)
Net.join_failed.connect(bad)
Net.join("127.0.0.1:%d" % PORT)
var t := 0.0
while not state.done and t < 25.0:
await _sleep(0.05)
t += 0.05
Net.joined.disconnect(ok)
Net.join_failed.disconnect(bad)
return state.reason if state.done else "test timed out"
func _spawn(user_args: Array, script: String = "") -> int:
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")])
if not script.is_empty():
args.append_array(["--script", script])
args.append("--")
for x in user_args:
args.append(str(x))
args.append_array(["--exit-with", str(OS.get_process_id())])
var pid := OS.create_process(OS.get_executable_path(), args)
_pids.append(pid)
return pid
func _sleep(s: float) -> void:
await create_timer(s).timeout
static func _rm_tree(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for sub in da.get_directories():
_rm_tree(d.path_join(sub))
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)

View File

@@ -141,7 +141,9 @@ func _body(world: Node3D, id: String) -> void:
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])
# (Warmth starts moving toward the air's again in the frames the world
# takes to load, so it's only roughly where it was.)
check(absf(sv.hunger - 42.0) < 0.5 and absf(sv.warmth - 33.0) < 3.0, "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():