extends RefCounted
## Zone 5 of docs/ZONES_ROADMAP.md, Hollowspire Necropolis (Lv 155-169 field) + Hollowspire Undercroft (Lv 172-180
## dungeon), selftest --only=zone5 (also at the end of the full run): data (9 scaled monsters with own names + cards,
## mostly Undead, loot, plain Lv 155 gear, 3 quests, NPC, 2 maps, portals Caldera <-> Necropolis <-> Undercroft), the
## ruined city built on the client (walls, graves, spire, braziers, mist) and on the server (monsters, wall / spire /
## brazier colliders), HALLOWED BRAZIERS (server/hazards.gd: undead monsters inside a ring lose 3% MaxHP a second, never
## the last HP; others are not touched), the dungeon with its mini-boss, music keys.

const SelfTest = preload("res://scripts/selftest.gd")
const WorldBuilder = preload("res://scripts/world_builder.gd")
const EnvNecro = preload("res://scripts/env_necro.gd")
const Hazards = preload("res://scripts/server/hazards.gd")
const Combat = preload("res://scripts/server/combat.gd")

const FIELD_MONS := ["grave_shambler", "bone_sentry", "barrow_knight", "dusk_wraith", "rotbark_hierophant", "wailing_shade"]
const CAVE_MONS := ["ashen_deacon", "headless_warden", "bone_regent"]
const GEAR := ["gravewarden_blade", "bonewood_longbow", "lantern_staff", "sepulchre_plate", "shroudweave_robe"]
const MAP := "hollowspire_necropolis"
## Inside the ring of the brazier at (-22, 7) (clear of its pedestal), and plain street far from every ring.
const IN_RING := Vector3(-19.5, 0, 7)
const OUTSIDE := Vector3(-40, 0, 0)


static func check(ok: bool, what: String) -> void:
	SelfTest.check(ok, "zone5: " + what)


static func _bodies_at(main: Node, p: Vector3, mask: int) -> Array:
	var q := PhysicsPointQueryParameters3D.new()
	q.position = Vector3(p.x, 1.0, p.z)
	q.collision_mask = mask
	q.collide_with_areas = false
	return main.get_world_3d().direct_space_state.intersect_point(q, 8)


static func _portal_to(to: String) -> int:
	for i in WorldBuilder.portals().size():
		if str(WorldBuilder.portals()[i]["to_map"]) == to:
			return i
	return -1


static func _landmark(kind: String) -> Dictionary:
	for lm in GameData.map(MAP)["env"]["landmarks"]:
		if str(lm["kind"]) == kind:
			return lm
	return {}


static func secs(main: Node, t: float) -> void:
	var end := Time.get_ticks_msec() + int(t * 1000.0)
	while Time.get_ticks_msec() < end:
		await main.get_tree().physics_frame


static func run(main: Node) -> void:
	print("SELFTEST zone5")
	data_tests()
	await world_tests(main)


