extends RefCounted
## Headless gameplay test: godot --headless --path game -- --selftest
## Prints "SELFTEST PASS" or "SELFTEST FAIL" plus a list of failures, then quits.
## N1: the game runs on the in-process LocalServer. Player actions go in as commands (main.send, or
## the player view's use_skill / use_item which send them); `pe` = main.me() is the server-side body
## (checks + test setup like resetting delays), `pl` = main.player the view. Monsters returned by
## nearest / dummy / still_mob are SERVER entities (server/mob.gd); main.view(m.id) is their view.

const WorldBuilder = preload("res://scripts/world_builder.gd")
const EnvAssets = preload("res://scripts/env_assets.gd")
const Refine = preload("res://scripts/refine.gd")
const Minimap = preload("res://scripts/minimap.gd")
const ModelLoader = preload("res://scripts/model_loader.gd")
const Icons = preload("res://scripts/icons.gd")
const ItemSlot = preload("res://scripts/item_slot.gd")
const SkillFxTest = preload("res://scripts/skill_fx_test.gd")

static var fails: Array = []


static func check(ok: bool, what: String) -> void:
	print(("  ok   " if ok else "  FAIL ") + what)
	if not ok:
		fails.append(what)


static func frames(main: Node, n: int) -> void:
	for i in n:
		await main.get_tree().physics_frame


## Nearest live server monster `id` to the player.
static func nearest(main: Node, id: String):
	var best = null
	var bd := 1e9
	for m in main.mobs():
		if m.dead or m.mid != id:
			continue
		var d: float = m.global_position.distance_to(main.player.global_position)
		if d < bd:
			bd = d
			best = m
	return best


## Fresh character made into `job` (JL50, BaseLv ~30) with the player's action state reset.
static func make_job(main: Node, job: String) -> bool:
	var ps = PlayerState
	var pe = main.me()
	ps.new_character("Tester")
	ps.gain_exp(0, 100000)
	var ok: bool = ps.change_job(job)
	ps.gain_exp(15000, 5000000)
	pe._stop_cast()
	pe._clear_goals()
	pe.acd = 0.0
	pe.cast_lock = 0.0
	pe.atk_cd = 0.0
	main.player._cancel_targeting()
	return ok


## Removes every monster (no respawn) and ground spell so skill checks are not disturbed by wanderers.
static func clear_mobs(main: Node) -> void:
	var map = main.me().map
	for m in map.mobs.duplicate():
		map.remove_mob(m)
	for a in map.areas.duplicate():
		map._remove_area(a)


## A still, harmless training dummy with 100000 HP. `tweak` overrides fields of its (own copy of) data.
static func dummy(main: Node, id: String, at: Vector3, tweak: Dictionary = {}):
	clear_mobs(main)
	var m = main._spawn(id, at, 0.0)
	m.data = m.data.duplicate(true)
	for k in tweak:
		m.data[k] = tweak[k]
	m.data["speed"] = 0.0
	m.roam = 0.0
	m.max_hp = 100000
	m.hp = 100000
	m.atk_t = 1e6
	return m


## Lets the player act again immediately (tests do not wait out delays). `pe` = main.me().
static func ready_to_act(pe) -> void:
	pe.acd = 0.0
	pe.cast_lock = 0.0


