extends RefCounted
## Combat v2 skill executor (docs/COMBAT_V2_DESIGN.md 2.1-2.4): every skills.json entry with "v2": true
## goes through here instead of the classic per-skill code. Data-driven: pct x hits (constant per level) x
## class_power, + flat = P x K[sLv] x k_class x S(BaseLv) after DEF, shapes single / line / cone / circle,
## hits spread "over" seconds, break gauge fill, wheel nodes (job_wheel.gd), the class stack passive,
## ultimates (600% / 120 s / break x1.8 / +50% vs broken) and the few utilities the kits use (dash,
## back-leap, pull, Battle Intent, Armor Break, freeze, party heal). Numbers are balanced by
## tools/balance/sim.py (roster_v2.json -> gen_v2_skills.py).

const CombatV2 = preload("res://scripts/server/combat_v2.gd")
const Combat = preload("res://scripts/server/combat.gd")
const Skills2 = preload("res://scripts/server/skills2.gd")
const JobWheel = preload("res://scripts/server/job_wheel.gd")

const LINE_W := 1.6
const CONE_DEG := 60.0
const CONE_LEN := 6.0
const LINE_LEN := 9.0


static func is_v2(sid: String) -> bool:
	return bool(GameData.skill(sid).get("v2", false))


## class_power(L) = power x (L/50)^tilt x magic_curve(L) for magic kits (sim.py Char._kit).
static func class_power(c, sk: Dictionary) -> float:
	var jd: Dictionary = GameData.jobs.get(str(c.job), {})
	var p := float(jd.get("power", 1.0)) * pow(float(c.base_lv) / 50.0, float(jd.get("tilt", 0.0)))
	if str(sk.get("type", "phys")) == "magic":
		p *= CombatV2.interp(CombatV2.g().get("magic_curve", {}).get("points", [[1, 1.0]]), float(c.base_lv))
	return p


## "" when a v2 skill may be learned one more level (skill-level gate by Base Lv; ult from Base Lv 15).
static func learn_check(c, sid: String) -> String:
	var sk := GameData.skill(sid)
	var next := int(c.skills.get(sid, 0)) + 1
	if c.base_lv < int(sk.get("min_base", 1)):
		return "Requires Base Level %d." % int(sk.get("min_base", 1))
	if not bool(sk.get("ult", false)) and next > CombatV2.skill_lv_cap(c.base_lv):
		var gates: Array = CombatV2.g().get("skill_lv_gate", [])
		return "Skill Lv %d needs Base Level %d." % [next, int(gates[mini(next, gates.size()) - 1])]
	return ""


## Rider of the class stack passive at full stacks (jobs.json "stack.rider"), 0 below max.
static func rider(c, key: String) -> float:
	var st: Dictionary = GameData.jobs.get(str(c.job), {}).get("stack", {})
	var r: Dictionary = st.get("rider", {})
	if not r.has(key):
		return 0.0
	if key in ["crit_per_stack", "heal_per_stack"]:
		return float(r[key]) * float(c.v2_stacks)
	return float(r[key]) if c.v2_stacks >= float(CombatV2.g().get("stack", {}).get("max", 15)) - 0.001 else 0.0


const BLOCK_MULT := 0.5


## Shield block (worn "block" %, off-hand shields): true = the monster hit is halved (player_entity), and with
## Combat v2 on a class with a stack passive (Defend&Counter Bulwark) the block adds 1 stack and counts as combat.
static func roll_block(c, roll: float = -1.0) -> bool:
	var pct := float(c.equip_bonus("block"))
	if pct <= 0.0:
		return false
	if roll < 0.0:
		roll = randf() * 100.0
	if roll >= pct:
		return false
	on_block(c)
	return true


static func on_block(c) -> void:
	if not CombatV2.enabled() or GameData.jobs.get(str(c.job), {}).get("stack", {}).is_empty():
		return
	c.v2_stacks = minf(float(CombatV2.g().get("stack", {}).get("max", 15)), float(c.v2_stacks) + 1.0)
	c.v2_last_dmg = Time.get_ticks_msec() / 1000.0