static func data_tests() -> void:
	var bad: Array = []
	var undead := 0
	for mid in FIELD_MONS + CAVE_MONS:
		var d: Dictionary = GameData.monsters.get(mid, {})
		if d.is_empty():
			bad.append(mid + " missing")
			continue
		if int(d["lv"]) < 155 or int(d["lv"]) > 180 or str(d.get("name", "")) == "":
			bad.append(mid + " lv/name")
		if int(d["hp"]) < 10000 or int(d["def"]) > 70 or int(d["mdef"]) > 70:
			bad.append(mid + " stats")
		var card := false
		for dr in d.get("drops", []):
			if GameData.item(str(dr["id"])).is_empty():
				bad.append(mid + " drop " + str(dr["id"]))
			card = card or str(dr["id"]) == mid + "_card"
		if not card:
			bad.append(mid + " card")
		undead += 1 if Combat.is_undead(d) else 0
	check(bad.is_empty(), "9 monsters Lv 155-180, own names, loot + a card each %s" % str(bad))
	check(undead == 8 and not Combat.is_undead(GameData.monsters["wailing_shade"]), "8 of 9 are Undead (the Wailing Shade is not)")
	var rg: Dictionary = GameData.monsters.get("bone_regent", {})
	check(bool(rg.get("miniboss", false)) and int(rg.get("hp", 0)) > int(GameData.monsters["ashen_deacon"]["hp"]), "Bone Regent is the mini-boss, tougher than its Deacons")
	var gear_ok := true
	for g in GEAR:
		gear_ok = gear_ok and int(GameData.item(g).get("req_lv", 0)) == 155
	check(gear_ok, "5 plain Lv 155 pieces")
	for mp in [[MAP, 62], ["hollowspire_undercroft", 25]]:
		var m: Dictionary = GameData.map(str(mp[0]))
		var total := 0
		for z in m.get("zones", []):
			for e in z["mobs"]:
				total += int(e["count"]) if GameData.monsters.has(str(e["id"])) else -999
		check(total == int(mp[1]) and total == int(m.get("monster_total", -1)), "%s: %d monsters" % [str(mp[0]), total])
	var from_caldera := false
	for pt in GameData.map("cinder_caldera").get("portals", []):
		from_caldera = from_caldera or str(pt["to_map"]) == MAP
	check(from_caldera, "the Cinder Caldera has a warp to the necropolis")
	var br := Hazards.hallowed_of(MAP)
	check(br.size() == 4 and Hazards.hallowed_of("cinder_caldera").is_empty() and not Hazards.in_hallowed(br, IN_RING).is_empty()
		and Hazards.in_hallowed(br, OUTSIDE).is_empty(), "4 hallowed braziers, only on this map")
	check(Hazards.hallow_amount(br[0], 10000, 10000) == 300 and Hazards.hallow_amount(br[0], 10000, 120) == 119 and Hazards.hallow_amount(br[0], 10000, 1) == 0,
		"hallowed burn 3% MaxHP a second, never the last HP")
	# no zone heart sits in a ring (its monsters would stand in the fire all day), nor the arrival point
	var clear := Hazards.in_hallowed(br, Vector3(-76, 0, 0)).is_empty()
	for z in GameData.map(MAP)["zones"]:
		var c := Vector3(float(z["center"][0]), 0, float(z["center"][1]))
		for b in br:
			clear = clear and Vector2(c.x - float(b["at"][0]), c.z - float(b["at"][1])).length() > float(z["radius"]) + float(b["r"])
	check(clear, "arrival point and hunting grounds are clear of the rings")
	var qok := true
	for q in ["necro_wraiths", "necro_censers", "necro_regent"]:
		qok = qok and str(GameData.quest(q).get("giver", "")) == "necro_keeper"
	check(qok and not SelfTest.find_npc("necro_keeper").is_empty(), "3 quests from Gravekeeper Ysolde")