static func run(main: Node) -> void:
	fails = []
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	await frames(main, 5)
	print("SELFTEST start")
	# --only=auto / --only=inventory: just that group (quick iteration); the full run is the default
	for arg in OS.get_cmdline_user_args():
		if arg.begins_with("--only="):
			match arg.substr(7):
				"auto":
					await auto_test(main)
					# AUTO + held WASD steering (steer_test.gd)
					await load("res://scripts/steer_test.gd").run(main)
				"smoke":
					# stability sweep: every v2 class hunts with AUTO + the homestead lane (tools/smoke_test.gd)
					await load("res://scripts/tools/smoke_test.gd").run(main)
				"steer":
					await load("res://scripts/steer_test.gd").run(main)
				"inventory":
					await inventory_test(main)
				"server":
					await server_tests(main)
				"net":
					await net_tests(main)
				"n3":
					await n3_tests(main)
				"lobby":
					await lobby_tests(main)
				"feel":
					await feel_test(main)
				"jobs2":
					# second jobs (jobs2_test.gd, loaded at run time: it preloads this script)
					await load("res://scripts/jobs2_test.gd").run(main)
				"map5":
					await map5_test(main)
				"skillfx":
					fails.append_array(await SkillFxTest.run(main))
				"move":
					# W A S D + touch joystick (ui/move_test.gd)
					await load("res://scripts/ui/move_test.gd").run(main)
				"ui":
					# skill / equip windows, scroll keeper, skill reset, basic info, camera button, job NPCs (ui_test.gd)
					await load("res://scripts/ui/ui_test.gd").run(main)
				"skin":
					# weapon costume skins (weapon_skin_test.gd)
					await load("res://scripts/weapon_skin_test.gd").run(main)
				"giant", "giantshot":
					# showcase MVP giant: telegraphed heavy attacks, aura, size / in-game shot (giant_test.gd)
					await load("res://scripts/giant_test.gd").run(main, arg.substr(7))
				"zone5":
					# Hollowspire Necropolis + Undercroft, zone 5 of docs/ZONES_ROADMAP.md (zone5_test.gd)
					await load("res://scripts/zone5_test.gd").run(main)
				"zone4":
					# Cinder Caldera + Magma Core, zone 4 of docs/ZONES_ROADMAP.md (zone4_test.gd)
					await load("res://scripts/zone4_test.gd").run(main)
				"zone3":
					# Sunken Reef + Drowned Hold, zone 3 of docs/ZONES_ROADMAP.md (zone3_test.gd)
					await load("res://scripts/zone3_test.gd").run(main)
				"zone2":
					# Frostveil Tundra + Hollow, zone 2 of docs/ZONES_ROADMAP.md (zone2_test.gd)
					await load("res://scripts/zone2_test.gd").run(main)
				"layers":
					# physics layers for any number of server maps (layers_test.gd)
					await load("res://scripts/layers_test.gd").run(main)
				"capital":
					# the central capital Crownhaven (capital_test.gd)
					await load("res://scripts/capital_test.gd").run(main)
				"qa":
					# QA sweep 2026-09-30: music fallbacks, monster looks / names, stat clean-up (qa_sweep_test.gd)
					await load("res://scripts/qa_sweep_test.gd").run(main)
				"capital":
					# the central capital Crownhaven (capital_test.gd)
					await load("res://scripts/capital_test.gd").run(main)
				"towns4":
					# the four early towns + their fields, retired tonics (towns4_test.gd)
					await load("res://scripts/towns4_test.gd").run(main)
				"gap":
					# Lv 40-100 gap maps: 6 fields + 6 dungeons (gap_test.gd)
					await load("res://scripts/gap_test.gd").run(main)
				"zone1":
					# Blossom Meadows, zone 1 of docs/ZONES_ROADMAP.md (zone1_test.gd)
					await load("res://scripts/zone1_test.gd").run(main)
				"combat_v2":
					# Combat v2 formulas vs tools/balance/sim.py, crit v2, break gauge, mob HP rule (combat_v2_test.gd)
					await load("res://scripts/combat_v2_test.gd").run(main)
				"v2fu":
					# Combat v2 follow-ups: tooltip cooldown, v2 skill icons, shield slot, instruments (v2fu_test.gd)
					await load("res://scripts/v2fu_test.gd").run(main)
				"skills_v2":
					# Combat v2 launch kits, executor, stack passive, wheel, AUTO roles (skills_v2_test.gd)
					await load("res://scripts/skills_v2_test.gd").run(main)
				"playtest":
					# Combat v2 in-game balance playtest, one class x level per run (playtest.gd, tools/balance/playtest.py)
					await load("res://scripts/playtest.gd").run(main)
				"ptrules":
					# playtest round 2 rules: level cap, EXP curve, bonus skill points, % potions, role bonus, AUTO kiting (playtest_rules_test.gd)
					await load("res://scripts/playtest_rules_test.gd").run(main)
				"gun":
					# fire guns held as guns (gun_hold.gd, gun_pose.gd)
					await load("res://scripts/gun_test.gd").run(main)
				"asurastrip":
					# asura ride motion strips (asura_strip.gd)
					await load("res://scripts/asura_strip.gd").run(main)
				"unlock":
					# Awakening / Asura Rider unlock gates (unlock_test.gd)
					await load("res://scripts/unlock_test.gd").run(main)
				"perclass":
					# per-class levels + main Lv 50 class change (perclass_test.gd)
					await load("res://scripts/perclass_test.gd").run(main)
				"pk3":
					# PK step 3: bounty maps, boss rings (pk3_test.gd)
					await load("res://scripts/pk3_test.gd").run(main)
				"arena":
					# arena 3v3 (server/arena.gd, arena_test.gd)
					await load("res://scripts/arena_test.gd").run(main)
				"skillgate":
					# skill level caps by Base Level + the reason in the skill window (skill_gate_test.gd)
					await load("res://scripts/skill_gate_test.gd").run(main)
				"skinclass":
					# a weapon costume on every class (skin_class_test.gd)
					await load("res://scripts/skin_class_test.gd").run(main)
				"guildact":
					# guild activities (server/guild_act.gd, guild_act_test.gd)
					await load("res://scripts/guild_act_test.gd").run(main)
				"guildwar":
					# guild war (server/guild_war.gd, guild_war_test.gd)
					await load("res://scripts/guild_war_test.gd").run(main)
				"guild":
					# guild basics (server/guild.gd, guild_test.gd)
					await load("res://scripts/guild_test.gd").run(main)
				"pvp":
					# PvP duels (server/pvp.gd, pvp_client.gd)
					await load("res://scripts/pvp_test.gd").run(main)
				"asura":
					# Asura Rider: data, summons, nodes, the ride on the server + its look, window (asura_test.gd)
					await load("res://scripts/asura_test.gd").run(main)
				"asuragear":
					# Asura gear / bond lines / talismans (asura_gear_test.gd)  # asura-gear
					await load("res://scripts/asura_gear_test.gd").run(main)
				"awaken":
					# awakening: gauge, transform command, awakened effects, save / load (awaken_test.gd, server/awaken.gd)
					await load("res://scripts/awaken_test.gd").run(main)
				"v2ui":
					# Combat v2 UI: BR / ratings, Class window, wheel tab, break bar, number styles (ui/v2_ui_test.gd)
					await load("res://scripts/ui/v2_ui_test.gd").run(main)
				"classes4":
					# later classes Samurai / Flying Sword / Flamethrower / Fire Mage (classes4_test.gd)
					await load("res://scripts/classes4_test.gd").run(main)
				"jobslots":
					# Combat v2 class roster + Job Slots + save migration (job_slots_test.gd)
					await load("res://scripts/job_slots_test.gd").run(main)
				"pull", "comfort":
					# Swordsman Lure Chain / comfort quests + Starlit Sanctum (comfort_test.gd)
					await load("res://scripts/comfort_test.gd").run(main, arg.substr(7))
				"mounts", "gacha":
					# mounts for every class + the Starwell gacha (mounts_test.gd)
					await load("res://scripts/mounts_test.gd").run(main, arg.substr(7))
				"dial", "constellation":
					# Beast Constellation card dials + album + Bestiary Well (constellation_test.gd)
					await load("res://scripts/constellation_test.gd").run(main)
				"maps_ui":
					# minimap / full map / world map (maps_ui_test.gd)
					fails.append_array(await load("res://scripts/maps_ui_test.gd").run(main))
				"fishing", "cooking", "housing", "helper", "life":
					# life skills: fishing / cooking / homestead (life/life_test.gd)
					await load("res://scripts/life/life_test.gd").run(main, arg.substr(7))
				"settings":
					# settings menu (settings_test.gd): options apply / persist / reset, presets, lighting, keys, language
					await load("res://scripts/settings_test.gd").run(main, load("res://scripts/selftest.gd"))
			finish(main)
			return

	check(main.get_tree().get_nodes_in_group("monsters").size() >= 50, "monsters spawned (%d)" % main.get_tree().get_nodes_in_group("monsters").size())
	check(main.get_tree().get_nodes_in_group("npcs").size() == town_npcs(GameData.start_map()), "%d npcs spawned" % town_npcs(GameData.start_map()))
	check(ps.base_lv == 1 and ps.hp == ps.max_hp() and ps.hp > 0, "fresh character hp %d" % ps.hp)

	# Classic data tables (docs/CLASSIC_SYSTEMS.md)
	check(GameData.exp_next(1) == 9 and GameData.exp_next(10) == 320, "base exp table lv1=9 lv10=320 (%d, %d)" % [GameData.exp_next(1), GameData.exp_next(10)])
	check(GameData.element_pct("fire", "earth", 1) == 150.0, "fire vs earth1 = 150%")
	check(GameData.element_pct("neutral", "ghost", 1) == 25.0, "neutral vs ghost1 = 25%")
	check(GameData.size_pct("dagger", "large") == 50.0 and GameData.size_pct("sword", "medium") == 100.0, "size table dagger/sword")
	var hp1: int = ps.max_hp()
	check(hp1 >= 35 and hp1 <= 45, "novice lv1 maxhp ~40 (%d)" % hp1)
	var mon: Dictionary = GameData.monsters["pudding_slime"]
	check(mon.has("stats") and mon.has("element") and mon.has("size"), "monster data has stats/element/size")
	check(int(mon.get("hp", 0)) == 50 and int(mon.get("lv", 0)) == 1, "pudding slime: lv1, 50hp")

	# movement
	var p0: Vector3 = pl.global_position
	main.send({"t": "move", "to": p0 + Vector3(6, 0, 0)})
	await frames(main, 90)
	check(pl.global_position.distance_to(p0) > 4.0, "click-to-move walks (moved %.1f)" % pl.global_position.distance_to(p0))

	# equip
	check(ps.equip("dagger") and ps.equipped_id("weapon") == "dagger", "equip dagger")
	check(ps.weapon_atk() > 0, "weapon atk %d" % ps.weapon_atk())

	# fight a slime
	var slime = nearest(main, "pudding_slime")
	check(slime != null, "found a slime")
	if slime:
		main.teleport_player(slime.global_position + Vector3(1.2, 0, 0))
		# spend the 48 starting points like a player would (1-stat novices lose to a slime half the time)
		ps.stats["str"] = 12
		ps.stats["agi"] = 9
		ps.stats["dex"] = 9
		ps.stat_points = 20
		ps.hp = ps.max_hp()
		var exp0: int = ps.base_exp + ps.base_lv * 100000
		main.send({"t": "attack", "target": slime.id})
		var n := 0
		while is_instance_valid(slime) and not slime.dead and n < 900:
			await main.get_tree().physics_frame
			n += 1
			if pl.dead:
				break
		check(not is_instance_valid(slime) or slime.dead, "slime killed in %d frames" % n)
		check(ps.base_exp + ps.base_lv * 100000 > exp0, "gained base exp")
		await frames(main, 3)

	# job change
	check(ps.change_job("swordman") == false, "cannot change job at job lv %d" % ps.job_lv)
	ps.gain_exp(0, 100000)
	check(ps.job_lv == 10, "novice capped at job 10 (got %d)" % ps.job_lv)
	check(ps.change_job("swordman") and ps.job == "swordman", "became swordman")
	ps.add_item("iron_blade")
	ps.gain_exp(5000, 0)
	check(ps.equip("iron_blade"), "equip iron blade (base lv %d)" % ps.base_lv)
	ps.gain_exp(0, 20000)
	check(ps.skill_points >= 6, "skill points after job exp (%d)" % ps.skill_points)
	for i in 5:
		ps.learn_skill("bash")
	check(ps.skill_lv("bash") == 5, "bash lv5")
	check(ps.learn_skill("magnum_break"), "learn magnum break")
	check(ps.can_learn("magnum_break") != "" or ps.skill_points > 0, "can_learn reports state")

	# bash
	var bunny = nearest(main, "carrot_bunny")
	if bunny == null:
		bunny = nearest(main, "pudding_slime")
	check(bunny != null, "found a target for bash")
	if bunny:
		main.teleport_player(bunny.global_position + Vector3(1.0, 0, 0))
		main.send({"t": "attack", "target": bunny.id})
		pl.use_skill("bash")
		await frames(main, 20)
		check(pe.acd > 0.0, "bash went off, no mana (after-cast delay %.2fs)" % pe.acd)
		# magnum break
		await frames(main, 60)
		ps.hp = ps.max_hp()
		var hp0: int = ps.hp
		pe.cast_lock = 0.0
		pl.use_skill("magnum_break")
		check(ps.hp < hp0, "magnum break costs hp")

	# shop + items
	ps.zeny = 1000
	var c0: int = ps.count("red_tonic")
	check(ps.buy("red_tonic", 3) and ps.count("red_tonic") == c0 + 3 and ps.zeny == 850, "buy 3 red tonic")
	ps.hp = 1
	pl.use_item("red_tonic")
	check(ps.hp > 1 and ps.count("red_tonic") == c0 + 2, "use red tonic heals")
	ps.add_item("jelly", 10)
	var z0: int = ps.zeny
	check(ps.sell("jelly", 10) and ps.zeny == z0 + 30, "sell 10 jelly")

	# hotbar
	var slot: int = ps.assign_hotbar("skill:bash")
	check(slot >= 0 and ps.hotbar[slot] == "skill:bash", "bash on hotbar")

	# stats
	var sp_before: int = ps.stat_points
	var str0: int = ps.stats["str"]
	check(ps.raise_stat("str") and ps.stat_points < sp_before and ps.stats["str"] == str0 + 1, "raise STR")

	# death + respawn
	ps.hp = 1
	pe.die()
	check(pl.dead and main.hud.death_panel.visible, "death shows panel")
	pl.respawn()
	check(not pl.dead and ps.hp > 0 and pl.global_position.distance_to(ps.save_pos) < 1.0, "respawn at save point")

	# loot pickup
	main.drop_item("carrot", 2, pl.global_position + Vector3(2, 0, 0))
	await frames(main, 2)
	var loot = main.get_tree().get_nodes_in_group("loot")[-1]
	var cc: int = ps.count("carrot")
	pe.cast_lock = 0.0
	main.send({"t": "pickup", "id": loot.id})
	await frames(main, 120)
	check(ps.count("carrot") == cc + 2, "loot picked up")

	# UI windows render without errors
	for k in ["status", "items", "equip", "skills", "quests"]:
		main.hud.toggle(k)
	main.hud.open_npc(GameData.npcs[1])
	main.hud.open_npc(GameData.npcs[0])
	await frames(main, 5)
	check(main.hud.windows["shop"].visible and main.hud.windows["npc"].visible, "shop + npc windows open")

	# music: area keys + fallback + real file playback (test folder made by run_test.ps1)
	var mu = main.music
	check(WorldBuilder.music_area(WorldBuilder.TOWN_SPAWN) == "town", "music area: town")
	check(WorldBuilder.music_area(Vector3(48, 0, 0)) == "field_4", "music area: fiddler fields = field_4")
	check(mu.file_for("field_2").get_file() == "field.ogg", "field_2 falls back to field.ogg (%s)" % mu.file_for("field_2"))
	check(mu.file_for("boss") == "", "missing boss file = silence")
	main.teleport_player(WorldBuilder.TOWN_SPAWN)
	mu._check_t = 0.0
	await frames(main, 10)
	check(mu.current_key == "town" and mu._players[mu._active].playing, "town.mp3 playing in town")
	main.teleport_player(Vector3(14, 0, -28))
	mu._check_t = 0.0
	await frames(main, 10)
	check(mu.current_key == "field_2" and mu._players[mu._active].playing and mu.current_file.ends_with("field.ogg"), "field.ogg playing in Leafy Grove")

	# ---------------------------------------------------------------- P2a: first jobs
	var D := Vector3(25, 0, 0)
	var starters := {"mage": "rod", "archer": "bow", "acolyte": "mace", "thief": "cutter", "merchant": "axe"}
	for j in starters:
		var jok: bool = make_job(main, j)
		check(jok and ps.job == j and ps.count(starters[j]) == 1 and ps.equip(starters[j]), "novice -> %s, starter %s equipped" % [j, starters[j]])
		check(ps.job_lv == 50 and ps.skill_points == 49, "%s JL50 with 49 skill points (%d)" % [j, ps.skill_points])
	check(ps.can_change_job("mage") != "", "first job cannot change job again")

	# --- Mage: cast time, MATK, FLEE ignored, interruption
	make_job(main, "mage")
	ps.equip("rod")
	ps.stats["int"] = 60
	ps.stats["dex"] = 30
	for i in 3:
		ps.learn_skill("fire_bolt")
	check(ps.skill_lv("fire_bolt") == 3, "mage learns Fire Bolt 3")
	check(not ps.learn_skill("soul_strike"), "Soul Strike needs Napalm Beat 4")
	var mdum = dummy(main, "cricket_fiddler", D, {"stats": {"str": 1, "agi": 250, "vit": 0, "int": 0, "dex": 1, "luk": 1}, "mdef": 0, "element": "neutral", "element_lv": 1})
	check(80 + ps.hit() - PlayerState.mon_flee(mdum.data) < 5, "dummy FLEE %d: physical hits would be 5%%" % PlayerState.mon_flee(mdum.data))
	main.teleport_player(D + Vector3(6, 0, 0))
	main.send({"t": "attack", "target": mdum.id, "auto": false})
	pl.use_skill("fire_bolt")
	await frames(main, 3)
	var ct_expect: float = 2.1 * (1.0 - ps.stat("dex") / 150.0)
	check(pe.casting == "fire_bolt" and absf(pe.cast_total - ct_expect) < 0.01, "Fire Bolt 3 cast %.2fs = 2.1 x (1 - DEX/150) = %.2f" % [pe.cast_total, ct_expect])
	await main.get_tree().process_frame
	check(main.hud.cast_panel.visible, "cast bar visible while casting")
	var cn := 0
	while pe.casting != "" and cn < 400:
		await main.get_tree().physics_frame
		cn += 1
	var mdmg: int = 100000 - mdum.hp
	var lo: int = 3 * int(ps.matk_min() * ps.matk_rate()) - 3
	var hi: int = 3 * int(ps.matk_max() * ps.matk_rate()) + 3
	check(mdmg >= lo and mdmg <= hi, "Fire Bolt ignores FLEE and uses MATK: %d dmg in [%d, %d]" % [mdmg, lo, hi])
	check(pe.acd > 0.5 and pe.attack_target == null, "after-cast delay %.2fs, no auto-attack after a spell" % pe.acd)
	ready_to_act(pe)
	main.send({"t": "attack", "target": mdum.id, "auto": false})
	pl.use_skill("fire_bolt")
	await frames(main, 3)
	check(pe.casting == "fire_bolt", "second Fire Bolt casting")
	mdum.data["stats"]["dex"] = 999
	mdum.data["atk"] = [30, 30]
	ps.hp = ps.max_hp()
	var tries := 0
	while pe.casting != "" and tries < 10:
		pe.take_hit(mdum)
		tries += 1
	await frames(main, 2)
	await main.get_tree().process_frame
	check(pe.casting == "" and not main.hud.cast_panel.visible, "damage interrupts the cast, bar hidden (%d hit)" % tries)
	mdum.add_status("frozen", 3.0)
	var fd: Dictionary = mdum.combat_data()
	check(str(fd["element"]) == "water" and str(mdum.data["element"]) == "neutral", "frozen monster counts as Water 1")
	# every other mage spell once at Lv10 (smoke test: effects run without script errors)
	mdum.buffs.clear()
	var mspells := ["napalm_beat", "soul_strike", "cold_bolt", "lightning_bolt", "frost_diver", "fire_ball"]
	var mhp0: int = mdum.hp
	for id in mspells:
		ps.skills[id] = 10
		pe._execute(id, 10, mdum)
	check(mdum.hp < mhp0 - 6 * 10, "Napalm/Soul Strike/Cold+Lightning Bolt/Frost Diver/Fire Ball all hit (%d dmg)" % (mhp0 - mdum.hp))
	mdum.buffs.clear()
	mhp0 = mdum.hp
	pe._execute("thunderstorm", 10, mdum.global_position)
	check(mdum.hp < mhp0, "Thunderstorm hits the area (%d dmg)" % (mhp0 - mdum.hp))
	mhp0 = mdum.hp
	var wall_at: Vector3 = mdum.global_position
	pe._execute("fire_wall", 10, wall_at)
	await frames(main, 6)
	check(mdum.hp < mhp0 and mdum.global_position.distance_to(wall_at) > 1.0, "Fire Wall burns and knocks back (%d dmg, pushed %.1f m)" % [mhp0 - mdum.hp, mdum.global_position.distance_to(wall_at)])
	pe._execute("sight", 1, null)
	check(ps.buffs.has("sight"), "Sight buff")

	# --- Archer: ranged normal attacks use arrows, Double Strafe
	make_job(main, "archer")
	ps.equip("bow")
	ps.stats["dex"] = 50
	var dex0: int = ps.stat("dex")
	ps.learn_skill("owls_eye")
	check(ps.stat("dex") == dex0 + 1, "Owl's Eye DEX +1")
	var adum = dummy(main, "pudding_slime", D)
	main.teleport_player(D + Vector3(7, 0, 0))
	var arrows0: int = ps.count("arrow")
	main.send({"t": "attack", "target": adum.id})
	await frames(main, 150)
	var adist: float = pl._flat_dist(adum.global_position)
	check(ps.count("arrow") < arrows0, "bow attacks use arrows (%d -> %d)" % [arrows0, ps.count("arrow")])
	check(adum.hp < 100000, "arrows hit (%d dmg)" % (100000 - adum.hp))
	check(adist > 6.0, "archer shoots from %.1f m without walking in" % adist)
	main.send({"t": "untarget"})
	await frames(main, 30)
	for i in 5:
		ps.learn_skill("double_strafe")
	var ahp: int = adum.hp
	var aar: int = ps.count("arrow")
	ready_to_act(pe)
	main.send({"t": "attack", "target": adum.id, "auto": false})
	pl.use_skill("double_strafe")
	await frames(main, 40)
	check(ps.count("arrow") == aar - 1 and adum.hp < ahp, "Double Strafe: 1 arrow, %d dmg" % (ahp - adum.hp))
	ps.skills["arrow_shower"] = 10
	ps.skills["improve_concentration"] = 10
	ahp = adum.hp
	aar = ps.count("arrow")
	pe._execute("arrow_shower", 10, adum.global_position + Vector3(0.5, 0, 0))
	check(adum.hp < ahp and ps.count("arrow") == aar - 1, "Arrow Shower: area hit, 1 arrow (%d dmg)" % (ahp - adum.hp))
	var agi0: int = ps.stat("agi")
	pe._execute("improve_concentration", 10, null)
	check(ps.stat("agi") >= agi0 and ps.buffs.has("improve_concentration") and ps.stat("dex") > dex0 + 1, "Improve Concentration raises DEX (%d)" % ps.stat("dex"))

	# --- Acolyte: Blessing buff + expiry, Heal, Heal vs Undead, Decrease/Increase AGI
	make_job(main, "acolyte")
	ps.equip("mace")
	ps.stats["int"] = 40
	for i in 5:
		ps.learn_skill("divine_protection")
	for i in 3:
		ps.learn_skill("blessing")
	check(ps.skill_lv("blessing") == 3, "Blessing 3 learned after Divine Protection 5")
	var s_str: int = ps.stat("str")
	var s_int: int = ps.stat("int")
	var s_dex: int = ps.stat("dex")
	pl.use_skill("blessing")
	check(ps.stat("str") == s_str + 3 and ps.stat("int") == s_int + 3 and ps.stat("dex") == s_dex + 3, "Blessing 3: STR/INT/DEX +3")
	main.hud._update_buffs()
	check(main.hud.buff_row.get_child_count() >= 1 and str(main.hud.buff_row.get_child(0).text).begins_with("Blessing"), "buff shown in the HUD row")
	ps.buffs.list["blessing"]["t"] = 0.05
	await frames(main, 10)
	check(ps.stat("str") == s_str and not ps.buffs.has("blessing"), "Blessing wears off, STR back to %d" % s_str)
	for i in 3:
		ps.learn_skill("heal")
	ready_to_act(pe)
	ps.hp = 1
	pl.use_skill("heal")
	var heal_n: int = (ps.base_lv + ps.stat("int")) / 8 * 28
	check(ps.hp == mini(ps.max_hp(), 1 + heal_n), "Heal 3 restores (BaseLv+INT)/8*28 = %d" % heal_n)
	var udum = dummy(main, "pudding_slime", D, {"element": "undead", "element_lv": 1})
	main.teleport_player(D + Vector3(5, 0, 0))
	ready_to_act(pe)
	main.send({"t": "attack", "target": udum.id, "auto": false})
	pl.use_skill("heal")
	await frames(main, 10)
	var hexp: int = int(heal_n / 2 * 150 / 100.0)
	check(100000 - udum.hp == hexp, "Heal on Undead: holy damage %d (expect %d)" % [100000 - udum.hp, hexp])
	ps.learn_skill("increase_agi")
	ps.learn_skill("decrease_agi")
	ready_to_act(pe)
	main.send({"t": "attack", "target": udum.id, "auto": false})
	pl.use_skill("decrease_agi")
	await frames(main, 90)
	check(udum.has_status("decrease_agi") and PlayerState.mon_flee(udum.combat_data()) < PlayerState.mon_flee(udum.data), "Decrease AGI lowers monster FLEE")
	ready_to_act(pe)
	ps.hp = ps.max_hp()
	pl.use_skill("increase_agi")
	await frames(main, 90)
	check(ps.buffs.has("increase_agi") and absf(ps.move_speed() - 6.25) < 0.01, "Increase AGI: move speed x1.25")
	for id in ["angelus", "cure", "ruwach"]:
		ps.skills[id] = 1
		pe._execute(id, 1, null)
	check(ps.buffs.has("angelus") and ps.buffs.has("ruwach"), "Angelus / Cure / Ruwach cast")
	ps.skills["teleport"] = 1
	var tp0: Vector3 = pl.global_position
	pe._execute("teleport", 1, null)
	check(pl.global_position.distance_to(tp0) > 1.0, "Teleport moves you to a random field spot")

	# --- Thief: Envenom, Steal (once per monster), Hiding
	make_job(main, "thief")
	ps.equip("cutter")
	ps.stats["dex"] = 60
	var tdum = dummy(main, "pudding_slime", D)
	main.teleport_player(D + Vector3(1.2, 0, 0))
	for i in 3:
		ps.learn_skill("envenom")
	main.send({"t": "attack", "target": tdum.id, "auto": false})
	pl.use_skill("envenom")
	await frames(main, 10)
	check(tdum.hp < 100000, "Envenom: %d poison dmg" % (100000 - tdum.hp))
	for i in 10:
		ps.learn_skill("steal")
	# every item the slime can drop counts (its list grew: Red Tonic 2% - a steal can take that too)
	var steal_ids: Array = []
	for dr in tdum.data.get("drops", []):
		if not (str(dr["id"]) in steal_ids):
			steal_ids.append(str(dr["id"]))
	var steal_count := func() -> int:
		var n := 0
		for sid in steal_ids:
			n += ps.count(sid)
		return n
	var loot0: int = steal_count.call()
	var st_tries := 0
	while not tdum.stolen and st_tries < 40:
		ready_to_act(pe)
		main.send({"t": "attack", "target": tdum.id, "auto": false})
		pl.use_skill("steal")
		await frames(main, 3)
		st_tries += 1
	check(tdum.stolen and steal_count.call() == loot0 + 1, "Steal took an item (%d tries)" % st_tries)
	ready_to_act(pe)
	main.send({"t": "attack", "target": tdum.id, "auto": false})
	pl.use_skill("steal")
	await frames(main, 3)
	check(steal_count.call() == loot0 + 1, "only one Steal per monster")
	ps.learn_skill("hiding")
	tdum.aggro(pe)
	ready_to_act(pe)
	pl.use_skill("hiding")
	await frames(main, 5)
	check(ps.is_hidden() and ps.move_speed() == 0.0 and tdum.state != "chase", "Hiding: cannot move, monster loses you")
	ready_to_act(pe)
	pl.use_skill("hiding")
	check(not ps.is_hidden(), "Hiding again comes out")
	ps.skills["detoxify"] = 1
	ps.add_buff("poison", 10.0)
	pe._execute("detoxify", 1, null)
	check(not ps.buffs.has("poison"), "Detoxify cures poison")

	# --- Merchant: Mammonite, Discount, weight limit
	make_job(main, "merchant")
	ps.equip("axe")
	ps.stats["dex"] = 50
	var mcdum = dummy(main, "pudding_slime", D)
	main.teleport_player(D + Vector3(1.2, 0, 0))
	for i in 5:
		ps.learn_skill("mammonite")
	ps.zeny = 10000
	var mz: int = ps.zeny
	main.send({"t": "attack", "target": mcdum.id, "auto": false})
	pl.use_skill("mammonite")
	await frames(main, 10)
	check(ps.zeny == mz - 500 and mcdum.hp < 100000, "Mammonite 5: 500 zeny, %d dmg" % (100000 - mcdum.hp))
	for i in 3:
		ps.learn_skill("enlarge_weight")
	ps.learn_skill("discount")
	check(ps.buy_price("red_tonic") == 46, "Discount 1: Red Tonic 50z -> %dz" % ps.buy_price("red_tonic"))
	check(ps.max_weight() == 2800 + 30 * int(ps.stats["str"]) + 600, "MaxWeight = 2800 + 30*STR + 200*EWL (%d)" % ps.max_weight())
	ps.add_item("axe", (ps.max_weight() * 10 - ps.weight10()) / 800)
	ps.zeny = 100000
	var wz: int = ps.zeny
	var axes: int = ps.count("axe")
	check(not ps.buy("axe") and ps.zeny == wz and ps.count("axe") == axes, "weight limit blocks buying (Weight %d / %d)" % [ps.weight(), ps.max_weight()])
	await frames(main, 2)
	var whp: int = mcdum.hp
	ready_to_act(pe)
	main.send({"t": "attack", "target": mcdum.id})
	await frames(main, 150)
	# owner 2026-09-30: overweight still fights (20% slower, config overweight_speed_mult), it only stops looting
	check(ps.weight_pct() >= 90.0 and ps.overweight() and mcdum.hp < whp, "at %d%% weight you still attack (overweight)" % int(ps.weight_pct()))
	check(is_equal_approx(ps.ch.attack_delay() * ps.ch.overweight_mult(), maxf(0.0, ps.ch.attack_delay() * ps.ch.overweight_mult())) and ps.ch.overweight_mult() < 1.0,
		"overweight: attack delay / %.2f (%.2f s)" % [ps.ch.overweight_mult(), ps.attack_delay()])

	# save / load round trip
	var path := "user://selftest_save.json"
	var before := JSON.stringify(ps.to_dict())
	check(ps.save_game(path), "save file written")
	ps.new_character("Nobody")
	check(ps.load_game(path), "save file loaded")
	check(JSON.stringify(ps.to_dict()) == before, "save/load round trip identical")

	# CC0 environment models (res://assets/env) load through the GLTF loader + toon swap
	var env_missing := []
	for c in EnvAssets.CATEGORIES:
		for id in EnvAssets.ids(c):
			if not EnvAssets.has(id):
				env_missing.append(id)
	check(env_missing.is_empty(), "env assets: every catalog id has a GLB %s" % str(env_missing))
	for id in ["common_tree_1", "wall_plaster_straight", "barrel", "chicken"]:
		var e1 := EnvAssets.spawn(id)
		var e2 := EnvAssets.spawn_height(id, 2.0)
		var meshes: Array = e1.find_children("*", "MeshInstance3D", true, false) if e1 else []
		check(e1 != null and e2 != null and e1 != e2 and not meshes.is_empty() and EnvAssets.height(id) > 0.05,
			"env asset '%s' spawns twice (h=%.2f m, %d meshes)" % [id, EnvAssets.height(id), meshes.size()])
		if e1:
			e1.free()
		if e2:
			e2.free()
	var env_mon := EnvAssets.spawn("chicken")
	var env_ap: AnimationPlayer = EnvAssets.anim_player(env_mon) if env_mon else null
	check(env_ap != null and env_ap.get_animation_list().size() > 0, "env monster 'chicken' keeps its animations")
	if env_mon:
		env_mon.free()
	EnvAssets.clear_cache()

	await server_tests(main)
	await p2b(main)
	await hero_anim_test(main)
	await feel_test(main)
	fails.append_array(await SkillFxTest.run(main))
	await maps_test(main)
	await dungeon_test(main)
	await map5_test(main)
	await auto_test(main)
	await inventory_test(main)
	await load("res://scripts/jobs2_test.gd").run(main)
	await load("res://scripts/ui/ui_test.gd").run(main)
	await load("res://scripts/ui/move_test.gd").run(main)
	await load("res://scripts/weapon_skin_test.gd").run(main)
	fails.append_array(await load("res://scripts/maps_ui_test.gd").run(main))
	await load("res://scripts/comfort_test.gd").run(main)
	await load("res://scripts/mounts_test.gd").run(main)
	await load("res://scripts/combat_v2_test.gd").run(main)
	await load("res://scripts/job_slots_test.gd").run(main)
	await load("res://scripts/skills_v2_test.gd").run(main)
	await load("res://scripts/ui/v2_ui_test.gd").run(main)
	await load("res://scripts/v2fu_test.gd").run(main)
	await load("res://scripts/classes4_test.gd").run(main)
	await load("res://scripts/playtest_rules_test.gd").run(main)
	await load("res://scripts/awaken_test.gd").run(main)
	await load("res://scripts/asura_test.gd").run(main)
	await load("res://scripts/asura_gear_test.gd").run(main)  # asura-gear
	await load("res://scripts/pvp_test.gd").run(main)
	await load("res://scripts/layers_test.gd").run(main)
	await load("res://scripts/gap_test.gd").run(main)
	await load("res://scripts/towns4_test.gd").run(main)
	await load("res://scripts/capital_test.gd").run(main)
	await load("res://scripts/qa_sweep_test.gd").run(main)
	await load("res://scripts/capital_test.gd").run(main)
	await load("res://scripts/zone1_test.gd").run(main)
	await load("res://scripts/zone2_test.gd").run(main)
	await load("res://scripts/zone3_test.gd").run(main)
	await load("res://scripts/zone4_test.gd").run(main)
	await load("res://scripts/zone5_test.gd").run(main)
	await load("res://scripts/life/life_test.gd").run(main)
	await load("res://scripts/constellation_test.gd").run(main)
	await load("res://scripts/settings_test.gd").run(main, load("res://scripts/selftest.gd"))

	finish(main)


