extends RefCounted
## PvP step 1 - duels (server/pvp.gd, pvp_client.gd): selftest --only=pvp. A second player is spawned next to us on the
## real server: request / accept, no hits during the countdown, PvP damage x dmg_mult, the knock-out hit leaves 1 HP and
## ends the duel (no death, no EXP loss), leaving the ring loses, no hits on players outside a duel, the client banner.

const SelfTest = preload("res://scripts/selftest.gd")
const CharacterState = preload("res://scripts/server/character_state.gd")
const Pvp = preload("res://scripts/server/pvp.gd")


static func check(ok: bool, what: String) -> void:
	SelfTest.check(ok, "pvp: " + what)


static func _secs(main: Node, s: float) -> void:
	await main.get_tree().create_timer(s).timeout


static func run(main: Node) -> void:
	print("SELFTEST pvp")
	var srv = main.server
	var a = main.me()
	var c := CharacterState.new()
	c.new_character("Duelist B")
	var b = srv.add_player(c, true, 99)
	srv.enter_map(b, a.map.map_id, a.global_position + Vector3(3, 0, 0))
	await SelfTest.frames(main, 5)
	# no duel yet: a player is not a target
	check(not Pvp.can_hit(srv, a, b), "players cannot hit each other outside a duel")
	var res: Dictionary = srv.submit({"t": "attack", "target": b.id}, a.id)
	check(not bool(res.get("ok", true)), "attack command on a player outside a duel is refused")
	# request -> invite -> accept
	srv.submit({"t": "duel_req", "target": b.id}, a.id)
	check(Pvp._st(srv)["invites"].has(b.id), "duel request creates an invite for the target")
	srv.submit({"t": "duel_accept", "from": a.id}, b.id)
	var d = Pvp.duel_of(srv, a)
	check(d != null and Pvp.duel_of(srv, b) == d, "accepted: both are in one duel")
	check(not Pvp.can_hit(srv, a, b), "no hits during the countdown")
	await _secs(main, float(Pvp.CFG["countdown"]) + 0.3)
	check(Pvp.can_hit(srv, a, b) and Pvp.can_hit(srv, b, a), "after the countdown both can hit")
	# PvP damage scale
	b.ch.hp = b.ch.max_hp()
	var hp0: int = b.ch.hp
	a._apply_hit(b, {"dmg": 1000, "miss": false})
	var want := Pvp.scale(a, b, 1000)
	check(hp0 - int(b.ch.hp) == mini(want, hp0 - 1), "PvP hit x%.2f (%d -> %d)" % [float(Pvp.CFG["dmg_mult"]), hp0, int(b.ch.hp)])
	# knock-out: 1 HP, duel over, nobody dead
	var exp0: int = b.ch.base_exp
	a._apply_hit(b, {"dmg": 999999, "miss": false})
	check(int(b.ch.hp) == 1 and not b.dead and Pvp.duel_of(srv, a) == null, "the finishing hit leaves 1 HP and ends the duel (hp %d)" % int(b.ch.hp))
	check(int(b.ch.base_exp) == exp0, "losing a duel costs no EXP")
	check(not Pvp.can_hit(srv, a, b), "after the duel no more hits")
	# ring: a new duel, B walks out and stays out -> A wins
	b.ch.hp = b.ch.max_hp()
	srv.submit({"t": "duel_req", "target": b.id}, a.id)
	srv.submit({"t": "duel_accept", "from": a.id}, b.id)
	await _secs(main, float(Pvp.CFG["countdown"]) + 0.2)
	var d2 = Pvp.duel_of(srv, a)
	if d2 != null:
		b.global_position = d2["at"] + Vector3(float(d2["r"]) + 4.0, 0, 0)
		b.move_goal = null
	await _secs(main, float(Pvp.CFG["out_s"]) + 0.8)
	check(d2 != null and Pvp.duel_of(srv, a) == null, "leaving the ring for %d s loses the duel" % int(Pvp.CFG["out_s"]))
	# client: banner node exists after the duel events reached this client
	check(main.get_node_or_null("PvpClient") != null, "client PvP node created by the duel events")
	await field_tests(main, srv, a, b)
	srv.remove_player(b)
	await SelfTest.frames(main, 3)


