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:
376
tests/test_boss.gd
Normal file
376
tests/test_boss.gd
Normal 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()
|
||||
Reference in New Issue
Block a user