## N1 server tests (test_server.gd): combat.gd unit tests, entity registry.
static func server_tests(main: Node) -> void:
	# loaded at run time: test_server.gd preloads this script
	var ts = load("res://scripts/test_server.gd")
	check(ts != null and ts.can_instantiate(), "test_server.gd compiles")
	if ts == null or not ts.can_instantiate():
		return
	ts.run_units()
	ts.command_tests(main)
	await ts.headless_test(main)
	await frames(main, 1)
	await net_tests(main)


## N2 co-op tests (net/test_net.gd): accounts, snapshot packing, replica deltas, a headless server
## running two maps at once, parties, chat. The multi-process ENet test is --nettest (net_test.gd).
static func net_tests(main: Node) -> void:
	var tn = load("res://scripts/net/test_net.gd")
	check(tn != null and tn.can_instantiate(), "net/test_net.gd compiles")
	if tn == null or not tn.can_instantiate():
		return
	for f in ["res://scripts/net/net_server.gd", "res://scripts/net/remote_server.gd", "res://scripts/net/net_test.gd", "res://scripts/lobby.gd"]:
		var sc = load(f)
		check(sc != null and sc.can_instantiate(), "%s compiles" % f.get_file())
	tn.run_units()
	await tn.multimap_test(main)
	await frames(main, 1)
	await n3_tests(main)


## N3 tests (net/test_n3.gd): storage, accounts, migration, snapshot codec, nav grid, a headless
## server + net front (interest, anti-cheat, admin, unload delay). Multi-process: --n3test (n3_test.gd).
static func n3_tests(main: Node) -> void:
	var t3 = load("res://scripts/net/test_n3.gd")
	check(t3 != null and t3.can_instantiate(), "net/test_n3.gd compiles")
	if t3 == null or not t3.can_instantiate():
		return
	for f in ["res://scripts/server/dedicated.gd", "res://scripts/server/admin.gd", "res://scripts/net/n3_test.gd"]:
		var sc = load(f)
		check(sc != null and sc.can_instantiate(), "%s compiles" % f.get_file())
	t3.run_units()
	await t3.server_test(main)
	await frames(main, 1)
	await lobby_tests(main)


## Title / lobby UI (net/test_lobby.gd): screens, keyboard, errors, cards, delete dialog, shot screens.
static func lobby_tests(main: Node) -> void:
	var tl = load("res://scripts/net/test_lobby.gd")
	check(tl != null and tl.can_instantiate(), "net/test_lobby.gd compiles")
	if tl == null or not tl.can_instantiate():
		return
	await tl.run(main)
	await frames(main, 1)


static func finish(main: Node) -> void:
	print("SELFTEST ", "PASS" if fails.is_empty() else "FAIL")
	for f in fails:
		print("  - ", f)
	main.get_tree().quit(0 if fails.is_empty() else 1)


## Number of NPCs data/npcs.json puts on map `map_id` (no "map" key = the start map).
static func town_npcs(map_id: String) -> int:
	return GameData.npcs.filter(func(n): return str(n.get("map", GameData.start_map())) == map_id).size()


## Map 5: Hanamura Outskirts + Hanamura (scripts/map5_test.gd).
static func map5_test(main: Node) -> void:
	var t = load("res://scripts/map5_test.gd")
	check(t != null and t.can_instantiate(), "map5_test.gd compiles")
	if t == null or not t.can_instantiate():
		return
	await t.run(main, load("res://scripts/selftest.gd"))


static func find_npc(id: String) -> Dictionary:
	for n in GameData.npcs:
		if str(n.get("id", "")) == id:
			return n
	return {}


## Same seeded physical hit on `mon` (no crit): lets two runs differ only by the gear being tested.
static func seeded_hit(mon: Dictionary) -> int:
	seed(4242)
	return int(PlayerState.roll_player_attack(mon, {"can_crit": false})["dmg"])