## Every server frame (player_entity.tick): the stack passive grows 1/s while dealing damage, fades after 5 s.
static func tick(pe, delta: float) -> void:
	var c = pe.ch
	if not CombatV2.enabled() or not bool(GameData.jobs.get(str(c.job), {}).get("v2", false)):
		c.v2_stacks = 0.0
		c.v2_crit_bonus = 0.0
		c.v2_hit_bonus = 0
		return
	var st: Dictionary = CombatV2.g().get("stack", {})
	var now := Time.get_ticks_msec() / 1000.0
	if now - float(c.v2_last_dmg) > float(st.get("linger_s", 5)):
		c.v2_stacks = 0.0
	elif now - float(c.v2_last_dmg) < 1.0:
		c.v2_stacks = minf(float(st.get("max", 15)), float(c.v2_stacks) + delta)
	c.v2_crit_bonus = rider(c, "crit_per_stack")
	c.v2_hit_bonus = int(rider(c, "hit"))


## Casts a learned v2 skill (player_entity._execute calls this after the cooldown was set).
static func execute(pe, sid: String, lv: int, target) -> void:
	var sk := GameData.skill(sid)
	var c = pe.ch
	var ev := {"t": "skill", "id": pe.id, "skill": sid, "lv": lv}
	if target is Object:
		ev["target"] = target.id
	pe.ev(ev)
	# Asura Rider: the beast performs the skill - its move (displacement, timing) replaces the class's own movement
	var riding: bool = load("res://scripts/server/asura.gd").riding(c)
	var beast_t: Array = []
	if riding:
		var AC = load("res://scripts/server/asura_combat.gd")
		beast_t = AC.start_move(pe, AC.role_for(c, sid), target, sid)
	# utilities that move the caster happen first
	if target is Object and sk.has("dash") and not riding:
		_dash(pe, target, float(sk["dash"]))
	if sk.has("buff"):
		var b: Dictionary = sk["buff"]
		c.add_buff(str(b["id"]), float(b.get("dur", 3)), 1)
	if bool(sk.get("heal", false)):
		_party_heal(pe, sid, lv, float(sk.get("r", 8)))
		return
	if bool(sk.get("pull", false)) and target is Object:
		Skills2.lure(pe, target, lv)
	if sk.has("backstep") and target is Object and not riding:
		_backstep(pe, target, float(sk["backstep"]))
	if bool(sk.get("leap", false)) and target is Object and not riding:
		_leap(pe, target)
	var hits := int(sk.get("hits", 1))
	var over := float(sk.get("over", 0.0))
	var wait := float(sk.get("delay_hit", 0.0))
	var center: Vector3 = target.global_position if target is Object else (target if target is Vector3 else pe.global_position)
	var dir: Vector3 = (center - pe.global_position)
	dir.y = 0.0
	dir = dir.normalized() if dir.length() > 0.05 else Vector3(0, 0, 1)
	var from: Vector3 = pe.global_position
	if bool(sk.get("self_circle", false)) or str(sk.get("target", "")) == "self":
		center = pe.global_position
	# later classes: plant the class field (Sword Spirit / Star Field) and drag weak monsters together
	if bool(sk.get("plant", false)):
		plant_field(pe, center, lv)
	if bool(sk.get("gather", false)):
		_gather(pe, center, float(sk.get("r", 3.0)))
	for i in hits:
		var t := wait + (over * float(i) / float(maxi(1, hits)) if over > 0.0 else 0.0)
		if riding and not beast_t.is_empty():
			# the class skill's hits spread over the beast move's hit frames (same hits, same %)
			t = load("res://scripts/server/asura_combat.gd").hit_time(beast_t, i, hits)
		if t <= 0.0:
			_hit_wave(pe, sid, lv, target, center, from, dir)
		else:
			pe.map.delay(t, _hit_wave.bind(pe, sid, lv, target, center, from, dir))
	if bool(sk.get("ult", false)) and str(c.job) == "musician":
		# Nova Light: also heals the party 20% MaxHP
		_party_heal(pe, sid, lv, float(sk.get("r", 5)), 0.2)