static func world_tests(main: Node) -> void:
	var tree = main.get_tree()
	var pl = main.player
	var pe = main.me()
	SelfTest.make_job(main, "swordman")
	PlayerState.ch.base_lv = 160
	pe.dead = false
	PlayerState.hp = PlayerState.max_hp()
	main.load_map("cinder_caldera")
	await SelfTest.wait_map(main, "cinder_caldera")
	await SelfTest.frames(main, 3)
	var to: String = await SelfTest.take_portal(main, _portal_to(MAP))
	check(to == MAP and main.map_id == to and pl.global_position.distance_to(Vector3(-76, 0, 0)) < 1.0, "portal: caldera -> Hollowspire Necropolis")
	await SelfTest.frames(main, 3)
	PlayerState.ch.add_buff("hiding", 600.0, 10)
	var mids: Dictionary = SelfTest.mob_ids(main)
	var all := true
	for mid in FIELD_MONS:
		all = all and mids.has(mid)
	check(tree.get_nodes_in_group("monsters").size() == 62 and all, "62 monsters, the whole necropolis set incl. the Tyrant's Court (%d)" % tree.get_nodes_in_group("monsters").size())
	var walls: Array = _landmark("ruin_walls")["walls"]
	var blocks := EnvNecro.wall_blocks(walls)
	check(tree.get_nodes_in_group("necro_walls").size() == blocks.size() and blocks.size() >= 40 and tree.get_nodes_in_group("hallowed_rings").size() == 4
		and tree.get_nodes_in_group("hallowed_braziers").size() == 4 and tree.get_nodes_in_group("necro_spire").size() == 1 and tree.get_nodes_in_group("grave_mist").size() == 1,
		"ruins drawn: %d wall blocks, 4 braziers + rings, the spire, mist" % blocks.size())
	check(WorldBuilder.music_area(Vector3(-60, 0, 0)) == "field_10", "necropolis music field_10")
	var mi = main.server.maps.get(MAP, null)
	var wb: Vector3 = (blocks[0][0] as Transform3D).origin
	check(mi != null and not _bodies_at(main, wb, mi.col_layer).is_empty() and not _bodies_at(main, Vector3(70, 0, 0), mi.col_layer).is_empty()
		and not _bodies_at(main, Vector3(-22, 0, 7), mi.col_layer).is_empty(), "server colliders on a wall block, the spire and a brazier")
	# the braziers: an undead monster in the ring burns (never to death), a non-undead one and one outside do not
	main.teleport_player(Vector3(-40, 0, -8))
	var zm = SelfTest.dummy(main, "grave_shambler", IN_RING)
	await secs(main, 2.3)
	var zloss: int = 100000 - zm.hp
	var sm = SelfTest.dummy(main, "wailing_shade", IN_RING)
	await secs(main, 1.6)
	var sloss: int = 100000 - sm.hp
	var om = SelfTest.dummy(main, "grave_shambler", OUTSIDE + Vector3(0, 0, 6))
	await secs(main, 1.6)
	var oloss: int = 100000 - om.hp
	check(zloss >= 6000 and zloss <= 9000 and sloss == 0 and oloss == 0, "hallowed ring burns an undead %d HP in 2.3 s (3000 a second); Wailing Shade %d, outside %d" % [zloss, sloss, oloss])
	var lm = SelfTest.dummy(main, "grave_shambler", IN_RING)
	lm.hp = 2500
	await secs(main, 2.3)
	check(lm.hp == 1 and not lm.dead, "the ring never takes the last HP (%d left)" % lm.hp)
	SelfTest.clear_mobs(main)
	# the dungeon
	main.teleport_player(Vector3(54, 0, 0))
	await SelfTest.frames(main, 2)
	var core: String = await SelfTest.take_portal(main, _portal_to("hollowspire_undercroft"))
	check(core == "hollowspire_undercroft" and main.map_id == core and WorldBuilder.walkable(pl.global_position), "portal: necropolis -> Undercroft (on the floor)")
	await SelfTest.frames(main, 3)
	PlayerState.ch.add_buff("hiding", 600.0, 10)
	var rg = SelfTest.nearest(main, "bone_regent")
	var rv = main.view(rg.id) if rg else null
	check(tree.get_nodes_in_group("monsters").size() == 25 and rv != null and rv.is_mini and absf(rv.scl - 2.0) < 0.01, "undercroft: 25 monsters, Bone Regent x2.0 mini-boss")
	check(WorldBuilder.music_area(Vector3(-50, 0, 0)) == "dungeon_7", "undercroft music dungeon_7")
	PlayerState.ch.remove_buff("hiding")
	var back: String = await SelfTest.take_portal(main, 0)
	check(back == MAP and pl.global_position.distance_to(Vector3(54, 0, 0)) < 1.0, "portal: Undercroft -> necropolis by the tower")
	var home: String = await SelfTest.take_portal(main, _portal_to("cinder_caldera"))
	check(home == "cinder_caldera" and pl.global_position.distance_to(Vector3(76, 0, 40)) < 1.0, "portal: necropolis -> caldera")
	PlayerState.hp = PlayerState.max_hp()
	main.load_map(GameData.start_map())
	await SelfTest.wait_map(main, GameData.start_map())