# ---------------------------------------------------------------- P2b: cards, refining, quests, minimap, UI
static func p2b(main: Node) -> void:
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	var D := Vector3(25, 0, 0)

	# --- card drop through the real death handler (card_drop_rate raised for the test)
	make_job(main, "swordman")
	var dr := GameData.rate("drop_rate")
	check(is_equal_approx(main.drop_chance("slime_card", 0.01), 0.01 * dr * GameData.rate("card_drop_rate")) and is_equal_approx(main.drop_chance("jelly", 70.0), 70.0 * dr), "card drop chance = 0.01%% x drop_rate x card_drop_rate (drop_rate %.1f)" % dr)
	var cdr0: float = GameData.rate("card_drop_rate")
	GameData.config["card_drop_rate"] = 100000.0
	var cmon = dummy(main, "pudding_slime", D)
	cmon.hp = 1
	cmon.take_damage(5, pe)
	await frames(main, 2)
	var got_card := false
	for l in main.get_tree().get_nodes_in_group("loot"):
		if l.item_id == "slime_card":
			got_card = true
	GameData.config["card_drop_rate"] = cdr0
	check(got_card, "Pudding Slime Card drops when a Pudding Slime dies (rate x100000)")
	var no_card := []
	for mid in GameData.monsters:
		var has := false
		for d in GameData.monsters[mid].get("drops", []):
			if GameData.item_type(str(d["id"])) == "card" and is_equal_approx(float(d["chance"]), 0.01):
				has = true
		if not has:
			no_card.append(mid)
	check(no_card.is_empty(), "every monster (%d) has its own 0.01%% card %s" % [GameData.monsters.size(), str(no_card)])
	# maps 2-3 cards: effects parsed from classic card scripts
	var wolf_c: Dictionary = GameData.item("forest_wolf_card")
	check(str(wolf_c.get("loc", "")) == "weapon" and int(wolf_c.get("batk", 0)) == 15 and int(wolf_c.get("crit", 0)) == 1, "Forest Wolf Card = Wolf Card: weapon, ATK +15, CRIT +1")
	check(int(GameData.item("dune_condor_card").get("flee", 0)) == 10 and str(GameData.item("dune_condor_card")["loc"]) == "garment", "Dune Condor Card = Condor Card: garment FLEE +10")
	check(int(GameData.item("sand_scorpion_card").get("add_race", {}).get("plant", 0)) == 20, "Sand Scorpion Card: +20% vs Plant")
	ps.add_item("clip")
	var clip: Dictionary = ps.find_gear("clip")[0]
	ps.add_item("smoke_raccoon_card")
	check(ps.slot_count(clip) == 1 and ps.insert_card("smoke_raccoon_card", clip), "Smoke Raccoon Card goes into a Clip [1]")
	var hid0: int = int(ps.skills.get("hiding", 0))
	check(ps.skill_lv("hiding") == hid0, "no Hiding before wearing the card")
	var hp_clip0: int = ps.max_hp()
	ps.equip_inst(clip)
	check(ps.skill_lv("hiding") == maxi(1, hid0) and int(ps.skills.get("hiding", 0)) == hid0 and ps.hotbar.has("skill:hiding"), "worn Smokie card enables Hiding 1 (on the hotbar)")
	check(ps.max_hp() == hp_clip0 + 20, "Clip: MaxHP +20 (%d -> %d)" % [hp_clip0, ps.max_hp()])
	ps.unequip("accessory")
	check(ps.skill_lv("hiding") == hid0, "Hiding gone with the card")
	var muka: Dictionary = ps.make_inst("clip", 0, ["prickle_cactus_card"])
	ps.add_inst(muka)
	ps.stats["vit"] = 99
	var reg0: int = ps.hp_regen()
	ps.equip_inst(muka)
	# the Clip itself adds Max HP (the MaxHP / 200 part of the regen): check the +10% on the regen formula of now
	var reg_base: int = ps.stat("vit") / 5 + maxi(1, ps.max_hp() / 200)
	check(ps.hp_regen() == reg_base * 110 / 100 and ps.hp_regen() > reg0, "Prickle Cactus Card (Muka): HP recovery +10%% (%d -> %d)" % [reg0, ps.hp_regen()])
	ps.unequip("accessory")

	# --- card insert: location / free slot / not worn, stats change
	ps.add_item("slime_card")
	ps.add_item("leather_vest")
	var vest: Dictionary = ps.find_gear("leather_vest")[0]
	var dag: Dictionary = ps.find_gear("dagger")[0]
	check(ps.slot_count(vest) == 1 and ps.inst_name(vest) == "Leather Vest [1]", "Leather Vest has 1 slot (%s)" % ps.inst_name(vest))
	check(ps.can_insert("slime_card", dag) != "", "armor card does not fit a weapon")
	var luk0: int = ps.stat("luk")
	check(ps.insert_card("slime_card", vest) and vest["cards"] == ["slime_card"] and ps.count("slime_card") == 0, "Pudding Slime Card inserted into Leather Vest")
	ps.add_item("slime_card")
	check(ps.can_insert("slime_card", vest) == "No free slot.", "no second card in a 1-slot vest")
	check(ps.equip_inst(vest) and ps.stat("luk") == luk0 + 2 and ps.equip_bonus("perfect_dodge") == 1 and is_equal_approx(ps.perfect_dodge(), 1.0 + ps.stat("luk") / 10.0 + 1.0),
		"worn carded vest: LUK +2, Perfect Dodge +1 (LUK %d, PD %.1f)" % [ps.stat("luk"), ps.perfect_dodge()])
	ps.equip("dagger")
	ps.add_item("bunny_card")
	check(ps.can_insert("bunny_card", ps.equipment["weapon"]) == "Take the item off first.", "cannot insert into worn equipment")
	ps.add_item("caterpillar_card")
	ps.add_item("dagger")
	var dag2: Dictionary = ps.find_gear("dagger")[0]
	var hp0: int = ps.max_hp()
	ps.insert_card("caterpillar_card", dag2)
	ps.equip_inst(dag2)
	check(ps.max_hp() > hp0 + 100 and ps.equip_bonus("vit") == 1, "Leaf Caterpillar Card: VIT +1, MaxHP +100 (%d -> %d)" % [hp0, ps.max_hp()])

	# --- weapon card % bonus in the damage pipeline (4.6)
	var mon: Dictionary = GameData.monsters["cricket_fiddler"].duplicate(true)
	mon["stats"]["agi"] = 0
	mon["stats"]["luk"] = 0
	GameData.items["test_race_card"] = {"name": "Test Race Card", "type": "card", "loc": "weapon", "add_race": {"insect": 20}}
	GameData.items["test_ele_card"] = {"name": "Test Ele Card", "type": "card", "loc": "weapon", "add_ele": {"earth": 20}}
	ps.unequip("weapon")
	ps.add_item("cutter")
	var cut: Dictionary = ps.find_gear("cutter")[0]
	ps.equip_inst(cut)
	var base_dmg := seeded_hit(mon)
	cut["cards"] = ["test_race_card"]
	var race_dmg := seeded_hit(mon)
	cut["cards"] = ["test_race_card", "test_race_card"]
	var race2_dmg := seeded_hit(mon)
	cut["cards"] = ["test_race_card", "test_ele_card"]
	var mix_dmg := seeded_hit(mon)
	check(race_dmg >= int(base_dmg * 1.2) and race_dmg <= int((base_dmg + 1) * 1.2), "+20%% vs Insect card: %d -> %d" % [base_dmg, race_dmg])
	check(race2_dmg >= int(base_dmg * 1.4) and race2_dmg <= int((base_dmg + 1) * 1.4), "two +20%% race cards add up to +40%%: %d" % race2_dmg)
	check(mix_dmg >= int(base_dmg * 1.44) and mix_dmg <= int((base_dmg + 1) * 1.44) and is_equal_approx(ps.card_mult(mon), 1.44), "race x element cards multiply (x1.44): %d" % mix_dmg)
	cut["cards"] = ["bone_miner_card"]
	check(is_equal_approx(ps.card_mult(mon), 1.15) and ps.equip_bonus("batk") == 5, "Bone Miner Card: +15% vs Medium, ATK +5")
	cut["cards"] = []
	GameData.items.erase("test_race_card")
	GameData.items.erase("test_ele_card")
	# armor card: damage taken from Insects -30%
	var tf: Dictionary = ps.make_inst("hood")
	ps.add_inst(tf)
	ps.equip_inst(tf)
	check(ps.card_pct("sub_race", "insect") == 0, "no insect resist yet")
	ps.unequip("armor")
	var vest2: Dictionary = ps.find_gear("leather_vest")[0]
	vest2["cards"] = ["thorn_frog_card"]
	ps.equip_inst(vest2)
	check(ps.card_pct("sub_race", "insect") == 30, "Thorn Frog Card: -30% from Insects")

	# --- refine tables (spec 8)
	check(Refine.safe_limit("armor") == 4 and Refine.safe_limit("1") == 7 and Refine.safe_limit("2") == 6 and Refine.safe_limit("3") == 5 and Refine.safe_limit("4") == 4, "refine safe limits 4/7/6/5/4")
	check(Refine.rate("1", 7) == 60.0 and Refine.rate("2", 9) == 20.0 and Refine.rate("armor", 4) == 60.0 and Refine.rate("4", 9) == 10.0, "refine success rates table")
	check(Refine.weapon_atk(1, 7) == 14 and Refine.weapon_atk(2, 7) == 21 and Refine.weapon_atk(3, 5) == 25 and Refine.weapon_atk(4, 10) == 70, "refine ATK per level 2/3/5/7")
	check(Refine.over_upgrade(1, 7) == 0 and Refine.over_upgrade(1, 8) == 3 and Refine.over_upgrade(1, 10) == 9 and Refine.over_upgrade(2, 7) == 5 and Refine.over_upgrade(2, 10) == 20
		and Refine.over_upgrade(3, 6) == 8 and Refine.over_upgrade(3, 10) == 40 and Refine.over_upgrade(4, 5) == 13 and Refine.over_upgrade(4, 10) == 78, "over-upgrade bonus table")
	check(Refine.armor_def(1) == 1 and Refine.armor_def(2) == 1 and Refine.armor_def(4) == 3 and Refine.armor_def(7) == 5 and Refine.armor_def(10) == 7, "armor DEF 0.7 per refine, rounded")
	check(Refine.material("1") == "phracite" and Refine.fee("1") == 50 and Refine.material("armor") == "elunite" and Refine.fee("4") == 20000, "refine ores and fees")

	# --- refine success / fail / destroy with forced rolls
	make_job(main, "swordman")
	ps.zeny = 100000
	ps.add_item("phracite", 20)
	ps.add_item("iron_blade")
	var blade: Dictionary = ps.find_gear("iron_blade")[0]
	blade["cards"] = ["buzzfly_card"]
	ps.equip_inst(blade)
	var atk0: int = ps.atk_display()
	for i in 7:
		ps.refine_item(blade, 99.9)
	check(int(blade["refine"]) == 7 and ps.count("phracite") == 13 and ps.zeny == 100000 - 350, "+7 with 7 Phracite and 350z, never fails up to the safe limit")
	check(ps.refine_atk() == 14 and ps.atk_display() == atk0 + 14 and ps.over_upgrade() == 0, "+7 WLv1 weapon: ATK +14 (%d -> %d)" % [atk0, ps.atk_display()])
	check(ps.inst_name(blade) == "+7 Iron Blade [3]", "name shows refine and slots (%s)" % ps.inst_name(blade))
	check(ps.refine_is_risky(blade) and is_equal_approx(ps.refine_rate(blade), 60.0), "+8 is risky (60%)")
	check(ps.refine_item(blade, 10.0) == "success" and int(blade["refine"]) == 8 and ps.over_upgrade() == 3, "+8 success with a good roll, over-upgrade 1..3")
	# refine ATK is added after DEF: vs a high-DEF monster the +8 blade adds exactly 16 over +0
	var hard: Dictionary = GameData.monsters["cricket_fiddler"].duplicate(true)
	hard["def"] = 50
	hard["stats"]["agi"] = 0
	hard["stats"]["luk"] = 0
	var r8 := 0
	var r0 := 0
	blade["refine"] = 7
	r8 = seeded_hit(hard)
	blade["refine"] = 0
	r0 = seeded_hit(hard)
	blade["refine"] = 8
	check(r8 - r0 == 14, "refine ATK ignores DEF: +7 adds %d (expect 14)" % (r8 - r0))
	var ph0: int = ps.count("phracite")
	var bl0: int = ps.count("iron_blade")
	check(ps.refine_item(blade, 99.0) == "destroyed" and not ps.equipment.has("weapon") and ps.count("iron_blade") == bl0 and ps.count("phracite") == ph0 - 1,
		"failed +9 destroys the worn blade (and its card)")
	ps.add_item("cotton_shirt")
	var shirt: Dictionary = ps.find_gear("cotton_shirt")[0]
	ps.add_item("elunite", 5)
	ps.equip_inst(shirt)
	var def0: int = ps.hard_def()
	for i in 4:
		ps.refine_item(shirt, 99.9)
	check(int(shirt["refine"]) == 4 and ps.hard_def() == def0 + 3 and ps.count("elunite") == 1, "armor +4: DEF +3 (%d -> %d), 4 Elunite" % [def0, ps.hard_def()])
	ps.zeny = 10
	check(ps.refine_check(shirt) != "" and ps.refine_item(shirt, 0.0) == "" and int(shirt["refine"]) == 4, "no zeny = no refine")
	ps.zeny = 100000
	check(ps.refine_item(shirt, 59.0) == "success" and int(shirt["refine"]) == 5, "armor +5 at 60%: roll 59 succeeds")
	# purify rough ores (5 -> 1, no zeny)
	ps.add_item("rough_oridium", 7)
	ps.add_item("rough_elunite", 4)
	var ori0: int = ps.count("oridium")
	var elu0: int = ps.count("elunite")
	var zp: int = ps.zeny
	check(ps.purify("oridium") and ps.count("rough_oridium") == 2 and ps.count("oridium") == ori0 + 1 and ps.zeny == zp, "Purify: 5 Rough Oridium -> 1 Oridium, free")
	check(not ps.purify("oridium") and ps.count("oridium") == ori0 + 1, "Purify needs 5 rough ores (2 left)")
	check(ps.purify_check("elunite") != "" and not ps.is_junk("rough_elunite"), "4 Rough Elunite: cannot purify; not sold as loot")
	ps.add_item("rough_elunite", 1)
	check(ps.purify("elunite") and ps.count("elunite") == elu0 + 1 and ps.count("rough_elunite") == 0, "Purify: 5 Rough Elunite -> 1 Elunite")
	# refine window + confirm dialog
	var refiner := find_npc("refiner")
	check(not refiner.is_empty() and main.get_tree().get_nodes_in_group("npcs").any(func(n): return n.npc.get("id", "") == "refiner"), "Refiner NPC in town")
	var rp: Vector2 = Vector2(float(refiner["pos"][0]), float(refiner["pos"][1]))
	check(rp.distance_to(Vector2(-48, 0)) > 5.0, "Refiner is clear of the fountain (%.1f m)" % rp.distance_to(Vector2(-48, 0)))
	main.hud.open_refine(refiner)
	main.hud._fill_refine()
	await main.get_tree().process_frame
	check(main.hud.is_open("refine") and main.hud.bodies["refine"].get_child_count() >= 3, "refine window lists items (%d rows)" % main.hud.bodies["refine"].get_child_count())
	ps.add_item("rough_oridium", 3)
	var ori1: int = ps.count("oridium")
	main.hud._on_purify("oridium")
	main.hud._fill_refine()
	check(ps.count("oridium") == ori1 + 1 and main.hud.refine_msg.begins_with("Purified"), "Refine window: Purify button makes Oridium")
	main.hud._on_refine_pressed(shirt)
	check(main.hud.confirm_panel.visible and int(shirt["refine"]) == 5, "risky refine asks for confirmation first")
	main.hud.confirm_panel.visible = false

	# --- quests: accept -> progress -> complete (+ rewards)
	make_job(main, "swordman")
	ps.new_character("Quester")
	var ql = ps.quests
	check(ql.offers(ps, "guide") == ["slime_trouble"], "Guide Toto offers the first quest (%s)" % str(ql.offers(ps, "guide")))
	check(ql.can_accept(ps, "fluffy_tails") != "", "follow-up quest locked until the first is done")
	check(ql.accept(ps, "slime_trouble") and ql.is_active("slime_trouble"), "accept Slime Trouble")
	var qm = dummy(main, "pudding_slime", D)
	qm.hp = 1
	qm.take_damage(5, pe)
	await frames(main, 2)
	check(ql.progress(ps, "slime_trouble") == 1, "real kill counts for the quest (%d)" % ql.progress(ps, "slime_trouble"))
	for i in 6:
		ql.on_kill(ps, "pudding_slime")
	ql.on_kill(ps, "carrot_bunny")
	check(ql.progress(ps, "slime_trouble") == 5 and ql.is_complete(ps, "slime_trouble"), "5 / 5 Pudding Slimes (capped)")
	var z0: int = ps.zeny
	var t0: int = ps.count("red_tonic")
	var lv0: int = ps.base_lv
	var e0: int = ps.base_exp
	main.hud.open_npc(find_npc("guide"))
	main.hud._turn_in("slime_trouble")
	check(ql.is_done("slime_trouble") and ps.zeny == z0 + 100 and ps.count("red_tonic") == t0 + 5 and (ps.base_lv > lv0 or ps.base_exp > e0), "turn in: +100z, 5 Red Tonic, base exp (lv %d -> %d)" % [lv0, ps.base_lv])
	check(ql.accept(ps, "fluffy_tails") and not ql.is_complete(ps, "fluffy_tails"), "accept Fluffy Tails")
	ps.add_item("bunny_tail", 6)
	var fw0: int = ps.count("fly_wing")
	check(ql.complete(ps, "fluffy_tails") and ps.count("bunny_tail") == 1 and ps.count("fly_wing") == fw0 + 3, "collect quest takes 5 tails, gives 3 Fly Wings")
	check(ql.accept(ps, "letter_for_lina") and ps.count("toto_letter") == 1 and ql.turn_ins("healer") == ["letter_for_lina"], "delivery quest gives the letter, Sister Lina takes it")
	check(ql.complete(ps, "letter_for_lina") and ps.count("toto_letter") == 0, "letter delivered")
	ps.gain_exp(100, 0)
	check(ql.accept(ps, "explorer"), "Warden's explorer quest (Base Lv %d)" % ps.base_lv)
	for pl_def in GameData.quest("explorer")["places"]:
		ql.check_visits(ps, Vector3(float(pl_def[1]), 0, float(pl_def[2])))
	check(ql.is_complete(ps, "explorer"), "visit quest: 4 / 4 places")
	main.teleport_player(Vector3(-14, 0, 0))
	ql.active.erase("explorer")
	ql.accept(ps, "explorer")
	await main.get_tree().create_timer(0.6).timeout
	check(ql.progress(ps, "explorer") >= 1, "standing in Pudding Meadow marks it visited")
	check(ql.accept(ps, "choose_path") and ql.progress(ps, "choose_path") == 0, "job quest open while Novice")
	ps.gain_exp(0, 100000)
	ps.change_job("archer")
	check(ql.complete(ps, "choose_path"), "job quest done after the job change")
	ps.gain_exp(500, 0)
	ps.zeny = 10000
	ps.add_item("phracite", 1)
	check(ql.accept(ps, "first_refine"), "Refiner's quest")
	ps.refine_item(ps.find_gear("dagger")[0], 99.9)
	check(ql.is_complete(ps, "first_refine"), "refining counts for Tempered Steel")
	if not main.hud.is_open("quests"):
		main.hud.toggle("quests")
	main.hud._fill_quests()
	main.hud._fill_tracker()
	check(main.hud.tracker.visible and main.hud.bodies["quests"].get_child_count() > 3, "Quest window + tracker show active quests")
	check(main.hud.note_box.get_child_count() > 0, "loot / EXP notification strip shows entries")
	var starter_q: Array = GameData.quest_list.filter(func(q): return not q.has("map") and not q.get("v2", false))
	check(starter_q.size() >= 6 and starter_q.size() <= 10, "%d starter quests" % starter_q.size())

	# --- save / load round trip with a refined, carded item, buffs and quests
	ps.add_item("cutter")
	var c2: Dictionary = ps.find_gear("cutter")[0]
	c2["refine"] = 6
	c2["cards"] = ["bunny_card", "buzzfly_card"]
	ps.equip_inst(c2)
	ps.add_inst(ps.make_inst("bow", 3, ["caterpillar_card"]))
	ps.add_buff("blessing", 77.5, 4)
	var path := "user://selftest_save_p2b.json"
	var before := JSON.stringify(ps.to_dict())
	check(ps.save_game(path), "p2b save written")
	ps.new_character("Nobody")
	check(ps.load_game(path), "p2b save loaded")
	check(JSON.stringify(ps.to_dict()) == before, "save/load round trip identical (gear, cards, refine, buffs, quests)")
	var w: Dictionary = ps.equipment.get("weapon", {})
	check(ps.equipped_id("weapon") == "cutter" and int(w.get("refine", 0)) == 6 and w.get("cards", []) == ["bunny_card", "buzzfly_card"], "worn +6 Cutter keeps both cards")
	var bow3: Dictionary = {}
	for b in ps.find_gear("bow"):
		if int(b["refine"]) == 3:
			bow3 = b
	check(not bow3.is_empty() and bow3["cards"] == ["caterpillar_card"], "bag +3 bow keeps its card")
	check(ps.buffs.has("blessing") and ps.buffs.lv("blessing") == 4 and absf(ps.buffs.time_left("blessing") - 77.5) < 0.01, "Blessing restored with 77.5 s left")
	check(ps.quests.is_done("slime_trouble") and ps.quests.is_active("first_refine"), "quest log restored")
	# old (version 1) save: equipment ids counted in the bag, slot -> id
	ps.from_dict({"version": 1, "name": "Old", "job": "novice", "inventory": {"dagger": 2, "red_tonic": 3}, "equipment": {"weapon": "cutter", "armor": "cotton_shirt"}})
	check(ps.count("dagger") == 2 and ps.count("red_tonic") == 3 and ps.equipped_id("weapon") == "cutter" and ps.equipped_id("armor") == "cotton_shirt", "version 1 save migrates to item instances")

	# --- Heal: Shift heals yourself even with an Undead target
	make_job(main, "acolyte")
	ps.skills["heal"] = 3
	var ud = dummy(main, "pudding_slime", D, {"element": "undead", "element_lv": 1})
	main.send({"t": "attack", "target": ud.id})
	check(pe._target_kind("heal") == "enemy", "Heal with an Undead target attacks it")
	var sh := InputEventKey.new()
	sh.keycode = KEY_SHIFT
	sh.pressed = true
	Input.parse_input_event(sh)
	await main.get_tree().process_frame
	var kind_shift: String = pl._target_kind("heal")
	sh.pressed = false
	Input.parse_input_event(sh)
	check(kind_shift == "self" and pe.target_kind("heal", true) == "self", "Shift + Heal heals yourself (%s)" % kind_shift)
	main.send({"t": "untarget"})
	check(pe._target_kind("heal") == "self", "Heal without an Undead target heals yourself")

	# --- minimap
	var mm = main.hud.minimap
	check(mm != null and mm.is_inside_tree() and mm.visible and mm.size.x >= 150.0, "minimap on screen (%d px)" % int(mm.size.x if mm else 0))
	mm.set_big(true)
	mm.queue_redraw()
	await main.get_tree().process_frame
	check(mm.size.x >= 350.0, "Tab: big minimap")
	mm.set_big(false)
	check(Minimap.area_name(WorldBuilder.TOWN_SPAWN) == "Aldoria" and Minimap.area_name(Vector3(48, 0, 0)) == "Fiddler Fields", "minimap area names")
	check(main.hud.map_coords.text.begins_with("X "), "minimap coordinate label (%s)" % main.hud.map_coords.text)
	clear_mobs(main)