## A delayed hit wave's target may be freed by then (`is` on a freed instance is a script error).
static func _alive(t) -> bool:
	return typeof(t) == TYPE_OBJECT and is_instance_valid(t) and not t.dead


static func _targets(pe, sid: String, sk: Dictionary, target, center: Vector3, from: Vector3, dir: Vector3) -> Array:
	var shape := str(sk.get("shape", "single"))
	if shape == "single":
		return [target] if _alive(target) else []
	var out: Array = []
	var r := float(sk.get("r", 3.0))
	var area_node := JobWheel.area_bonus(pe.ch, sid)
	match shape:
		"circle":
			for m in _near(pe, center, r * (1.0 + area_node)):
				out.append(m)
		"line", "cone":
			var reach := LINE_LEN if shape == "line" else CONE_LEN
			for m in _near(pe, from, reach * (1.0 + area_node) + 1.0):
				var d: Vector3 = m.global_position - from
				d.y = 0.0
				var along := d.dot(dir)
				if along < -0.5 or along > reach * (1.0 + area_node):
					continue
				if shape == "line" and (d - dir * along).length() > LINE_W:
					continue
				if shape == "cone" and d.length() > 0.5 and rad_to_deg(dir.angle_to(d.normalized())) > CONE_DEG * 0.5:
					continue
				out.append(m)
	if _alive(target) and not out.has(target) and shape != "circle":
		out.push_front(target)
	return out


## Monsters near `c` + the players the caster may hit there (PvP, server/pvp.gd).
static func _near(pe, c: Vector3, r: float) -> Array:
	var out: Array = pe.map.mobs_near(c, r)
	out.append_array(load("res://scripts/server/pvp.gd").hostiles_near(pe, c, r))
	return out


## One wave of hits of a skill on every monster in its shape.
static func _hit_wave(pe, sid: String, lv: int, target, center: Vector3, from: Vector3, dir: Vector3) -> void:
	if pe.dead or pe.map == null:
		return
	var sk := GameData.skill(sid)
	var c = pe.ch
	var ult := bool(sk.get("ult", false))
	var p := float(sk.get("pct", 100.0)) * class_power(c, sk)
	var jd: Dictionary = GameData.jobs.get(str(c.job), {})
	var slv := lv if not ult else CombatV2.skill_lv_cap(c.base_lv)
	var flat := p * CombatV2.flat_per_pct(slv, float(jd.get("k_class", 1.0)), float(c.base_lv))
	var magic := str(sk.get("type", "phys")) == "magic"
	var mod := JobWheel.mod(c, sid)
	var eh := JobWheel.extra_hit(c, sid)
	var brk_g: Dictionary = CombatV2.g().get("break", {})
	var fill := CombatV2.break_fill(p, slv, float(sk.get("brk", 1.0)), JobWheel.rank(c, sid + ":brk"), float(jd.get("flags", {}).get("brk_talent", 1.0)))
	if ult:
		fill = CombatV2.break_fill(p, slv, float(sk.get("brk_mult", CombatV2.g().get("ult", {}).get("brk_mult", 1.8))), JobWheel.rank(c, "ult:brk"), float(jd.get("flags", {}).get("brk_talent", 1.0)))
	var tg := _targets(pe, sid, sk, target, center, from, dir)
	# planted class field: +dmg while it lasts (Star Field: only while the caster stands in it) and the
	# Sword Spirit echo (no crit, no break) on monsters near it
	var fld := field_state(pe)
	var fdmg := 1.0 + float(fld.get("dmg", 0.0))
	var echo := 0.0
	if float(fld.get("echo", 0.0)) > 0.0:
		echo = float(fld["echo"]) + rider(c, "echo")
	var crowd := 1.0
	if bool(jd.get("flags", {}).get("crowd", false)) and tg.size() >= 3:
		crowd += 0.10 + (0.10 if tg.size() >= 4 else 0.0)
	var battle := 1.0 + (float(GameData.skill("dc_shield_rush").get("buff", {}).get("dmg", 0.05)) if c.buffs.has("battle_intent") else 0.0)
	for m in tg:
		if not is_instance_valid(m) or m.dead:
			continue
		var md: Dictionary = m.combat_data()
		var em := (1.0 + mod) * eh * crowd * battle * fdmg
		if bool(md.get("broken", false)):
			em *= 1.0 + 0.03 * float(JobWheel.rank(c, (sid if not ult else "ult") + ":split"))
			if ult:
				em *= 1.0 + float(sk.get("vs_broken", CombatV2.g().get("ult", {}).get("vs_broken", 0.5)))
		var opts := {"pct": p, "flat": flat, "skill": true, "can_crit": not bool(sk.get("nocrit", false)), "sure_hit": true,
			"stacks": float(c.v2_stacks), "extra_mult": em, "crit_bonus": float(c.v2_crit_bonus)}
		if sk.has("element"):
			opts["element"] = str(sk["element"])
		var r: Dictionary
		if magic:
			opts["hits"] = 1
			r = Combat.magic(c, md, opts)
		else:
			r = Combat.player_attack(c, md, opts)
		r["brk"] = fill
		pe._apply_hit(m, r)
		if m.dead:
			continue
		if echo > 0.0 and _near_field(fld, m.global_position):
			var eo := opts.duplicate()
			eo["pct"] = p * echo
			eo["flat"] = flat * echo
			eo["can_crit"] = false
			var re: Dictionary = Combat.magic(c, m.combat_data(), eo) if magic else Combat.player_attack(c, m.combat_data(), eo)
			re["brk"] = 0.0
			re["echo"] = true
			pe._apply_hit(m, re)
			if m.dead:
				continue
		if sk.has("armor_break"):
			var ab: Dictionary = sk["armor_break"]
			if str(ab.get("needs", "")) == "" or c.buffs.has(str(ab["needs"])):
				m.add_status("armor_break", float(ab.get("dur", 3)), 1)
		if sk.has("freeze") and randf() < float(sk["freeze"].get("chance", 0.0)):
			m.add_status("frozen", float(sk["freeze"].get("dur", 2)), 1)


