extends RefCounted
## Later classes unlock (selftest --only=classes4, also in the full run): Samurai / Flying Sword / Flamethrower /
## Fire Mage. Roster unlocked (9 launch classes, none "coming soon"), own weapon types (katana / flying-sword
## focus / fire gun / fire tome) with shop items + testers, borrowed hero models with tints, kits in
## skills.json + FX + icons, class fields (Sword Spirit echo, Star Field only while standing in it), Gravity
## Core pull, dashes, ammo-free ranged autos, AUTO roles, Job Slot unlock + weapon-bound switch, hand meshes.
## Numbers (budget, parity) are checked by skills_v2_test.gd for every launch class.

const SelfTest = preload("res://scripts/selftest.gd")
const CharacterState = preload("res://scripts/server/character_state.gd")
const JobSlots = preload("res://scripts/server/job_slots.gd")
const SkillsV2 = preload("res://scripts/server/skills_v2.gd")
const ModelLoader = preload("res://scripts/model_loader.gd")
const AutoPlay = preload("res://scripts/auto_play.gd")
const PlayerScript = preload("res://scripts/player.gd")

const NEW := {
	"samurai": {"w": "katana", "starter": "iron_katana", "model": "hero_swordman", "items": ["iron_katana", "crimson_katana", "moonlit_katana", "tester_katana"]},
	"flying_sword": {"w": "flying_sword", "starter": "spirit_blade_charm", "model": "hero_archer", "items": ["spirit_blade_charm", "jade_flying_sword", "starlit_flying_sword", "tester_flying_sword"]},
	"flamethrower": {"w": "gun", "starter": "tinder_flame_gun", "model": "hero_merchant", "items": ["tinder_flame_gun", "brass_flamethrower", "dragonbreath_cannon", "tester_flame_gun"]},
	"fire_mage": {"w": "fire_tome", "starter": "kindling_tome", "model": "hero_mage", "items": ["kindling_tome", "ember_grimoire", "phoenix_rod", "tester_fire_tome"]},
}
const NEW_STYLES := ["fly_swords", "sword_rain", "air_blades", "flame_cone", "meteor", "fire_pillars", "piglets"]


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