# ---------------------------------------------------------------- maps 2-3: map system, portals, CC0 monsters

## Waits (max ~10 s) until the world has finished travelling to `id`.
static func wait_map(main: Node, id: String) -> bool:
	for i in 600:
		if main.map_id == id and not main.traveling:
			return true
		await main.get_tree().process_frame
	return false


## Walks into portal #i of the current map (teleports onto its edge; the portal check does the rest).
static func take_portal(main: Node, i: int) -> String:
	var pt: Dictionary = WorldBuilder.portals()[i]
	main.me().portal_cd = 0.0
	main.teleport_player(pt["at"] + Vector3(0.6, 0, 0))
	await wait_map(main, str(pt["to_map"]))
	return str(pt["to_map"])


static func mob_ids(main: Node) -> Dictionary:
	var ids := {}
	for m in main.get_tree().get_nodes_in_group("monsters"):
		ids[m.mid] = true
	return ids


static func zone_ids() -> Dictionary:
	var ids := {}
	for z in WorldBuilder.zones():
		for e in z["mobs"]:
			ids[str(e["id"])] = true
	return ids


static func maps_test(main: Node) -> void:
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	var start: String = GameData.start_map()
	print("SELFTEST maps")
	make_job(main, "swordman")
	pe.dead = false
	pl.visible = true
	ps.hp = ps.max_hp()
	check(GameData.map_list.size() >= 4 and start == "aldoria_meadows" and str(GameData.map_list[3]["id"]) == "hollowroot_caverns", "maps.json: %d maps, first = Aldoria Meadows, 4th = Hollowroot Caverns" % GameData.map_list.size())

	# --- map 1 still builds exactly like the old hard-coded Aldoria Meadows
	main.load_map(start)
	await frames(main, 2)
	var tree = main.get_tree()
	var named := 0
	for z in WorldBuilder.zones():
		if str(z["name"]) != "":
			named += 1
	check(WorldBuilder.zones().size() == 5 and named == 4, "map 1: 5 zones (4 named + Pudding King)")
	check(tree.get_nodes_in_group("npcs").size() == town_npcs(start), "map 1: %d npcs" % town_npcs(start))
	var n1: int = tree.get_nodes_in_group("monsters").size()
	check(n1 == 61, "map 1: 61 monsters spawned (%d)" % n1)
	check(main.last_build.is_empty(), "map 1 reload reuses the cached scenery")
	var probe := Node3D.new()
	var nat: Dictionary = WorldBuilder._placements(probe)
	var ts := Vector3.ZERO
	for tr in nat["trees"]:
		ts += tr["pos"] * float(tr["h"])
	var rs := Vector3.ZERO
	for rk in nat["rocks"]:
		rs += rk["pos"] * float(rk["s"])
	probe.free()
	# numbers printed by the pre-refactor world_builder.gd (same seed / RNG sequence)
	check(nat["trees"].size() == 230 and nat["rocks"].size() == 52 and absf(ts.x - 1484.9741) < 0.01 and absf(ts.z - 160.2737) < 0.01
		and absf(rs.x - 38.4492) < 0.01 and absf(rs.z - 87.2840) < 0.01, "map 1 trees/rocks identical to the old layout (%d/%d, %.3f,%.3f)" % [nat["trees"].size(), nat["rocks"].size(), ts.x, ts.z])
	check(WorldBuilder.in_town(WorldBuilder.TOWN_SPAWN) and WorldBuilder.music_area(Vector3(48, 0, 0)) == "field_4", "map 1: town + field_4 music area")
	check(WorldBuilder.portals().size() == 5 and WorldBuilder.portals()[0]["to_map"] == "whisperwood_forest", "map 1: portal 0 to Whisperwood Forest (+3 early-town fields, +1 Crownhaven)")
	check(tree.get_nodes_in_group("portals").size() == WorldBuilder.portals().size(), "map 1: portal visuals in the world")
	check(GameData.monsters["buzzfly"]["model"] == "bee", "Buzzfly uses the CC0 bee model")

	# --- walk into the east portal: map 1 -> map 2
	var to2: String = await take_portal(main, 0)
	check(main.map_id == "whisperwood_forest" and to2 == main.map_id, "portal: Aldoria Meadows -> Whisperwood Forest")
	check(pl.global_position.distance_to(Vector3(-72, 0, 0)) < 1.0, "arrived at the west warp of the forest (%s)" % str(pl.global_position))
	check(ps.map_id == "whisperwood_forest", "PlayerState knows the map")
	await frames(main, 2)
	var ids2 := mob_ids(main)
	check(tree.get_nodes_in_group("monsters").size() == 54 and ids2.keys().all(func(k): return zone_ids().has(k)), "map 2: 54 forest monsters (%d)" % tree.get_nodes_in_group("monsters").size())
	check(not ids2.has("pudding_slime") and ids2.has("spore_cap") and ids2.has("elder_willow"), "map 2: monster set changed")
	check(tree.get_nodes_in_group("npcs").is_empty(), "map 2: no town NPCs")
	check(not main.last_build.is_empty() and int(main.last_build["trees"]) > 300, "map 2: forest built (%s trees)" % str(main.last_build.get("trees", 0)))
	check(WorldBuilder.map_half() == 80.0 and not WorldBuilder.has_town(), "map 2: 160 m, no town")
	check(WorldBuilder.music_area(Vector3(-12, 0, 30)) == "forest_2" and WorldBuilder.music_area(Vector3(58, 0, -42)) == "forest_4", "map 2 music areas forest_2 / forest_4")
	check(main.music.fallbacks("forest_2") == ["forest_2", "forest", "field"] and main.music.file_for("forest_3").get_file() == "field.ogg", "forest_N -> forest -> field.ogg")
	check(main.music.fallbacks("desert_1") == ["desert_1", "desert", "field"], "desert_N -> desert -> field")
	check(main.hud.minimap != null and WorldBuilder.portals().size() == 4 and WorldBuilder.portals()[2]["to_map"] == "hollowroot_caverns", "map 2: four portals (Aldoria, Sunscorch, Hollowroot Caverns, Hanamura Outskirts)")
	# CC0 animated monster
	var sc = main.view(nearest(main, "spore_cap").id) if nearest(main, "spore_cap") else null
	check(sc != null and sc.anim != null and sc.clips.has("idle") and sc.clips.has("walk") and sc.clips.has("attack") and sc.clips.has("hit") and sc.clips.has("death"),
		"Spore Cap = CC0 mushroom with idle/walk/attack/hit/death clips %s" % str(sc.clips if sc else {}))
	check(sc != null and sc.anim.is_playing(), "CC0 monster animates")
	var elder_e = nearest(main, "elder_willow")
	var elder = main.view(elder_e.id) if elder_e else null
	check(elder != null and elder.is_mini and not elder.is_boss and elder.bar.visible and elder.name_label.modulate.g < 0.9 and absf(elder._model_h() - 1.3 * 1.6) < 0.01,
		"Elder Willow: mini-boss (gold name, bar shown, x1.6)")
	check(float(GameData.monsters["elder_willow"]["respawn"]) == 60.0 and elder_e.add_status("frozen", 0.1), "mini-boss: 60 s respawn, not status immune")
	var wolf = main.view(nearest(main, "forest_wolf").id) if nearest(main, "forest_wolf") else null
	check(wolf != null and wolf.model_kind() == "tripo:forest_wolf" and wolf._static, "Forest Wolf: static Tripo model")
	# hit flash + freeze on a GLB monster, then kill it for loot
	var gm = main._spawn("spore_cap", pl.global_position + Vector3(1.2, 0, 0), 0.0, false)
	await frames(main, 2)
	var gmv = main.view(gm.id)
	var mi0: MeshInstance3D = gmv._meshes[0]
	var c0: Color = gmv._get_color(mi0)
	gm.take_damage(1, pe)
	var c1: Color = gmv._get_color(mi0)
	await main.get_tree().create_timer(0.25).timeout
	check(c1 != c0 and gmv._get_color(mi0) == c0, "GLB monster: hit flash then colour restored")
	check(gm.add_status("frozen", 0.3) and gmv._get_color(mi0).r < c0.r and gmv.anim.speed_scale == 0.0, "GLB monster: frozen = icy tint + animation paused")
	await main.get_tree().create_timer(0.5).timeout
	check(gmv._get_color(mi0) == c0 and gmv.anim.speed_scale == 1.0, "GLB monster: thaws")
	var dr0: float = GameData.rate("drop_rate")
	GameData.config["drop_rate"] = 100000.0
	var loot0: int = tree.get_nodes_in_group("loot").size()
	var e0: int = ps.base_exp + ps.base_lv * 100000
	gm.hp = 1
	gm.take_damage(5, pe)
	await frames(main, 2)
	GameData.config["drop_rate"] = dr0
	var got_spore := false
	for l in tree.get_nodes_in_group("loot"):
		if l.item_id == "cap_spore":
			got_spore = true
	check(gm.dead and tree.get_nodes_in_group("loot").size() > loot0 and got_spore, "killed a CC0 Spore Cap: it drops Cap Spores")
	check(ps.base_exp + ps.base_lv * 100000 > e0, "CC0 monster gives exp")
	check(gmv.clips.has("death") and gmv.anim.current_animation == gmv.clips["death"], "death animation plays")
	await main.get_tree().create_timer(2.0).timeout
	check(not is_instance_valid(gmv) and not is_instance_valid(gm), "dead GLB monster is removed (view + server body)")
	# visit quest places belong to their map
	ps.gain_exp(3000, 0)
	var ql = ps.quests
	ql.active.erase("explorer")
	check(ql.accept(ps, "explorer") and ql.accept(ps, "whisperwood_scout"), "accept map-1 and map-2 visit quests (Base Lv %d)" % ps.base_lv)
	ql.check_visits(ps, Vector3(-14, 0, 0))
	ql.check_visits(ps, Vector3(-12, 0, 30))
	check(ql.progress(ps, "explorer") == 0 and ql.progress(ps, "whisperwood_scout") == 1, "visits count only on the quest's own map")

	# --- map 2 -> map 3 (east portal)
	var to3: String = await take_portal(main, 1)
	check(main.map_id == "sunscorch_dunes" and to3 == main.map_id, "portal: Whisperwood Forest -> Sunscorch Dunes")
	await frames(main, 2)
	var ids3 := mob_ids(main)
	check(tree.get_nodes_in_group("monsters").size() == 60 and ids3.has("sand_scorpion") and not ids3.has("spore_cap"), "map 3: 60 desert monsters, new set (%d)" % tree.get_nodes_in_group("monsters").size())
	check(WorldBuilder.map_half() == 85.0 and WorldBuilder.music_area(Vector3(-52, 0, 22)) == "desert_1" and WorldBuilder.music_area(Vector3(74, 0, 2)) == "desert_4", "map 3: 170 m, desert music areas")
	var warns: Array = tree.get_nodes_in_group("warning_signs")
	check(warns.size() == 1 and str(warns[0].get_node("WarningLabel").text).contains("Lv 25+") and warns[0].global_position.distance_to(Vector3(40, 0, -32)) < 18.0,
		"Stinger Wastes has a 'Danger: Lv 25+' warning sign")
	var scorp = main.view(nearest(main, "sand_scorpion").id) if nearest(main, "sand_scorpion") else null
	check(scorp != null and scorp.model_kind() == "tripo:sand_scorpion" and scorp._static, "Sand Scorpion: static Tripo model")
	var warden = main.view(nearest(main, "dune_warden").id) if nearest(main, "dune_warden") else null
	check(warden != null and warden.is_mini and warden.model_kind() == "cyclops" and absf(warden.scl - 1.4) < 0.01, "Dune Warden: mini-boss x1.4 (cyclops stand-in)")
	var pup = main.view(nearest(main, "desert_pup").id) if nearest(main, "desert_pup") else null
	check(pup != null and pup.model_kind() == "tripo:desert_fox_wolf" and absf(pup.scl - 0.7) < 0.01, "Dune Pup: Tripo fox-wolf x0.7")
	# Fly Wing stays on this map
	ps.add_item("fly_wing", 2)
	pl.use_item("fly_wing")
	await frames(main, 2)
	var fp: Vector3 = pl.global_position
	check(main.map_id == "sunscorch_dunes" and absf(fp.x) < 85.0 and absf(fp.z) < 85.0 and fp.distance_to(Vector3(-76, 0, 0)) > 0.5, "Fly Wing: random spot on the current map")
	# save / load keeps the map
	var spath := "user://selftest_save_maps.json"
	check(ps.save_game(spath), "save on map 3")
	var sd = JSON.parse_string(FileAccess.get_file_as_string(spath))
	check(int(sd["version"]) >= 3 and str(sd["map"]) == "sunscorch_dunes" and str(sd["save_map"]) == start, "save v3+ stores map + save map")
	ps.map_id = start
	check(ps.load_game(spath) and ps.map_id == "sunscorch_dunes", "load restores the map")
	var old: Dictionary = sd.duplicate(true)
	old["version"] = 2
	old.erase("map")
	old.erase("save_map")
	ps.from_dict(old)
	check(ps.map_id == start and ps.save_map == start, "version 2 save -> map 1")
	ps.from_dict(sd)
	# gnarled branch summons a monster of this map, ember scroll casts Fire Bolt
	var before_n: int = tree.get_nodes_in_group("monsters").size()
	ps.add_item("gnarled_branch", 1)
	pl.use_item("gnarled_branch")
	await frames(main, 2)
	check(tree.get_nodes_in_group("monsters").size() == before_n + 1 and ps.count("gnarled_branch") == 0, "Gnarled Branch summons a monster")
	var dm = dummy(main, "sand_worm", pl.global_position + Vector3(2.0, 0, 0))
	main.send({"t": "attack", "target": dm.id})
	ps.add_item("ember_scroll", 1)
	pl.use_item("ember_scroll")
	await frames(main, 3)
	check(dm.hp < 100000 and ps.count("ember_scroll") == 0, "Ember Scroll: casts Fire Bolt (%d dmg)" % (100000 - dm.hp))
	main.send({"t": "untarget"})
	ps.add_item("mint_herb", 1)
	ps.add_buff("poison", 10.0)
	pl.use_item("mint_herb")
	check(not ps.buffs.has("poison"), "Mint Herb cures poison")
	# minimap + title on a map without town
	check(Minimap.area_name(Vector3(0, 0, 6)) == "Cactus Garden" and Minimap.area_name(Vector3(-80, 0, 70)) == "Sunscorch Dunes", "map 3 area names")

	# --- Return Wing from map 3 -> save point in Aldoria
	ps.save_map = start
	ps.save_pos = WorldBuilder.TOWN_SPAWN
	ps.add_item("return_wing", 1)
	pl.use_item("return_wing")
	check(await wait_map(main, start), "Return Wing: back on map 1")
	check(pl.global_position.distance_to(WorldBuilder.TOWN_SPAWN) < 1.0 and WorldBuilder.in_town(pl.global_position), "Return Wing: at the town save point")
	check(tree.get_nodes_in_group("npcs").size() == town_npcs(start) and main.last_build.is_empty(), "map 1 NPCs back, scenery from cache")

	# --- full round trip by portals: 1 -> 2 -> 3 -> 2 -> 1
	var route := []
	route.append(await take_portal(main, 0))
	route.append(await take_portal(main, 1))
	route.append(await take_portal(main, 0))
	route.append(await take_portal(main, 0))
	check(route == ["whisperwood_forest", "sunscorch_dunes", "whisperwood_forest", "aldoria_meadows"], "portal round trip 1-2-3-2-1 %s" % str(route))
	check(pl.global_position.distance_to(Vector3(62, 0, 18)) < 1.0 and tree.get_nodes_in_group("monsters").size() == 61, "back at the Aldoria east warp with 61 monsters")
	# dying on map 2 respawns at the save point on map 1
	await take_portal(main, 0)
	ps.hp = 1
	pe.die()
	pl.respawn()
	check(await wait_map(main, start) and pl.global_position.distance_to(WorldBuilder.TOWN_SPAWN) < 1.0, "death on map 2 -> respawn in Aldoria")
	check(GameData.quest_list.filter(func(q): return str(q.get("map", "")) in ["whisperwood_forest", "sunscorch_dunes"]).size() == 5, "5 quests for maps 2-3")
	ql.active.erase("explorer")
	ql.active.erase("whisperwood_scout")