## Step 2 - field PK (server/pvp.gd): levels, modes, zones, party, aggressor, karma, red rules, boss ring, decay,
## client name colours + the PK pill. --shotpk=<dir>: screenshot of the HUD pill + a red outlaw.
static func field_tests(main: Node, srv, a, b) -> void:
	print("-- field PK")
	var t0 := Pvp.now()
	SelfTest.clear_mobs(main)
	# a PK field spot: away from the town box of this map
	var fp := Vector3(14, 0, 14)
	main.teleport_player(fp)
	b.global_position = fp + Vector3(2.0, 0, 0)
	b.move_goal = null
	await SelfTest.frames(main, 3)
	for p in [a, b]:
		p.ch.pk = {}
		p.ch.hp = p.ch.max_hp()
		p.dead = false
		if p.has_meta("pk_orange"):
			p.remove_meta("pk_orange")
	check(Pvp.zone(a) == "free" and Pvp.zone(b) == "free", "the field is a PK zone (%s / %s)" % [Pvp.zone(a), Pvp.zone(b)])
	# low-level protection
	a.ch.base_lv = 20
	b.ch.base_lv = 20
	Pvp.pk(a.ch)["mode"] = "free"
	check(not Pvp.can_hit(srv, a, b), "no field PK below Base Lv %d" % int(Pvp.CFG["pk_min_lv"]))
	a.ch.base_lv = 50
	b.ch.base_lv = 50
	check(Pvp.can_hit(srv, a, b), "Free mode at Lv 50 in a PK zone: may attack")
	# Peace never attacks; switching to Free takes 10 s and not in combat
	Pvp.pk(a.ch)["mode"] = "peace"
	check(not Pvp.can_hit(srv, a, b) and Pvp.hostiles_near(a, b.global_position, 3.0).is_empty(), "Peace mode cannot attack players (single or AoE)")
	a.ch.combat_until = Pvp.now() + 5.0
	var r1: Dictionary = srv.submit({"t": "pk_mode", "mode": "free"}, a.id)
	check(not bool(r1.get("ok", true)), "no switch to Free in combat")
	a.ch.combat_until = 0.0
	var r2: Dictionary = srv.submit({"t": "pk_mode", "mode": "free"}, a.id)
	check(bool(r2.get("ok", false)) and Pvp.mode(a) == "peace" and a.has_meta("pk_switch_at"), "Free mode starts a %d s switch" % int(Pvp.CFG["free_switch_s"]))
	a.set_meta("pk_switch_at", Pvp.now() - 0.01)
	Pvp._tick_pk(srv, 0.3)
	check(Pvp.mode(a) == "free" and Pvp.can_hit(srv, a, b), "after the switch: Free mode, may attack")
	# Guild mode: only red / orange players
	srv.submit({"t": "pk_mode", "mode": "guild"}, a.id)
	check(Pvp.mode(a) == "guild" and not Pvp.can_hit(srv, a, b), "Guild mode does not attack an innocent")
	Pvp.pk(b.ch)["karma"] = int(Pvp.CFG["karma_red"])
	check(Pvp.can_hit(srv, a, b), "Guild mode attacks a red player")
	Pvp.pk(b.ch)["karma"] = 0
	Pvp.pk(a.ch)["mode"] = "free"
	# safe zone (town box) / party allies
	var md = a.map.get("data")
	if md != null and bool(md.get("has_town")):
		var tc: Vector3 = load("res://scripts/world_builder.gd").town_center()
		b.global_position = tc
		check(Pvp.zone(b) == "safe" and not Pvp.can_hit(srv, a, b), "nobody can be attacked in town")
		b.global_position = fp + Vector3(2.0, 0, 0)
	srv.party_invite(a, str(b.ch.char_name))
	srv.party_accept(b)
	check(Pvp.allies(srv, a, b) and not Pvp.can_hit(srv, a, b), "party members never hit each other")
	srv._party_leave(b, true)
	check(Pvp.can_hit(srv, a, b), "out of the party: hostile again")
	# --shotpk=<file>: the PK pill (aggressor) + a red outlaw, before anyone dies
	for arg in OS.get_cmdline_user_args():
		if arg.begins_with("--shotpk="):
			a.set_meta("pk_orange", Pvp.now() + 45.0)
			Pvp.pk(b.ch)["karma"] = int(Pvp.CFG["karma_red"])
			Pvp._tick_pk(srv, 0.3)
			b.global_position = a.global_position + Vector3(1.6, 0, 1.2)
			var rig = main.rig
			rig.yaw = 20.0
			rig.pitch = -18.0
			rig.dist = 7.0
			rig._apply()
			await SelfTest.frames(main, 40)
			main.get_viewport().get_texture().get_image().save_jpg(arg.substr(9), 0.9)
			print("SHOT pk")
			a.remove_meta("pk_orange")
			Pvp.pk(b.ch)["karma"] = 0
			b.global_position = fp + Vector3(2.0, 0, 0)
			Pvp._tick_pk(srv, 0.3)
	# hitting an innocent = aggressor; killing one = karma, the victim loses no EXP
	b.ch.base_exp = 1000
	a._apply_hit(b, {"dmg": 30, "miss": false})
	check(Pvp.orange(a) and Pvp.state_of(a) == "orange", "hitting an innocent: orange aggressor")
	a.remove_meta("pk_orange")
	a._apply_hit(b, {"dmg": 99999999, "miss": false})
	check(b.dead and Pvp.karma(a) == 1, "killing an innocent: dead, killer karma 1 (%d)" % Pvp.karma(a))
	check(int(b.ch.base_exp) == 1000, "a player killed by a player loses no EXP")
	_revive(b)
	# red: shop prices, bounty, EXP loss
	var base_price: int = a.ch.buy_price("red_tonic")
	Pvp.pk(a.ch)["karma"] = int(Pvp.CFG["karma_red"])
	check(Pvp.red(a) and Pvp.state_of(a) == "red", "karma %d: red name" % int(Pvp.CFG["karma_red"]))
	check(a.ch.buy_price("red_tonic") == int(round(base_price * float(Pvp.CFG["red_shop"]))), "red names pay x%.1f in shops (%d -> %d)" % [float(Pvp.CFG["red_shop"]), base_price, a.ch.buy_price("red_tonic")])
	Pvp.pk(b.ch)["mode"] = "free"
	var z0: int = b.ch.zeny
	a.ch.base_exp = 100000
	a.ch.hp = a.ch.max_hp()
	b._apply_hit(a, {"dmg": 99999999, "miss": false})
	check(a.dead and Pvp.karma(b) == 0, "killing a red player costs no karma")
	check(int(b.ch.zeny) == z0 + int(Pvp.CFG["bounty_per_karma"]) * int(Pvp.CFG["karma_red"]), "bounty paid (%d zeny)" % (int(b.ch.zeny) - z0))
	check(int(a.ch.base_exp) < 100000, "a red player killed by a player loses EXP (%d)" % (100000 - int(a.ch.base_exp)))
	_revive(a)
	Pvp.pk(a.ch)["karma"] = 0
	# level gap: hitting someone 20+ levels lower = red at once
	a.ch.base_lv = 75
	a._apply_hit(b, {"dmg": 10, "miss": false})
	check(Pvp.red(a), "hitting someone %d+ levels lower: red at once" % int(Pvp.CFG["low_gap"]))
	Pvp.pk(a.ch)["karma"] = 0
	a.ch.base_lv = 50
	a.remove_meta("pk_orange")
	# boss ring: free-for-all, no karma
	var boss = SelfTest.still_mob(main, "pudding_king", fp + Vector3(6, 0, 0))
	await SelfTest.frames(main, 2)
	check(Pvp.zone(a) == "boss" and Pvp.zone(b) == "boss", "within %d m of a live boss: boss ring" % int(Pvp.CFG["boss_ring"]))
	Pvp.pk(a.ch)["mode"] = "guild"
	check(Pvp.can_hit(srv, a, b), "boss ring: even Guild mode may attack anyone")
	b.ch.hp = b.ch.max_hp()
	a._apply_hit(b, {"dmg": 99999999, "miss": false})
	check(b.dead and Pvp.karma(a) == 0 and not Pvp.orange(a), "boss ring kill: no karma, no aggressor")
	_revive(b)
	SelfTest.clear_mobs(main)
	Pvp.pk(a.ch)["mode"] = "free"
	# karma decay
	Pvp.pk(a.ch)["karma"] = 1
	Pvp.pk(a.ch)["decay"] = float(Pvp.CFG["karma_decay_s"]) - 0.1
	Pvp._tick_pk(srv, 0.3)
	check(Pvp.karma(a) == 0, "karma decays 1 per %d min online" % int(float(Pvp.CFG["karma_decay_s"]) / 60.0))
	# client: name colours + the PK pill
	Pvp.pk(b.ch)["karma"] = int(Pvp.CFG["karma_red"])
	Pvp._tick_pk(srv, 0.3)
	await SelfTest.frames(main, 24)
	var pc = main.get_node_or_null("PvpClient")
	var bv = null
	for v in main.get_tree().get_nodes_in_group("remote_players"):
		if int(v.get("id")) == b.id:
			bv = v
	var nl = bv.get("name_label") if bv else null
	check(nl != null and (nl.modulate as Color).g < 0.5 and (nl.modulate as Color).r > 0.9, "client: the outlaw's name plate is red")
	check(pc != null and pc.pk_btn != null and is_instance_valid(pc.pk_btn) and pc.pk_btn.visible and str(pc.pk_self.get("mode", "")) == "free",
		"client: PK pill shows the mode (%s)" % (pc.pk_btn.text if pc and pc.pk_btn else "-"))
	# (the test raised Base Lv on the server directly; a real level-up refreshes the look)
	if bv:
		bv.set_look({"lv": int(b.ch.base_lv)})
	check(pc != null and pc.may_attack(bv), "client: clicking the outlaw offers an attack (my lv %d, its lv %d)" % [int(PlayerState.base_lv), int(bv.look.get("lv", 0)) if bv else -1])
	if pc and pc.pk_btn:
		pc.pk_btn.pressed.emit()
		await SelfTest.frames(main, 4)
		check(Pvp.mode(a) == "peace", "client: the PK pill cycles the mode (free -> peace)")
		Pvp.pk(a.ch)["mode"] = "free"
		Pvp._tick_pk(srv, 0.3)
		await SelfTest.frames(main, 6)
	# clean up
	for p in [a, b]:
		p.ch.pk = {}
		p.dead = false
		p.ch.hp = p.ch.max_hp()
		if p.has_meta("pk_orange"):
			p.remove_meta("pk_orange")
	Pvp._tick_pk(srv, 0.3)
	print("field PK tests %.1f s" % (Pvp.now() - t0))


## Server-side revive in place (the client hears "respawned" like after a real respawn, without the warp).
static func _revive(p) -> void:
	p.dead = false
	p.ch.hp = p.ch.max_hp()
	p.ev({"t": "respawned", "id": p.id})