static func run(main: Node) -> void:
	print("-- later classes (Samurai / Flying Sword / Flamethrower / Fire Mage)")
	var old_flag = GameData.config.get("combat_v2", false)
	GameData.config["combat_v2"] = true
	var fx: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/skill_fx.json")).get("skills", {})
	var idx: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://assets/icons/index.json")).get("skills", {})
	# ---- roster unlocked
	var launch: Array = JobSlots.launch_classes()
	check(launch.size() == 9 and (JobSlots.cfg().get("locked", []) as Array).is_empty(), "9 launch classes, none locked (%d)" % launch.size())
	var wtypes := {}
	var dup := false
	for j in launch:
		for w in GameData.jobs[j].get("weapons", []):
			dup = dup or wtypes.has(w)
			wtypes[w] = j
	check(not dup, "every weapon type belongs to one class (weapon-bound Job Slot switch is unambiguous)")
	var shops := {}
	for npc in GameData.npcs:
		if str(npc.get("id", "")) in ["tool_dealer", "hanamura_tool"]:
			shops[str(npc["id"])] = npc.get("sells", [])
	for job in NEW:
		var n: Dictionary = NEW[job]
		var jd: Dictionary = GameData.jobs[job]
		check(not JobSlots.is_locked(job) and job in launch and str(jd.get("name_th", "")) != "", "%s unlocked (%s / %s)" % [job, jd.get("name", ""), jd.get("name_th", "")])
		check(n["w"] in jd.get("weapons", []) and GameData.item(n["starter"]).get("wtype", "") == n["w"] and jd.get("starter", {}).has(n["starter"]),
			"%s: weapon type %s + starter %s" % [job, n["w"], n["starter"]])
		check(GameData.size_pct(n["w"], "medium") > 0.0 and ModelLoader.resolve("hero_" + job) == n["model"] and ModelLoader.tint_of("hero_" + job) != Color.WHITE,
			"%s: size table, borrowed model %s with a tint" % [job, n["model"]])
		var sold := true
		for it in n["items"]:
			sold = sold and not GameData.item(it).is_empty() and it in shops.get("tool_dealer", []) and it in shops.get("hanamura_tool", [])
		check(sold, "%s: 3 weapons + tester sold at both Tool Dealers" % job)
		# kit
		var acts := 0
		var ult := ""
		var ok_fx := true
		var ok_icon := true
		for sid in jd.get("skills", []):
			var sk := GameData.skill(str(sid))
			if bool(sk.get("passive", false)):
				ok_icon = ok_icon and idx.has(str(sid))
				continue
			if bool(sk.get("ult", false)):
				ult = str(sid)
			else:
				acts += 1
			ok_fx = ok_fx and fx.has(str(sid))
			ok_icon = ok_icon and idx.has(str(sid)) and ResourceLoader.exists(str(idx.get(str(sid), "")))
		check(acts == 4 and ult != "" and ok_fx and ok_icon, "%s: 4 actives + ultimate, FX entries and icons" % job)
		check(AutoPlay.role(ult) == "ult", "%s: AUTO fires the ultimate by the ult rule" % job)
		# a fresh character can pick it, the starter weapon equips
		var c = CharacterState.new()
		c.new_character("C4" + job)
		check(JobSlots.pick_class(c, job) == "" and c.job == job and (c.equipped_id("weapon") == str(n["starter"]) or (c.count(n["starter"]) >= 1 and c.can_equip(n["starter"]) == "")), "%s: class pick + starter weapon" % job)  # sweep: wielded or in the bag
	var styles := {}
	for sid in fx:
		styles[str(fx[sid].get("release", {}).get("style", ""))] = true
	var st_ok := true
	for s in NEW_STYLES:
		st_ok = st_ok and styles.has(s)
	check(st_ok, "FX: flying / planted swords, sword rain, air blades, flame cone, meteors, fire pillars, piglets in use")
	check(AutoPlay.role("ft_star_grenade") == "attack" and GameData.skill("ft_star_grenade").get("target", "") == "self", "Star Grenade: self-cast planter AUTO uses on any target")
	# ---- Job Slots: unlock slot 2 as Flying Sword, switch by equipping its weapon
	var k = CharacterState.new()
	k.new_character("C4slots")
	JobSlots.pick_class(k, "samurai")
	k.base_lv = 60
	k.zeny = 2000000
	k.quests.done.append("jobslot_2")
	check(JobSlots.unlock(k, 1, "flying_sword") == "" and k.job_slots.size() == 2, "Job Slot 2 unlocked as Flying Sword")
	k.slot_cd_until = 0.0
	k.combat_until = 0.0
	k.add_item("spirit_blade_charm")
	check(k.equip("spirit_blade_charm") and k.job == "flying_sword" and k.active_slot == 1, "equipping a flying-sword focus switches to the Flying Sword slot")
	# ---- ranged autos without ammo
	var g = CharacterState.new()
	g.new_character("C4gun")
	JobSlots.pick_class(g, "flamethrower")
	g.equip("tinder_flame_gun")
	check(g.weapon_type() == "gun" and g.is_ranged() and g.shoots() and is_equal_approx(g.attack_range(), 7.0), "fire gun: DEX ranged, 7 m, no ammo")
	# ---- executor on real server mobs
	var pe = main.me()
	var ch = pe.ch
	var saved: Dictionary = ch.to_dict()
	var pos0: Vector3 = pe.global_position
	var mid := ""
	for id in GameData.monsters:
		var md: Dictionary = GameData.monsters[id]
		if not md.get("boss", false) and not md.get("miniboss", false) and int(md.get("lv", 1)) >= 30 and int(md.get("lv", 1)) <= 50:
			mid = id
			break
	ch.base_lv = 60
	ch.wheel = {}
	ch.skill_cd = {}
	pe.s2 = {}
	ch.hp = ch.max_hp()
	var mobs: Array = []
	for off in [Vector3(4, 0, 0), Vector3(4.5, 0, 1.2), Vector3(3.2, 0, -1.4)]:
		mobs.append(pe.map.spawn_mob(mid, pe.global_position + off, 0.1, false))
	await main.get_tree().physics_frame
	# Flying Sword: Blade Formation plants the Sword Spirit (one per caster), echo armed
	ch.job = "flying_sword"
	ch.skills = {"fs_blade_formation": 5, "fs_sword_curtain": 5, "fs_star_cross": 5}
	ch.hp = ch.max_hp()
	var m0 = mobs[0]
	var h0: int = m0.hp
	pe._execute("fs_blade_formation", 5, m0)
	await main.get_tree().physics_frame
	var fld: Dictionary = SkillsV2.field_state(pe)
	check(m0.hp < h0 and pe.map.zones_of(pe, "fs_sword_spirit").size() == 1, "Blade Formation hits and plants a Sword Spirit (%d -> %d)" % [h0, m0.hp])
	check(float(fld.get("echo", 0)) == 0.3 and is_equal_approx(float(fld.get("dmg", 0)), 0.2) and SkillsV2._near_field(fld, m0.global_position),
		"Sword Spirit: +20% damage, 30% echo on monsters near it")
	ch.skill_cd = {}
	pe.s2 = {}
	ch.hp = ch.max_hp()
	pe._execute("fs_star_cross", 5, m0)
	await main.get_tree().physics_frame
	check(pe.map.zones_of(pe, "fs_sword_spirit").size() == 1, "a new Sword Spirit absorbs the old one")
	# Flamethrower: Star Field only while standing in it; Gravity Core drags normal monsters together
	ch.job = "flamethrower"
	ch.skills = {"ft_star_grenade": 5, "ft_gravity_core": 5, "ft_full_blaze": 5}
	ch.skill_cd = {}
	pe.s2 = {}
	ch.hp = ch.max_hp()
	pe._execute("ft_star_grenade", 5, null)
	await main.get_tree().physics_frame
	check(pe.map.zones_of(pe, "ft_star_field").size() == 1 and not SkillsV2.field_state(pe).is_empty(), "Star Grenade opens a Star Field at the caster's feet")
	var here: Vector3 = pe.global_position
	pe.global_position = here + Vector3(0, 0, 12)
	check(SkillsV2.field_state(pe).is_empty(), "Star Field bonus only while standing in it")
	pe.global_position = here
	var alive: Array = mobs.filter(func(x): return is_instance_valid(x) and not x.dead)
	if alive.size() >= 2:
		var d0: float = (alive[1].global_position - alive[0].global_position).length()
		ch.skill_cd = {}
		pe.s2 = {}
		ch.hp = ch.max_hp()
		pe._execute("ft_gravity_core", 5, alive[0])
		await main.get_tree().physics_frame
		var d1: float = (alive[1].global_position - alive[0].global_position).length() if is_instance_valid(alive[1]) and is_instance_valid(alive[0]) else 0.0
		check(d1 <= d0 + 0.01 and d1 < 1.6, "Gravity Core drags monsters together (%.2f -> %.2f m)" % [d0, d1])
	# Samurai dash + Fire Mage meteors
	ch.job = "samurai"
	ch.skills = {"sa_instant_step": 5}
	ch.skill_cd = {}
	pe.s2 = {}
	ch.hp = ch.max_hp()
	alive = mobs.filter(func(x): return is_instance_valid(x) and not x.dead)
	if not alive.is_empty():
		var tm = alive[0]
		pe.global_position = tm.global_position + Vector3(0, 0, 5)
		var dd: float = pe.global_position.distance_to(tm.global_position)
		pe._execute("sa_instant_step", 5, tm)
		await main.get_tree().physics_frame
		check(pe.global_position.distance_to(tm.global_position) < dd - 1.0, "Instant Step dashes to the target")
	ch.job = "fire_mage"
	ch.skills = {"fm_meteor_fall": 5}
	ch.skill_cd = {}
	pe.s2 = {}
	ch.hp = ch.max_hp()
	alive = mobs.filter(func(x): return is_instance_valid(x) and not x.dead)
	if not alive.is_empty():
		var tm2 = alive[0]
		var hh: int = tm2.hp
		var err: String = pe._skill_check("fm_meteor_fall", 5)
		pe._execute("fm_meteor_fall", 5, tm2)
		for q in 5:
			ch.hp = ch.max_hp()
			await SelfTest.frames(main, 20)
		check(not is_instance_valid(tm2) or tm2.dead or tm2.hp < hh, "Meteor Fall lands after its delay (%s)" % err)
	for mm in mobs:
		if is_instance_valid(mm) and not mm.dead:
			mm.respawn = false
			mm.die(null)
	for z in pe.map.zones_of(pe):
		pe.map._remove_area(z)
	pe.remove_meta("v2_field")
	ch.from_dict(saved)
	ch.skill_cd = {}
	ch.v2_stacks = 0.0
	pe.s2 = {}
	pe.global_position = pos0
	# ---- hand meshes of the new weapons (remote hero views)
	var meshes_ok := true
	for job in NEW:
		for it in (NEW[job]["items"] as Array).slice(0, 3):
			var h = PlayerScript.new()
			h.remote = true
			h.id = -7700
			h.look = {"job": job, "weapon": it, "name": "", "lv": 1, "hp": 1, "max_hp": 1, "mount": ""}
			main.add_child(h)
			await main.get_tree().process_frame
			await main.get_tree().process_frame
			meshes_ok = meshes_ok and h.weapon_mesh != null and is_instance_valid(h.weapon_mesh)
			h.queue_free()
	check(meshes_ok, "hand meshes: katana, flying-sword focus (1-3 swords), fire guns, tomes + rod")
	GameData.config["combat_v2"] = old_flag