# ---------------------------------------------------------------- rigged hero: optional clips + fallbacks

## cast / shoot / sit / death / cheer are used when anim_<name>.glb exists, otherwise the old
## behaviour (idle, slash, tip over) runs. Checks whichever path the files present today take.
static func hero_anim_test(main: Node) -> void:
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	make_job(main, "archer")
	await frames(main, 2)
	if not ModelLoader.has_model("hero_novice"):
		check(pl.anim == null, "no rigged hero files: procedural chibi")
		return
	check(pl.anim != null and pl.has_clip("idle") and pl.has_clip("walk") and pl.has_clip("slash"), "rigged hero loaded with idle/walk/slash")
	var present := []
	for n in ModelLoader.ANIMS:
		if pl.has_clip(n):
			present.append(n)
	print("  hero clips present: ", present)
	var ap: AnimationPlayer = pl.anim
	pl._clear_goals()
	pl._anim_lock = 0.0
	pl.sitting = true
	pl._animate_rigged(0.016, false)
	check(ap.assigned_animation == ("sit" if pl.has_clip("sit") else "idle"), "sitting -> %s" % ap.assigned_animation)
	pl.sitting = false
	pl.casting = "fire_bolt"
	pl._animate_rigged(0.016, false)
	check(ap.assigned_animation == ("cast" if pl.has_clip("cast") else "idle"), "casting -> %s" % ap.assigned_animation)
	pl.casting = ""
	ps.equip("bow")
	pl.swing = 1.0
	pl._animate_rigged(0.016, false)
	check(ap.assigned_animation == ("shoot" if pl.has_clip("shoot") else "slash"), "bow attack -> %s" % ap.assigned_animation)
	pl._anim_lock = 0.0
	pl._animate_rigged(0.016, true)
	check(ap.assigned_animation == ("run" if pl.has_clip("run") else "walk"), "walking -> %s" % ap.assigned_animation)
	pl._on_leveled("base")
	check((ap.assigned_animation == "cheer") == pl.has_clip("cheer") and (pl._anim_lock > 0.0) == pl.has_clip("cheer"), "level up -> %s" % ap.assigned_animation)
	pl._anim_lock = 0.0
	main.teleport_player(WorldBuilder.TOWN_SPAWN + Vector3(3, 0, 0))
	ps.hp = 1
	pe.die()
	await frames(main, 40)
	if pl.has_clip("death"):
		check(ap.assigned_animation == "death" and is_zero_approx(pl.model.rotation.x), "death clip plays (no tipping over)")
	else:
		check(pl.model.rotation.x < -1.0, "no death clip: hero tips over (%.2f)" % pl.model.rotation.x)
	pl.respawn()
	await frames(main, 3)
	check(not pl.dead and is_zero_approx(pl.model.rotation.x) and ap.assigned_animation == "idle", "respawn: upright, idle")


# ---------------------------------------------------------------- melee game feel (hit_feel.gd)

## Impact delay maths (fraction of the clip, shortened with ASPD), the measured slash timing, a real
## basic attack whose hit shows at the blade's impact frame (not at the swing) with hit-stop, white
## flash, knock-back (view only) and squash, the weapon trail made / drawn / freed, crit shake and its
## "screen_shake" setting in user://settings.json.
static func feel_test(main: Node) -> void:
	var HitFeel = load("res://scripts/hit_feel.gd")
	var feel = main.feel
	check(feel != null, "hit feel node exists")
	if feel == null:
		return
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	# 1. timing maths
	var bad := ""
	for i in 19:
		var ad := 0.2 + 0.1 * i
		var d: float = HitFeel.impact_delay(HitFeel.DEFAULT_IMPACT, HitFeel.clip_time(ad), ad)
		if d <= 0.0 or d > ad * HitFeel.MAX_OF_DELAY + 0.0001 or d > HitFeel.MAX_DELAY + 0.0001:
			bad += " %.1f->%.3f" % [ad, d]
	check(bad == "", "impact delay > 0, <= %d%% of the attack delay, <= %.2f s (attack delays 0.2..2.0 s)%s" % [int(HitFeel.MAX_OF_DELAY * 100), HitFeel.MAX_DELAY, bad])
	var d_slow: float = HitFeel.impact_delay(0.45, HitFeel.clip_time(1.2), 1.2)
	var d_fast: float = HitFeel.impact_delay(0.45, HitFeel.clip_time(0.25), 0.25)
	check(absf(d_slow - 0.405) < 0.001 and absf(d_fast - 0.1125) < 0.001,
		"impact at 45%% of the slash: %.3f s (1.2 s delay), %.3f s (0.25 s delay, fast ASPD shortens it)" % [d_slow, d_fast])
	check(HitFeel.HITSTOP >= 0.04 and HitFeel.HITSTOP <= 0.08 and HitFeel.HITSTOP_CRIT > HitFeel.HITSTOP and HitFeel.HITSTOP_CRIT <= 0.12,
		"hit-stop %d ms, crit %d ms (40-80 / <= 120 ms)" % [int(HitFeel.HITSTOP * 1000), int(HitFeel.HITSTOP_CRIT * 1000)])
	# measured slash timing of every hero model (printed for tuning IMPACT_FRAC)
	var fr := []
	for job in ["novice", "swordman", "thief", "merchant", "acolyte", "mage", "archer"]:
		make_job(main, job)
		await frames(main, 2)
		if pl.anim:
			fr.append("%s %d%%" % [pl._hero_model_id(), int(pl.impact_frac * 100)])
			check(pl._track.has("xf") and (pl._track["xf"] as Array).size() == HitFeel.TRACK_STEPS + 1,
				"%s: slash clip sampled for the trail" % pl._hero_model_id())
	print("  slash impact per hero: ", ", ".join(fr))
	# 2. a real swing: swordman + blade next to a still dummy
	make_job(main, "swordman")
	ps.add_item("iron_blade")
	check(ps.equip("iron_blade"), "feel: equip iron blade")
	ps.hp = ps.max_hp()
	await frames(main, 3)
	if pl.anim:
		check(pl.impact_frac >= HitFeel.IMPACT_MIN and pl.impact_frac <= HitFeel.IMPACT_MAX,
			"slash impact measured at %d%% of the clip (%s)" % [int(pl.impact_frac * 100), pl._hero_model_id()])
	check(pl.has_trail() and pl._trail.get_parent() == pl, "sword: weapon trail created on the hero")
	var m = dummy(main, "pudding_slime", pl.global_position + Vector3(1.2, 0, 0))
	await frames(main, 3)
	var mv = main.view(m.id)
	var m_pos: Vector3 = m.global_position
	var snap := {}
	var on_imp := func(e: Dictionary) -> void:
		snap["ms"] = Time.get_ticks_msec()
		snap["pl_stop"] = pl._stop_t
		snap["pl_speed"] = pl.anim.speed_scale if pl.anim else 0.0
		snap["mv_stop"] = mv._stop_t if is_instance_valid(mv) else -1.0
		snap["white"] = is_instance_valid(mv) and not mv._meshes.is_empty() and mv._meshes[0].material_overlay != null
		snap["squash"] = mv._squash if is_instance_valid(mv) else 0.0
		snap["miss"] = bool(e.get("miss", false))
	feel.impacted.connect(on_imp)
	ps.stats["dex"] = 99
	ready_to_act(pe)
	pe.atk_cd = 0.0
	main.send({"t": "attack", "target": m.id})
	var swing_ms := 0
	var shown_early := false
	var pts := 0
	for i in 120:
		await main.get_tree().physics_frame
		if swing_ms == 0 and feel._hold.has(m.id):
			swing_ms = Time.get_ticks_msec()
			shown_early = snap.has("ms")
		if pl.has_trail() and pl._trail.visible and pl._trail_im.get_surface_count() > 0:
			pts += 1
		if snap.has("ms"):
			break
	feel.impacted.disconnect(on_imp)
	# one swing is enough (a second one would kick the monster again)
	main.send({"t": "stop"})
	var delay: float = feel.last_delay
	check(swing_ms > 0 and not shown_early and snap.has("ms"), "melee hit is held back at the swing and shown later (delay %.3f s)" % delay)
	if snap.has("ms"):
		# from the moment the hit event arrived (not when this loop noticed it)
		var late: int = int(snap["ms"]) - int(feel.last_at)
		check(late >= int(delay * 1000.0) - 5 and late <= int(delay * 1000.0) + 80, "hit shown at the impact frame: %d ms after the swing (expected %d)" % [late, int(delay * 1000.0)])
		check(delay <= pe.ch.attack_delay() * HitFeel.MAX_OF_DELAY + 0.001, "impact delay %.3f <= %d%% of the attack delay %.2f" % [delay, int(HitFeel.MAX_OF_DELAY * 100), pe.ch.attack_delay()])
		if not bool(snap["miss"]):
			check(float(snap["pl_stop"]) > 0.0 and float(snap["mv_stop"]) > 0.0 and (pl.anim == null or float(snap["pl_speed"]) == 0.0),
				"hit-stop: hero + monster frozen at impact (%.3f / %.3f s)" % [float(snap["pl_stop"]), float(snap["mv_stop"])])
			check(bool(snap["white"]) and float(snap["squash"]) > 0.0, "monster: white flash + slime squash at impact")
			await frames(main, 4)
			var kick: float = mv.model.position.length() if is_instance_valid(mv) else 0.0
			check(kick > 0.05 and kick < 0.35 and m.global_position.distance_to(m_pos) < 0.001,
				"knock-back %.2f m on the view only (server position unchanged)" % kick)
			await main.get_tree().create_timer(0.45).timeout
			check(is_instance_valid(mv) and mv.model.position.length() < 0.01 and mv._stop_t <= 0.0 and pl._stop_t <= 0.0 and (pl.anim == null or pl.anim.speed_scale == 1.0),
				"after impact: monster back in place, hit-stop over")
	check(pts >= 2, "weapon trail drawn during the swing (%d frames)" % pts)
	await frames(main, 10)
	check(pl._trail_pts.size() == 0 and not pl._trail.visible, "weapon trail fades out after the swing")
	# attack variety: consecutive swings alternate (full slash turning one way / snappier one the other)
	var d1: float = pl.on_swing(1.0)
	var v1: Array = [pl._swing_skip, signf(pl._twist_amp), pl._atk_clip]
	var d2: float = pl.on_swing(1.0)
	var v2: Array = [pl._swing_skip, signf(pl._twist_amp), pl._atk_clip]
	# a combo model (animation pilot) varies by clip (slash -> atk2 -> atk3), the others by skip
	var varied: bool = v1[2] != v2[2] if not pl._combo.is_empty() else v1[0] != v2[0]
	check(d1 > 0.0 and d2 > 0.0 and varied and v1[1] == -v2[1],
		"attack variety: swings alternate (skip %.2f / %.2f, clip %s / %s, twist %d / %d, impact %.3f / %.3f s)" % [v1[0], v2[0], v1[2], v2[2], int(v1[1]), int(v2[1]), d1, d2])
	await frames(main, 40)
	# 3. bows: no trail, the arrow shows the hit
	var old_trail = pl._trail
	make_job(main, "archer")
	ps.add_item("bow")
	check(ps.equip("bow"), "feel: archer equips a bow")
	await frames(main, 2)
	check(not pl.has_trail() and not is_instance_valid(old_trail), "bow: weapon trail freed")
	check(pl.on_swing(1.0) == 0.0, "bow swing: no delay (the arrow lands the hit)")
	# 4. crit shake (own crits only) + the setting
	var saved: bool = feel.screen_shake
	feel.set_screen_shake(true)
	if is_instance_valid(mv) and main.rig:
		main.rig._shake_t = 0.0
		feel._impact({"src": 987654, "dst": m.id, "dmg": 1, "miss": false, "crit": true}, null, mv)
		check(main.rig._shake_t == 0.0, "someone else's crit: no screen shake")
		feel._impact({"src": main.my_id, "dst": m.id, "dmg": 1, "miss": false, "crit": true}, pl, mv)
		check(main.rig._shake_t > 0.0, "own crit: screen shakes")
		await frames(main, 12)
		check(main.rig._shake_t == 0.0 and main.rig.cam.h_offset == 0.0 and main.rig.cam.v_offset == 0.0, "shake over after 0.1 s, camera back")
		feel.set_screen_shake(false)
		feel._impact({"src": main.my_id, "dst": m.id, "dmg": 1, "miss": false, "crit": true}, pl, mv)
		check(main.rig._shake_t == 0.0, "setting off: no shake")
	var st: Dictionary = HitFeel.read_settings()
	var f2 = HitFeel.new()
	f2._load_settings()
	check(st.has("screen_shake") and not bool(st["screen_shake"]) and not f2.screen_shake, "screen shake setting saved to settings.json + loaded (%s)" % str(st.get("screen_shake")))
	f2.free()
	feel.set_screen_shake(saved)
	check(bool(HitFeel.read_settings().get("screen_shake", false)) == saved, "screen shake setting restored (%s)" % str(saved))
	if is_instance_valid(m) and not m.dead:
		m.map.remove_mob(m)
	await frames(main, 20)


# ---------------------------------------------------------------- map 4: Hollowroot Caverns (dungeon)