# ---------------------------------------------------------------- class fields (later classes, 2026-09-30)

## Field definition of the caster's class (jobs.json flags.field), {} when it has none.
static func field_def(c) -> Dictionary:
	return GameData.jobs.get(str(c.job), {}).get("flags", {}).get("field", {})


## Plants the class field at `at` for its "dur" (one per caster: a new one absorbs the old). The ground
## effect is a ZoneEnt with the field's id (skill_zone.gd draws the planted swords / the star field).
static func plant_field(pe, at: Vector3, lv: int) -> void:
	var fd := field_def(pe.ch)
	if fd.is_empty() or pe.map == null:
		return
	var fid := str(fd.get("id", "field"))
	for z in pe.map.zones_of(pe, fid):
		pe.map._remove_area(z)
	var dur := float(fd.get("dur", 8.0))
	pe.map.add_zone(pe, fid, lv, at, float(fd.get("r", 3.0)), dur, 999.0, false, 999.0)
	pe.set_meta("v2_field", {"pos": Vector3(at.x, 0, at.z), "until": Time.get_ticks_msec() / 1000.0 + dur, "map": pe.map})


## The live field of this caster with its effects, {} when none / expired / (Star Field) the caster left it.
static func field_state(pe) -> Dictionary:
	if not pe.has_meta("v2_field"):
		return {}
	var f: Dictionary = pe.get_meta("v2_field")
	var fd := field_def(pe.ch)
	if fd.is_empty() or Time.get_ticks_msec() / 1000.0 > float(f.get("until", 0.0)) or f.get("map") != pe.map:
		return {}
	var p: Vector3 = f["pos"]
	var r := float(fd.get("r", 3.0))
	if bool(fd.get("inside", false)) and Vector2(pe.global_position.x - p.x, pe.global_position.z - p.z).length() > r:
		return {}
	var out := fd.duplicate()
	out["pos"] = p
	return out


static func _near_field(fld: Dictionary, at: Vector3) -> bool:
	if not fld.has("pos"):
		return false
	var p: Vector3 = fld["pos"]
	return Vector2(at.x - p.x, at.z - p.z).length() <= float(fld.get("r", 3.0))


