extends RefCounted
## Combat v2 P1 (selftest --only=combat_v2, also in the full run): GDScript formulas vs the balance sim
## (data/combat_v2_golden.json from `python tools/balance/sim.py --golden`), crit v2 layer, monster
## crits, break gauge state machine, mob HP by level / mini-boss + boss HP by rule.
## The flag is switched on only inside this test and restored after.

const SelfTest = preload("res://scripts/selftest.gd")
const Combat = preload("res://scripts/server/combat.gd")
const CombatV2 = preload("res://scripts/server/combat_v2.gd")


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


static func near(a: float, b: float, rel: float = 1e-6) -> bool:
	return absf(a - b) <= rel * maxf(1.0, absf(b))


static func run(main: Node) -> void:
	print("-- combat v2 (P1 formulas)")
	var f := FileAccess.open("res://data/combat_v2_golden.json", FileAccess.READ)
	check(f != null, "golden file present")
	if f == null:
		return
	var gold: Dictionary = JSON.parse_string(f.get_as_text())
	check(not CombatV2.g().is_empty(), "combat_v2.json loads")
	# ---- parity with sim.py
	var bad := 0
	for e in gold["ev"]:
		var r: Array = CombatV2.ev_core(e["in"])
		if not (near(float(r[0]), float(e["tot"])) and near(float(r[1]), float(e["fl"]))):
			bad += 1
			if bad < 4:
				print("  ev mismatch ", r, " vs ", e["tot"], " ", e["fl"])
	check(bad == 0, "parity: expected hit (ev_core) %d cases, %d off" % [gold["ev"].size(), bad])
	bad = 0
	for e in gold["crit"]:
		var mon := {"lv": e["mon_lv"], "stats": {"vit": e["mon_vit"]}}
		if not near(CombatV2.mon_cres(mon, str(e["kind"])), float(e["cres"])) or not near(CombatV2.crit_mult(float(e["cd"]), float(e["cres"])), float(e["M"])) \
				or not near(CombatV2.super_crit_chance(float(e["luk"]), float(e["sc_gear"])), float(e["q"])) or not near(CombatV2.super_crit_mult(), float(e["S"])):
			bad += 1
	check(bad == 0, "parity: crit layer M / mon CRIT RES / super-crit (%d cases, %d off)" % [gold["crit"].size(), bad])
	bad = 0
	for e in gold["chance"]:
		if not near(CombatV2.crit_chance_raw(float(e["luk"]), float(e["gear"]), bool(e["katar"]), bool(e["auto"]), float(e["wheel"]), float(e["stack_bonus"]), float(e["mon_luk"])), float(e["v"])):
			bad += 1
	check(bad == 0, "parity: crit chance (%d cases, %d off)" % [gold["chance"].size(), bad])
	bad = 0
	for e in gold["flat"]:
		if not near(CombatV2.flat_per_pct(int(e["slv"]), float(e["k_class"]), float(e["L"])), float(e["v"])):
			bad += 1
	for e in gold["hp"]:
		if not near(CombatV2.hp_level_mult(float(e["lv"])), float(e["v"])) or not near(CombatV2.mini_curve(float(e["lv"])), float(e["mini"])) \
				or CombatV2.skill_lv_cap(int(e["lv"])) != int(e["slv"]) or not near(CombatV2.flat_scale(float(e["lv"])), float(e["fs"])):
			bad += 1
	check(bad == 0, "parity: flat-K, skill-level gate, HP level curve, mini curve (%d off)" % bad)
	bad = 0
	for e in gold["brk"]:
		if not near(CombatV2.break_fill(float(e["P"]), int(e["slv"]), float(e["brk"]), int(e["rank"]), float(e["talent"])), float(e["v"])):
			bad += 1
	for e in gold["moncrit"]:
		if not near(CombatV2.mon_crit_factor(str(e["kind"]), float(e["cres"])), float(e["v"])):
			bad += 1
	for e in gold["mini"]:
		var mh := CombatV2.rule_base_hp(int(e["lv"]), "mini", float(e["median"]))
		if not near(mh, float(e["mini"]) * float(e["median"])) or not near(CombatV2.rule_base_hp(int(e["lv"]), "boss", float(e["median"])), mh * float(e["boss_x"])):
			bad += 1
	check(bad == 0, "parity: break fill, monster crit factor, mini/boss HP rule (%d off)" % bad)
	# ---- rule sanity
	check(near(CombatV2.crit_mult(0, 0), 1.5) and CombatV2.crit_mult(1e9, 0) < 3.0 and CombatV2.crit_mult(500, 0) > 2.5, "crit multiplier bounded x1.5 .. x3.0")
	check(CombatV2.crit_mult(100, 300) == 1.5, "CRIT RES above CRIT DMG floors the multiplier at x1.5")
	check(near(CombatV2.super_crit_chance(9999, 0), 0.5), "super-crit chance capped at 50%")
	var mcb := CombatV2.mon_crit("boss", 0)
	var mcb2 := CombatV2.mon_crit("boss", 200)
	check(near(float(mcb["chance"]), 12.0) and near(float(mcb["mult"]), 2.0) and near(float(mcb2["mult"]), 1.5), "monster crit: boss 12% x2, x1.5 at CRIT RES 200")
	check(near(float(CombatV2.mon_crit("field", 0)["chance"]), 3.0) and near(float(CombatV2.mon_crit("mini", 0)["mult"]), 1.75), "monster crit: field 3%, mini-boss x1.75")
	check(CombatV2.skill_lv_cap(1) == 1 and CombatV2.skill_lv_cap(20) == 3 and CombatV2.skill_lv_cap(99) == 10, "skill level gate 1/10/20/.../99")
	check(near(CombatV2.break_rate(10), 1.25), "BRK(sLv10) = 1.25")
	# ---- live objects with the flag on
	var old_flag = GameData.config.get("combat_v2", false)
	GameData.config["combat_v2"] = true
	var ps = PlayerState
	var ch = ps.ch
	check(near(CombatV2.crit_dmg_rating(ch), float(ch.stat("str") + ch.stat("int") + 3 * ch.weapon_refine() + ch.equip_bonus("crit_dmg"))), "CRIT DMG rating = STR + INT + 3 x refine + gear")
	check(CombatV2.crit_res_rating(ch) >= float(ch.stat("vit")), "CRIT RES rating >= VIT (no RSL)")
	var field_id := ""
	var mini_id := ""
	var boss_id := ""
	for id in GameData.monsters:
		var m: Dictionary = GameData.monsters[id]
		if field_id == "" and not m.get("boss", false) and not m.get("miniboss", false) and int(m.get("lv", 1)) >= 20:
			field_id = id
		if mini_id == "" and m.get("miniboss", false):
			mini_id = id
		if boss_id == "" and m.get("boss", false):
			boss_id = id
	var fd: Dictionary = GameData.monsters[field_id]
	check(CombatV2.mob_max_hp(fd) == maxi(1, int(float(fd["hp"]) * float(GameData.config.get("mob_hp_rate", 1.0)) * CombatV2.hp_level_mult(int(fd["lv"])))), "field mob HP = data HP x rate x level curve (%s)" % field_id)
	if mini_id != "":
		var md0: Dictionary = GameData.monsters[mini_id]
		var mh := CombatV2.mob_max_hp(md0)
		check(mh > int(md0["hp"]), "mini-boss HP by rule (%s: %d -> %d)" % [mini_id, int(md0["hp"]), mh])
		check(CombatV2.mon_kind(md0) == "mini" and CombatV2.break_gauge_max("mini") == 400.0, "mini-boss break gauge 400")
	if boss_id != "":
		check(CombatV2.mob_max_hp(GameData.monsters[boss_id]) > int(GameData.monsters[boss_id]["hp"]) / 2, "boss HP by rule (%s)" % boss_id)
	# break gauge state machine on a real server mob
	var mob = null
	for m in main.get_tree().get_nodes_in_group("mobs"):
		if not m.dead and not m.is_boss and not m.is_mini:
			mob = m
			break
	var spawned := false
	var pe = main.me()
	if mob == null and pe != null and pe.map != null:
		# no field mob on this map (town / dungeon at this point of the full run): spawn a temporary one
		mob = pe.map.spawn_mob(field_id, pe.global_position + Vector3(3, 0, 0), 0.5, false)
		spawned = mob != null
	check(mob != null, "a server mob to break")
	if mob != null:
		mob.brk_gauge = 0.0
		mob.brk_immune_until = 0.0
		mob.buffs.remove("broken")
		var def0 := int(mob.combat_data().get("def", 0))
		check(not mob.add_break(60.0) and near(mob.brk_gauge, 60.0), "break: 60/100 does not break")
		check(mob.add_break(45.0) and mob.buffs.has("broken") and mob.brk_gauge == 0.0, "break: 105/100 breaks, gauge resets")
		check(bool(mob.combat_data().get("broken", false)) and int(mob.combat_data().get("def", 0)) == int(float(def0) * 0.5), "broken: hard DEF halved, flagged for +20%")
		check(not mob.add_break(500.0) and mob.brk_gauge == 0.0, "broken: no fill while broken")
		check(near(mob.buffs.time_left("broken"), 8.0, 0.05), "broken lasts 8 s")
		mob.buffs.remove("broken")
		check(not mob.add_break(500.0), "4 s immunity after the break")
		mob.brk_immune_until = 0.0
		mob.brk_gauge = 0.0
		# crits keep DEF in v2 (Classic crits ignore it); broken target takes +20%
		var md: Dictionary = mob.combat_data().duplicate(true)
		md["def"] = 0
		md["stats"]["vit"] = 0
		var md_def := md.duplicate(true)
		md_def["def"] = 60
		seed(777)
		var c0 := int(Combat.player_attack(ch, md, {"crit": true, "pct": 3000.0})["dmg"])
		seed(777)
		var c60 := int(Combat.player_attack(ch, md_def, {"crit": true, "pct": 3000.0})["dmg"])
		check(c60 < c0, "v2 crit keeps hard DEF (%d def0 vs %d def60)" % [c0, c60])
		var md_b := md.duplicate(true)
		md_b["broken"] = true
		seed(778)
		var n0 := int(Combat.player_attack(ch, md, {"sure_hit": true, "can_crit": false, "pct": 3000.0})["dmg"])
		seed(778)
		var nb := int(Combat.player_attack(ch, md_b, {"sure_hit": true, "can_crit": false, "pct": 3000.0})["dmg"])
		check(n0 > 20 and absi(nb - int(float(n0) * 1.2)) <= 1, "broken target takes +20%% (%d -> %d)" % [n0, nb])
		var r := Combat.player_attack(ch, md, {"crit": true})
		check(r.has("super") and bool(r["crit"]), "hit result carries crit + super flags")
		# super-crit only on a crit: forced LUK huge -> q = 50%; over many forced crits both tiers appear
		var sup := 0
		for i in 200:
			if bool(Combat.player_attack(ch, md, {"crit": true})["super"]):
				sup += 1
		var q := CombatV2.char_super_crit(ch)
		check(absf(float(sup) / 200.0 - q) < 0.12, "super-crit rate %d/200 ~ q %.2f" % [sup, q])
		var ns := 0
		for i in 100:
			if bool(Combat.player_attack(ch, md, {"sure_hit": true, "can_crit": false})["super"]):
				ns += 1
		check(ns == 0, "no super-crit without a crit")
		# monster crits on the player
		var mc := 0
		var mdat: Dictionary = mob.combat_data().duplicate(true)
		mdat["boss"] = true
		for i in 400:
			var rr := Combat.monster_attack(ch, mdat)
			if bool(rr.get("crit", false)):
				mc += 1
		check(mc > 0 and mc < 120, "boss crits the player sometimes (%d/400 incl. misses)" % mc)
	if spawned and is_instance_valid(mob) and not mob.dead:
		mob.respawn = false
		mob.die(null)
	GameData.config["combat_v2"] = old_flag
	var r_off := Combat.monster_attack(ch, GameData.monsters[field_id])
	check(not bool(r_off.get("crit", false)), "flag off: monsters never crit")