static func dungeon_test(main: Node) -> void:
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	var tree = main.get_tree()
	print("SELFTEST dungeon")
	make_job(main, "swordman")
	pe.dead = false
	ps.hp = ps.max_hp()
	main.load_map("whisperwood_forest")
	await frames(main, 2)
	check(tree.get_nodes_in_group("portals").size() == 4, "forest: 4 portal visuals")
	var cave_to: String = await take_portal(main, 2)
	check(main.map_id == "hollowroot_caverns" and cave_to == main.map_id, "portal: Whisperwood Forest -> Hollowroot Caverns")
	check(pl.global_position.distance_to(Vector3(-54, 0, 0)) < 1.0 and WorldBuilder.walkable(pl.global_position), "arrived on the cave floor by the entrance warp")
	await frames(main, 2)
	check(WorldBuilder.is_cave() and int(main.last_build.get("trees", -1)) == 0, "dungeon built as a cave (no trees)")
	var mons: Array = tree.get_nodes_in_group("monsters")
	var off_floor := 0
	for m in mons:
		if not WorldBuilder.walkable(m.global_position):
			off_floor += 1
	check(mons.size() == 59 and off_floor == 0, "59 dungeon monsters, all on the floor (%d, %d in rock)" % [mons.size(), off_floor])
	var ids := mob_ids(main)
	check(ids.has("flitter_bat") and ids.has("rattlebones") and ids.has("bone_archer") and ids.has("talisman_hopper") and ids.has("grand_whisper") and not ids.has("spore_cap"), "dungeon monster set")
	check(tree.get_nodes_in_group("npcs").size() == 1 and PlayerState.quests.offers(ps, "miner").size() >= 1, "Miner Dorvik at the entrance offers a quest")
	var n_torch: int = tree.get_nodes_in_group("cave_torches").size()
	var n_cry: int = tree.get_nodes_in_group("cave_crystals").size()
	check(n_torch >= 25 and n_cry >= 8, "torches (%d) and crystals (%d) light the cave" % [n_torch, n_cry])
	var wes: Array = main.env_root.find_children("*", "WorldEnvironment", true, false)
	var env: Environment = (wes[0] as WorldEnvironment).environment
	check(env.background_mode == Environment.BG_COLOR and env.fog_enabled, "no sky underground, dark fog")
	check(absf(main.rig.pitch - float(GameData.map("hollowroot_caverns")["look"]["cam_pitch"])) < 0.01, "steeper camera in the dungeon (%.0f)" % main.rig.pitch)
	check(WorldBuilder.music_area(Vector3(57, 0, 42)) == "dungeon_3" and main.music.fallbacks("dungeon_2") == ["dungeon_2", "dungeon", "field"], "dungeon music keys dungeon_N -> dungeon -> field")
	check(Minimap.area_name(Vector3(-24, 0, 2)) == "Bat Roost", "minimap: dungeon area names (%s)" % Minimap.area_name(Vector3(-24, 0, 2)))
	clear_mobs(main)
	# walls: walking into the rock stops at the edge
	var blocked_at := Vector3(-8, 0, 20)
	check(not WorldBuilder.walkable(blocked_at), "solid rock south-east of the Bat Roost")
	main.teleport_player(Vector3(-24, 0, 2))
	main.send({"t": "move", "to": blocked_at})
	await frames(main, 300)
	var pp: Vector3 = pl.global_position
	check(pp.distance_to(blocked_at) > 2.0 and WorldBuilder.walkable(WorldBuilder.nearest_walkable(pp)) and pp.distance_to(WorldBuilder.nearest_walkable(pp)) < 1.2,
		"cave walls stop the player at the floor edge (%s)" % str(pp))
	pl._clear_goals()
	# click-to-move routes around the rock (A*)
	var from := Vector3(-40, 0, 1)
	var to := Vector3(-24, 0, -20)
	main.teleport_player(from)
	check(WorldBuilder.walkable(to) and not WorldBuilder.route(from, to).is_empty(), "no straight line from the entrance hall into the north tunnel: a route")
	main.send({"t": "move", "to": to})
	var steps := 0
	while pl.global_position.distance_to(to) > 0.6 and steps < 900:
		await tree.physics_frame
		steps += 1
	check(pl.global_position.distance_to(to) < 0.8, "player walks the route around the corner (%d frames, %.1f m left)" % [steps, pl.global_position.distance_to(to)])
	pl._clear_goals()
	var far_route := WorldBuilder.route(Vector3(-54, 0, 0), Vector3(57, 0, 42))
	check(far_route.size() >= 2 and far_route[far_route.size() - 1].distance_to(Vector3(57, 0, 42)) < 1.5, "the Weeping Sanctum is reachable from the entrance (%d waypoints)" % far_route.size())
	# monsters: undead + Heal, shadow Familiar, ranged Bone Archer, mini-boss
	var ghoul: Dictionary = GameData.monsters["drowsy_ghoul"]
	# balance v3 (tools/balance/zone_patch_data.py): dungeon HP <= x1.6 of the band's field median -> 352 (Classic 534)
	check(str(ghoul["element"]) == "undead" and str(ghoul["race"]) == "undead" and int(ghoul["lv"]) == 15 and int(ghoul["hp"]) == 352, "Drowsy Ghoul: Undead 1, Lv 15, 352 HP (balance v3 dungeon cap)")
	check(str(GameData.monsters["flitter_bat"]["element"]) == "shadow" and bool(GameData.monsters["flitter_bat"]["aggressive"]), "Flitterfang = Familiar (Shadow 1, aggressive)")
	check(str(GameData.monsters["pale_maiden"]["race"]) == "demon" and int(GameData.monsters["grand_whisper"]["element_lv"]) == 2, "Pale Maiden = Sohee (Demon), Grand Whisper = Ghost 2")
	make_job(main, "acolyte")
	main.teleport_player(Vector3(-28, 0, 2))
	ps.skills["heal"] = 5
	ps.stats["int"] = 40
	var gd = dummy(main, "drowsy_ghoul", pl.global_position + Vector3(2.5, 0, 0))
	main.send({"t": "attack", "target": gd.id, "auto": false})
	check(pe._target_kind("heal") == "enemy", "Heal targets the Drowsy Ghoul (Undead)")
	ready_to_act(pe)
	pl.use_skill("heal")
	await frames(main, 10)
	var hexp: int = int(pe._heal_amount(5) / 2 * GameData.element_pct("holy", "undead", 1) / 100.0)
	check(100000 - gd.hp == hexp and hexp > 0, "Heal burns the Drowsy Ghoul: %d holy damage (expect %d)" % [100000 - gd.hp, hexp])
	var md = dummy(main, "pale_maiden", pl.global_position + Vector3(2.5, 0, 0))
	main.send({"t": "attack", "target": md.id})
	check(pe._target_kind("heal") == "self", "Heal on a Pale Maiden (Demon, not Undead) heals yourself")
	main.send({"t": "untarget"})
	var ar = dummy(main, "bone_archer", pl.global_position + Vector3(8.0, 0, 0))
	ar.atk_t = 0.0
	ar.aggro(pe)
	await frames(main, 20)
	check(ar.state == "chase" and ar.atk_t > 0.0 and pl._flat_dist(ar.global_position) > 7.0, "Bone Archer shoots from %.1f m" % pl._flat_dist(ar.global_position))
	var gw_e = main._spawn("grand_whisper", pl.global_position + Vector3(4, 0, 0), 0.0, false)
	await frames(main, 2)
	var gw = main.view(gw_e.id)
	check(gw.is_mini and not gw.is_boss and absf(gw.scl - 1.9) < 0.01 and gw.model_kind() == "tripo:grand_whisper" and gw._static and gw.name_label.modulate.g < 0.9, "Grand Whisper: mini-boss x1.9, gold name (static Tripo ghost)")
	check(GameData.monsters["grand_whisper"]["drops"].any(func(d): return str(d["id"]) == "grand_whisper_card"), "Grand Whisper has its card")
	# status cards: stun (Rattlebones Card) holds a monster, blind (Flitterfang Card) lowers its FLEE
	var sd = dummy(main, "rattlebones", pl.global_position + Vector3(3, 0, 0))
	check(sd.add_status("stun", 0.5) and sd.has_status("stun"), "stun on a monster")
	sd.add_status("blind", 1.0)
	check(PlayerState.mon_flee(sd.combat_data()) < PlayerState.mon_flee(sd.data), "blind lowers monster FLEE")
	check(int(GameData.item("rattlebones_card").get("batk", 0)) == 10 and is_equal_approx(float(GameData.item("rattlebones_card")["add_eff"]["stun"]), 2.0), "Rattlebones Card = Skeleton Card: ATK +10, 2% stun")
	var sturdy: Dictionary = GameData.item("sturdy_shoes")
	check(str(sturdy.get("slot", "")) == "shoes" and int(sturdy.get("slots", 0)) == 1, "Sturdy Shoes [1] for shoes cards")
	clear_mobs(main)
	check(GameData.quest_list.filter(func(q): return str(q.get("map", "")) == "hollowroot_caverns").size() == 3, "3 dungeon quests")
	# save / load in the dungeon, and back out through the warp
	var spath := "user://selftest_save_cave.json"
	check(ps.save_game(spath) and ps.load_game(spath) and ps.map_id == "hollowroot_caverns", "save / load in the dungeon keeps the map")
	var back: String = await take_portal(main, 0)
	check(back == "whisperwood_forest" and pl.global_position.distance_to(Vector3(10, 0, -60)) < 1.0, "dungeon warp -> Whisperwood Forest by the cave mouth")
	check(not WorldBuilder.is_cave() and WorldBuilder.walkable(Vector3(0, 0, 0)), "open maps are walkable everywhere")


# ---------------------------------------------------------------- AUTO (auto_play.gd)

## Waits until `cond` is true (max `n` physics frames). Returns the frames waited, -1 on timeout.
static func wait_until(main: Node, cond: Callable, n: int) -> int:
	for i in n:
		if cond.call():
			return i
		await main.get_tree().physics_frame
	return -1


## Real mouse click on control `c`: motion + press + release pushed into the viewport (GUI routing,
## stacking and mouse filters apply, unlike calling the button's callback).
static func click(main: Node, c: Control, button: MouseButton = MOUSE_BUTTON_LEFT) -> String:
	var vp: Viewport = main.get_viewport()
	var at: Vector2 = c.get_global_rect().get_center()
	var mv := InputEventMouseMotion.new()
	mv.position = at
	mv.global_position = at
	vp.push_input(mv, true)
	await main.get_tree().process_frame
	var hov: Control = vp.gui_get_hovered_control()
	var under := str(hov.get_path()) if hov else "nothing"
	for down in [true, false]:
		var ev := InputEventMouseButton.new()
		ev.button_index = button
		ev.pressed = down
		ev.button_mask = (MOUSE_BUTTON_MASK_LEFT if button == MOUSE_BUTTON_LEFT else MOUSE_BUTTON_MASK_RIGHT) if down else 0
		ev.position = at
		ev.global_position = at
		vp.push_input(ev, true)
		await main.get_tree().process_frame
	await main.get_tree().process_frame
	return under


## A monster that stands still and never attacks, at `at`.
static func still_mob(main: Node, id: String, at: Vector3):
	var m = main._spawn(id, at, 0.0, false)
	m.data = m.data.duplicate(true)
	m.data["speed"] = 0.0
	m.data["aggressive"] = false
	m.roam = 0.0
	m.atk_t = 1e6
	return m