## Gravity Core: normal monsters within 1.5 x r are dragged next to `center` (bosses / mini-bosses resist).
static func _gather(pe, center: Vector3, r: float) -> void:
	for m in pe.map.mobs_near(center, r * 1.5):
		if m.dead or m.is_boss or m.is_mini:
			continue
		var d: Vector3 = center - m.global_position
		d.y = 0.0
		if d.length() > 1.0:
			m.move_and_collide(d - d.normalized() * 0.8)
			m.position.y = 0.0
			m.route = PackedVector3Array()
			m.route_t = 0.0


static func _dash(pe, target, dist: float) -> void:
	var d: Vector3 = target.global_position - pe.global_position
	d.y = 0.0
	var go := maxf(0.0, minf(dist, d.length() - 1.4))
	if go > 0.05:
		pe.move_and_collide(d.normalized() * go)
		pe.position.y = 0.0


## Leap strike (Defend&Counter Skyfall Charge, owner 2026-09-30): the caster jumps from up to the skill range and lands
## LEAP_GAP m from the target on a walkable spot (the side it came from first, then around the target; no spot -> it
## stays where it is), immune to crowd control while airborne (Awaken.blocks_status reads "cc_guard_until").
## Event {"t": "leap", "id", "from", "to", "time"} for the view (the tank pilot's "leap" clip + leap_slam motion).
const LEAP_GAP := 1.3
const LEAP_TIME := 0.45


static func _leap(pe, target) -> void:
	var from: Vector3 = pe.global_position
	var tp: Vector3 = target.global_position
	var d: Vector3 = from - tp
	d.y = 0.0
	if d.length() <= LEAP_GAP + 0.3:
		return
	var base := atan2(d.z, d.x)
	var land = null
	for k in [0, 1, -1, 2, -2, 3, -3, 4]:
		var a := base + float(k) * PI / 4.0
		var p := tp + Vector3(cos(a), 0.0, sin(a)) * LEAP_GAP
		p.y = 0.0
		if pe.map == null or pe.map.walkable(p):
			land = p
			break
	if land == null:
		return
	pe.ch.set_meta("cc_guard_until", Time.get_ticks_msec() / 1000.0 + LEAP_TIME + 0.2)
	pe.global_position = land
	pe.position.y = 0.0
	pe.ev({"t": "leap", "id": pe.id, "from": from, "to": land, "time": LEAP_TIME})


static func _backstep(pe, target, dist: float) -> void:
	# balance v3: never step back from a mini-boss / boss - it follows, leaves its spot and its leash resets its HP
	# (Hunt Lv20 took 259 s on the Cinderbark Willow); the skill still hits
	if ("is_mini" in target and target.is_mini) or ("is_boss" in target and target.is_boss):
		return
	var d: Vector3 = pe.global_position - target.global_position
	d.y = 0.0
	if d.length() < 0.05:
		d = Vector3(0, 0, 1)
	pe.move_and_collide(d.normalized() * dist)
	pe.position.y = 0.0


## Party heal (Dawn Dance / Nova Light): the caster and every player within `r` m on the map.
## Classic heal formula x (1 + 1% per Encore stack); `pct_max` > 0 heals that share of MaxHP instead.
static func _party_heal(pe, sid: String, lv: int, r: float, pct_max: float = 0.0) -> void:
	var c = pe.ch
	var mult := 1.0 + rider(c, "heal_per_stack")
	for p in pe.map.players():
		if p.dead or p.global_position.distance_to(pe.global_position) > r:
			continue
		var n := int(float(p.ch.max_hp()) * pct_max) if pct_max > 0.0 else int(float(Combat.heal_amount(c, lv)) * mult)
		p.ch.heal(n)
		p.ev({"t": "heal_text", "id": p.id, "n": n})
	pe._fx("heal", {"id": pe.id})
	# Encore: a heal grants a stack
	c.v2_last_dmg = Time.get_ticks_msec() / 1000.0
	c.v2_stacks = minf(float(CombatV2.g().get("stack", {}).get("max", 15)), float(c.v2_stacks) + float(GameData.jobs.get(str(c.job), {}).get("stack", {}).get("rider", {}).get("heal_stack", 0)))