static func auto_test(main: Node) -> void:
	var ps = PlayerState
	var pl = main.player
	var pe = main.me()
	var au = main.auto
	print("SELFTEST auto")
	make_job(main, "swordman")
	pe.dead = false
	ps.stats["str"] = 60
	ps.stats["dex"] = 50
	ps.hp = ps.max_hp()
	main.load_map("aldoria_meadows")
	await frames(main, 2)
	clear_mobs(main)
	main.teleport_player(Vector3(-14, 0, 0))
	check(au != null and not au.active and ps.auto_cfg.has("leash") and (ps.auto_cfg["slots"] as Array).size() == 9, "AUTO node + default settings")
	# 1. auto-battle kills a slime with no input
	var sl = still_mob(main, "pudding_slime", Vector3(-9, 0, 2))
	au.start_battle()
	check(au.active and au.mode == "battle", "AUTO on (battle)")
	var w: int = await wait_until(main, func(): return not is_instance_valid(sl) or sl.dead, 600)
	check(w >= 0 and int(au.sess.get("kills", 0)) >= 1 and int(au.sess.get("base_exp", 0)) > 0, "AUTO kills a slime by itself (%d frames, %d kills, +%d exp)" % [w, int(au.sess.get("kills", 0)), int(au.sess.get("base_exp", 0))])
	await frames(main, 2)
	check(main.hud.auto_strip.visible and main.hud.auto_lbl.text.contains("kills"), "HUD: AUTO status line (%s)" % main.hud.auto_lbl.text)
	# 2. auto-loot walks over and picks up a drop
	ps.auto_cfg["loot_on"] = true
	var jelly0: int = ps.count("jelly")
	main.drop_item("jelly", 1, pl.global_position + Vector3(5, 0, 0))
	w = await wait_until(main, func(): return ps.count("jelly") > jelly0, 400)
	check(w >= 0 and int((au.sess["items"] as Dictionary).get("jelly", 0)) >= 1, "AUTO loots a Jelly (%d frames)" % w)
	ps.auto_cfg["loot_rarity"] = "rare"
	check(not au.wanted("jelly") and au.wanted("slime_card") and au.wanted("dagger"), "loot filter 'valuables': skips common Jelly, keeps cards + equipment")
	ps.auto_cfg["loot_on"] = false
	check(not au.wanted("slime_card"), "loot off: nothing is wanted")
	ps.auto_cfg["loot_on"] = true
	ps.auto_cfg["loot_rarity"] = "any"
	# 3. auto-potion at the threshold
	ps.auto_cfg["hp_pot"] = 50.0
	var red0: int = ps.count("red_tonic")
	ps.hp = ps.max_hp() * 3 / 10
	ps.changed.emit()
	await frames(main, 20)
	check(ps.count("red_tonic") == red0 - 1 and ps.hp > ps.max_hp() * 3 / 10, "AUTO drinks a Red Tonic under 50%% HP (%d left)" % ps.count("red_tonic"))
	check(au.potion("heal_hp") == "red_tonic", "potion choice: hotbar first, then bag")
	# 4. skip bosses (unless allowed), monster filter
	clear_mobs(main)
	await frames(main, 2)
	var king = still_mob(main, "pudding_king", pl.global_position + Vector3(3, 0, 0))
	var sl2 = still_mob(main, "pudding_slime", pl.global_position + Vector3(-8, 0, 0))
	au.target = null
	await frames(main, 12)
	check(au.pick_target(pl.global_position, 20.0, []) == main.view(sl2.id) and au.target != main.view(king.id), "AUTO skips the MVP boss and takes the slime")
	ps.auto_cfg["allow_boss"] = true
	check(au.pick_target(pl.global_position, 20.0, []) == main.view(king.id), "'Attack MVP bosses' on: the nearer boss is picked")
	ps.auto_cfg["allow_boss"] = false
	ps.auto_cfg["exclude"] = ["pudding_slime"]
	check(au.pick_target(pl.global_position, 20.0, []) == null, "Targets tab: an excluded monster is ignored")
	ps.auto_cfg["exclude"] = []
	# 5. manual control pauses (or stops) AUTO
	au.on_manual()
	check(au.active and au.pause_t > 0.0 and au.status_line().contains("paused"), "manual click pauses AUTO")
	au.pause_t = 0.0
	ps.auto_cfg["manual"] = "stop"
	au.on_manual()
	check(not au.active and main.hud.auto_sum.visible, "manual click with the 'Stop' setting stops AUTO + session summary")
	ps.auto_cfg["manual"] = "pause"
	# 6. retreat when out of HP potions: Return Wing, or walk back to the start point
	clear_mobs(main)
	main.teleport_player(Vector3(-14, 0, 0))
	await frames(main, 2)
	for it in ps.inventory.keys():
		if GameData.item(str(it)).has("heal_hp"):
			ps.remove_item(str(it), ps.count(str(it)))
	if ps.count("return_wing") == 0:
		ps.add_item("return_wing", 1)
	ps.save_map = "aldoria_meadows"
	ps.save_pos = Vector3(-40, 0, 2)
	ps.auto_cfg["keep_fighting"] = false
	ps.auto_cfg["retreat_wing"] = true
	au.start_battle()
	ps.hp = ps.max_hp() / 5
	await frames(main, 12)
	check(not au.active and ps.count("return_wing") == 0 and pl.global_position.distance_to(Vector3(-40, 0, 2)) < 1.0, "no HP potions + HP < 25%: Return Wing home, AUTO stops")
	ps.auto_cfg["retreat_wing"] = false
	main.teleport_player(Vector3(-14, 0, 0))
	au.start_battle()
	main.teleport_player(Vector3(-4, 0, 0))
	ps.hp = ps.max_hp() / 5
	await frames(main, 90)
	check(au.active and au._retreat and pl._flat_dist(Vector3(-14, 0, 0)) < 8.5, "no Return Wing: retreat toward the start point (%.1f m left)" % pl._flat_dist(Vector3(-14, 0, 0)))
	au.stop("", false)
	ps.hp = ps.max_hp()
	ps.auto_cfg["keep_fighting"] = true
	ps.add_item("red_tonic", 10)
	# 7. settings save / load (save v4), old saves get defaults, presets
	ps.auto_cfg["leash"] = 33.0
	ps.auto_cfg["priority"] = "lowest_hp"
	ps.auto_cfg["slots"][3] = false
	ps.auto_cfg["exclude"] = ["buzzfly"]
	var apath := "user://selftest_auto.json"
	check(ps.save_game(apath), "save with AUTO settings")
	var ad = JSON.parse_string(FileAccess.get_file_as_string(apath))
	check(int(ad["version"]) == 4 and ad.has("auto"), "save version 4 has 'auto'")
	ps.set_auto_cfg({})
	check(ps.load_game(apath) and is_equal_approx(float(ps.auto_cfg["leash"]), 33.0) and str(ps.auto_cfg["priority"]) == "lowest_hp" and not bool(ps.auto_cfg["slots"][3]) and ps.auto_cfg["exclude"] == ["buzzfly"],
		"AUTO settings survive save / load")
	var old: Dictionary = ad.duplicate(true)
	old["version"] = 3
	old.erase("auto")
	ps.from_dict(old)
	check(is_equal_approx(float(ps.auto_cfg["leash"]), 20.0) and bool(ps.auto_cfg["slots"][3]) and (ps.auto_cfg["exclude"] as Array).is_empty(), "version 3 save -> default AUTO settings")
	ps.apply_auto_preset("boss")
	check(bool(ps.auto_cfg["allow_boss"]) and str(ps.auto_cfg["priority"]) == "lowest_hp" and bool(ps.auto_cfg["loot_on"]), "preset 'Boss hunt' fills the settings")
	ps.set_auto_cfg({})
	var ui = main.hud.auto_ui
	ui.open()
	var tabs_ok := true
	for t in ui.TABS:
		ui.show_tab(str(t[0]))
		if ui.page.get_child_count() == 0:
			tabs_ok = false
	check(tabs_ok and main.hud.is_open("auto"), "AUTO settings window: all %d tabs build" % ui.TABS.size())
	ui.show_tab("skills")
	check(ui.page.find_children("*", "CheckButton", true, false).size() >= 10, "Skills tab: a switch per hotbar slot + rules")
	main.hud.windows["auto"].visible = false
	# real mouse clicks (pushed through the viewport like the OS would) reach the AUTO controls
	var hud = main.hud
	await frames(main, 2)
	await click(main, hud.auto_gear)
	check(hud.is_open("auto"), "real click on the gear button opens AUTO settings")
	await click(main, ui._tab_btns["loot"])
	check(ui.tab == "loot", "real click on a settings tab switches to it (%s)" % ui.tab)
	var sws: Array = ui.page.find_children("*", "CheckButton", true, false)
	var loot0: bool = bool(ps.auto_cfg["loot_on"])
	if not sws.is_empty():
		await click(main, sws[0])
	check(not sws.is_empty() and bool(ps.auto_cfg["loot_on"]) != loot0, "real click on a toggle switch changes the setting")
	ps.auto_cfg["loot_on"] = true
	await click(main, ui.start_btn)
	check(au.active, "real click on 'Start AUTO' in the settings starts AUTO")
	au.stop("", false)
	await click(main, hud.auto_gear)
	check(not hud.is_open("auto"), "second click on the gear closes the settings")
	await click(main, hud.auto_btn)
	check(au.active and au.mode == "battle", "real click on the AUTO button starts auto-battle")
	await click(main, hud.auto_btn)
	check(not au.active, "second click on the AUTO button stops it")
	hud.auto_sum.visible = false
	var rt0: int = ps.count("red_tonic")
	await click(main, hud.hot_buttons[0])
	check(ps.count("red_tonic") == rt0 - 1, "real click on hotbar slot F1 uses the Red Tonic")
	await click(main, hud.hot_buttons[0], MOUSE_BUTTON_RIGHT)
	check(str(ps.hotbar[0]) == "", "right-click on a hotbar slot clears it")
	ps.hotbar[0] = "item:red_tonic"
	# death: the "Return to Save Point" button takes a real click, also with AUTO on; Enter works too
	main.teleport_player(Vector3(-14, 0, 0))
	au.start_battle()
	pe.die()
	await frames(main, 12)
	var dbtn: Button = hud.death_panel.find_children("*", "Button", true, false)[0]
	check(pl.dead and hud.death_panel.visible and not au.active and not hud.auto_sum.visible, "death with AUTO on: AUTO stops, death panel on top, no summary card over it")
	await click(main, dbtn)
	check(not pl.dead and not hud.death_panel.visible and ps.hp > 0, "real click on 'Return to Save Point' respawns")
	await frames(main, 40)
	pe.die()
	await frames(main, 2)
	var kev := InputEventKey.new()
	kev.keycode = KEY_ENTER
	kev.pressed = true
	main.get_viewport().push_input(kev, true)
	await frames(main, 2)
	check(not pl.dead and not hud.death_panel.visible, "Enter on the death panel respawns")
	await frames(main, 40)
	ps.hp = 0
	check(ps.revive_if_dead() and ps.hp == ps.max_hp() / 2 and ps.map_id == ps.save_map, "a save made while dead loads at the save point with 50% HP")
	ps.hp = ps.max_hp()
	if main.map_id != "aldoria_meadows":
		main.load_map("aldoria_meadows")
	await frames(main, 2)
	# 8. auto-path to an NPC on the same map
	clear_mobs(main)
	main.teleport_player(Vector3(-14, 0, 0))
	await frames(main, 2)
	au.goto_npc("guide")
	await frames(main, 3)
	check(au.route_points().size() >= 2 and au.route_end() is Vector3, "auto-path: route line for the minimap")
	w = await wait_until(main, func(): return not au.active, 900)
	await frames(main, 20)
	var guide = au.npc_node("guide")
	check(w >= 0 and guide != null and pl._flat_dist(guide.global_position) < 3.0 and main.hud.is_open("npc"), "auto-path walks to Guide Toto and opens the dialog (%d frames)" % w)
	main.hud.windows["npc"].visible = false
	# big minimap click -> walk there
	var mm = main.hud.minimap
	mm.set_big(true)
	var target_w := Vector3(-20, 0, 6)
	var local: Vector2 = mm._to_map(Vector2(target_w.x, target_w.z), mm._view())
	check(mm.to_world(local).distance_to(target_w) < 0.05, "big minimap pixel <-> world point")
	var ev := InputEventMouseButton.new()
	ev.button_index = MOUSE_BUTTON_LEFT
	ev.pressed = true
	ev.position = local
	mm._gui_input(ev)
	check(au.active and au.mode == "path" and au.dest_pos.distance_to(target_w) < 0.1, "big minimap click starts auto-path")
	w = await wait_until(main, func(): return not au.active, 600)
	check(w >= 0 and pl._flat_dist(target_w) < 1.2, "auto-path arrives at the clicked spot")
	mm.set_big(false)
	# 9. map graph + auto-path across a portal
	var hop: Dictionary = au.next_portal("aldoria_meadows", "hollowroot_caverns")
	check(not hop.is_empty() and (hop["at"] as Vector3).distance_to(Vector3(67, 0, 18)) < 0.1 and au.map_route("aldoria_meadows", "hollowroot_caverns").size() == 2, "map graph: Aldoria -> Whisperwood -> Hollowroot (2 portals)")
	check(au.map_route("sunscorch_dunes", "aldoria_meadows").size() == 2 and au.next_portal("aldoria_meadows", "aldoria_meadows").is_empty(), "map graph: Sunscorch -> Aldoria")
	main.teleport_player(Vector3(56, 0, 18))
	clear_mobs(main)
	await frames(main, 2)
	var dest := Vector3(-62, 0, 4)
	au.goto_point("whisperwood_forest", dest)
	w = await wait_until(main, func(): return not au.active, 1500)
	check(w >= 0 and main.map_id == "whisperwood_forest" and pl._flat_dist(dest) < 1.5, "auto-path through the portal to Whisperwood Forest (%d frames, %.1f m off)" % [w, pl._flat_dist(dest)])
	# 10. quest goals
	ps.quests.accept(ps, "slime_trouble")
	var g: Dictionary = au.quest_goal("slime_trouble")
	check(str(g["kind"]) == "kill" and str(g["map"]) == "aldoria_meadows" and g["mobs"] == ["pudding_slime"] and (g["pos"] as Vector3).distance_to(Vector3(-14, 0, 0)) < 0.1, "quest goal: Slime Trouble -> Pudding Meadow")
	var fz: Dictionary = au.find_zone(au.droppers("bunny_tail"), "")
	check("carrot_bunny" in au.droppers("bunny_tail") and str(fz.get("map", "")) == "aldoria_meadows", "quest goal: collect Fluffy Tails -> Carrot Bunny zone")
	check(str(au.find_zone(["elder_willow"], "whisperwood_forest").get("map", "")) == "whisperwood_forest", "quest goal: Elder Willow -> Whisperwood")
	# 11. auto-quest from another map: portal back, hunt 5 slimes, walk to Guide Toto, turn in
	var done0: int = ps.quests.done.size()
	var zeny0: int = ps.zeny
	au.start_quest("slime_trouble")
	check(au.active and au.mode == "quest", "auto-quest started from Whisperwood")
	w = await wait_until(main, func(): return not au.active, 5400)
	check(w >= 0 and ps.quests.is_done("slime_trouble") and ps.quests.done.size() == done0 + 1 and ps.zeny > zeny0,
		"auto-quest: back through the portal, 5 Pudding Slimes, turned in to Guide Toto (%d frames)" % w)
	check(main.map_id == "aldoria_meadows", "auto-quest ended in Aldoria")
	main.hud.windows["npc"].visible = false
	main.hud.auto_sum.visible = false
	ps.hp = ps.max_hp()


# ---------------------------------------------------------------- inventory grid, icons, hotbar drag

## All Label texts under `n`, joined (for UI checks).
static func texts(n: Node) -> String:
	var out: Array = []
	for l in n.find_children("*", "Label", true, false):
		out.append((l as Label).text)
	return " | ".join(out)


static func grid_slots(hud) -> Array:
	var out: Array = []
	for c in hud.inv_grid.get_children():
		if c.get_script() == ItemSlot:
			out.append(c)
	return out


static func inventory_test(main: Node) -> void:
	var ps = PlayerState
	var hud = main.hud
	print("SELFTEST inventory")
	make_job(main, "swordman")
	# start from a clean screen: windows left open by earlier groups would overlap the bag
	for k in hud.windows:
		hud.windows[k].visible = false
	# icon lookup + fallback
	check(Icons.icon("items", "no_such_item_xyz") == null, "icons: missing file -> null")
	var ft: PanelContainer = Icons.tile("items", "no_such_item_xyz", "Sticky Jelly", Color(0.4, 0.6, 0.8), 48.0)
	check(not bool(ft.get_meta("has_icon")) and texts(ft) == "SJ", "icons: fallback tile with initials (SJ)")
	ft.free()
	ft = Icons.skill_tile("magnum_break", 56.0)
	check(texts(ft).contains(GameData.skill_name("magnum_break").split(" ")[0]) and texts(ft).length() > 7 or bool(ft.get_meta("has_icon")), "icons: skill fallback = symbol + short name (%s)" % texts(ft))
	ft.free()
	if FileAccess.file_exists(Icons.path("items", "red_tonic")):
		var rt: PanelContainer = Icons.item_tile("red_tonic", 48.0)
		check(Icons.icon("items", "red_tonic") != null and bool(rt.get_meta("has_icon")), "icons: red_tonic.png is shown")
		rt.free()
	# bag: tabs filter
	ps.add_item("slime_card", 2)
	ps.add_item("jelly", 7)
	ps.add_item("blue_tonic", 3)
	var hood: Dictionary = ps.make_inst("hood", 4)
	ps.add_inst(hood)
	if not hud.is_open("items"):
		hud.toggle("items")
	hud.inv_tab = "all"
	hud._inv_sig = ""
	hud.refresh()
	check(grid_slots(hud).size() == ps.inventory.size() + ps.gear.size(), "Items tab All: one slot per stack + per equipment piece (%d)" % grid_slots(hud).size())
	var tab_ok := true
	for t in [["use", ["use", "ammo"]], ["etc", ["etc"]], ["card", ["card"]], ["equip", ["equip"]]]:
		hud._set_inv_tab(str(t[0]))
		hud.refresh()
		for sl in grid_slots(hud):
			if not (GameData.item_type(str(sl.entry["id"])) in t[1]):
				tab_ok = false
	hud._set_inv_tab("card")
	hud.refresh()
	check(tab_ok and grid_slots(hud).size() == 1 and str(grid_slots(hud)[0].entry["id"]) == "slime_card", "Items tabs Use / Etc / Cards / Equip filter by type")
	hud._set_inv_tab("equip")
	hud.refresh()
	var hs = null
	for sl in grid_slots(hud):
		if is_same(sl.entry["inst"], hood):
			hs = sl
	check(hs != null and texts(hs).contains("+4") and grid_slots(hud).size() == ps.gear.size(), "Equip tab: +4 Traveler's Hood shows its refine badge")
	# drag a potion from the bag onto the hotbar, reorder inside the hotbar
	hud._set_inv_tab("use")
	hud.refresh()
	var bt = null
	for sl in grid_slots(hud):
		if str(sl.entry["id"]) == "blue_tonic":
			bt = sl
	check(bt != null and texts(bt).contains("3"), "Blue Tonic slot with a x3 badge")
	var data = bt.drag_payload() if bt else null
	check(data is Dictionary and str(data["entry"]) == "item:blue_tonic" and hud._hot_can_drop(Vector2.ZERO, data, 6), "drag data from a bag slot is accepted by the hotbar")
	hud._hot_drop(Vector2.ZERO, data, 6)
	check(str(ps.hotbar[6]) == "item:blue_tonic", "drop on hotbar slot F7 assigns the Blue Tonic")
	var d2 = hud.hot_payload(0)
	hud._hot_drop(Vector2.ZERO, d2, 6)
	check(str(ps.hotbar[6]) == "item:red_tonic" and str(ps.hotbar[0]) == "item:blue_tonic", "drag between hotbar slots swaps them")
	var sd = hud.skill_payload("first_aid")
	hud._hot_drop(Vector2.ZERO, sd, 8)
	check(str(ps.hotbar[8]) == "skill:first_aid" and not ("skill:first_aid" in ps.hotbar.slice(0, 8)), "skill dragged from the Skills window moves to F9")
	hud._set_inv_tab("etc")
	hud.refresh()
	check(not grid_slots(hud).is_empty() and grid_slots(hud)[0].drag_payload() == null, "materials cannot go on the hotbar")
	hud.refresh()
	check(hud.hot_buttons[6].find_children("*", "PanelContainer", true, false).size() >= 1, "hotbar slots show an icon tile")
	# right-click uses, tooltip card
	hud._set_inv_tab("use")
	hud.refresh()
	await frames(main, 3)
	bt = null
	for sl in grid_slots(hud):
		if str(sl.entry["id"]) == "blue_tonic":
			bt = sl
	var b0: int = ps.count("blue_tonic")
	var found: bool = bt != null
	var under := "-"
	# sweep: class-choice window (fresh v2 hero) would cover the slot
	if hud.windows.has("jobslots"):
		hud.windows["jobslots"].visible = false
	await frames(main, 2)
	if bt:
		under = await click(main, bt, MOUSE_BUTTON_RIGHT)
	var ids_now: Array = []
	for sl in grid_slots(hud):
		ids_now.append(str(sl.entry["id"]))
	if ps.count("blue_tonic") != b0 - 1:
		print("DIAG right-click: under the mouse: ", under, "  open: ", hud.windows.keys().filter(func(k): return hud.is_open(k)))
	check(found and ps.count("blue_tonic") == b0 - 1, "right-click on a potion slot uses it")
	var tip: Control = hud.item_tooltip({"id": "hood", "inst": hood, "qty": 1})
	var tt := texts(tip)
	check(tt.contains("+4 Traveler's Hood [1]") and tt.contains("Sells for") and tt.contains("Garment") and tt.contains("DEF"), "rich tooltip: name, type, stats, price (%s)" % tt.left(90))
	tip.free()
	# equipment window + shop + loot notes use icon slots
	ps.equip_inst(hood)
	if not hud.is_open("equip"):
		hud.toggle("equip")
	hud.refresh()
	var eq_slots := 0
	for c in hud.bodies["equip"].find_children("*", "Button", true, false):
		if c.get_script() == ItemSlot:
			eq_slots += 1
	check(eq_slots >= 1, "Equipment window shows worn items as icon slots")
	hud._open_shop(find_npc("tool_dealer"))
	hud.refresh()
	var shop_slots := 0
	for c in hud.bodies["shop"].find_children("*", "Button", true, false):
		if c.get_script() == ItemSlot:
			shop_slots += 1
	check(shop_slots >= 3, "shop buy / sell rows have item icons (%d)" % shop_slots)
	hud.add_note("Sticky Jelly", "x1")
	var last: Node = hud.note_box.get_child(hud.note_box.get_child_count() - 1)
	check(last.find_children("*", "PanelContainer", true, false).size() >= 1, "loot notification shows the item icon")
	for w in ["items", "equip", "shop"]:
		hud.windows[w].visible = false
