extends RefCounted
## Second-job checks (selftest --only=jobs2, also part of the full run):
##  1. data from classic: every job's tables, skills' numbers, prerequisites, items, NPCs;
##  2. every job change path (rules: Job Lv 40+, no unspent points, guild fee, right first job), the
##     inherited skill tree, starter gear, the job EXP table;
##  3. one damage skill per class against its formula (seeded rolls), buffs (ASPD, crit, LUK...),
##     AoE over time (Glacier Gale freeze, Hallowed Ground heal), traps (Root Snare, Stone Burst Trap,
##     Disarm), Aegis of Mercy, Riposte Stance, Veil Step;
##  4. save / load of a second-job character;
##  5. network: a headless server + net front: party support skills sent by a client are validated
##     (party member, not learned, forbidden keys) and Revive brings a party member back.

const SelfTest = preload("res://scripts/selftest.gd")
const CharacterState = preload("res://scripts/server/character_state.gd")
const Combat = preload("res://scripts/server/combat.gd")
const Skills2 = preload("res://scripts/server/skills2.gd")
const Jobs2 = preload("res://scripts/server/jobs2.gd")
const NetServer = preload("res://scripts/net/net_server.gd")
const ServerLog = preload("res://scripts/server/server_log.gd")
const WorldBuilder = preload("res://scripts/world_builder.gd")
const ModelLoader = preload("res://scripts/model_loader.gd")

const PAIRS := {"knight": "swordman", "wizard": "mage", "hunter": "archer", "priest": "acolyte", "assassin": "thief", "blacksmith": "merchant"}
const ROOT := "user://selftest_jobs2"


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


static func frames(main: Node, n: int) -> void:
	await SelfTest.frames(main, n)


static func secs(main: Node, t: float) -> void:
	# simulated seconds (physics steps), not wall clock: a loading hitch must not eat server time
	var left := t
	while left > 0.0:
		await main.get_tree().physics_frame
		left -= main.get_physics_process_delta_time()


## Fresh local character: `first` at Job Lv 50 with its points spent (set to 0), 5000 gold.
static func make_first(main: Node, first: String) -> void:
	var ps = PlayerState
	var pe = main.me()
	ps.new_character("Tester")
	ps.gain_exp(0, 100000)
	ps.change_job(first)
	ps.gain_exp(20000, 50000000)
	ps.ch.skill_points = 0
	ps.ch.zeny = 5000
	pe._stop_cast()
	pe._clear_goals()
	SelfTest.ready_to_act(pe)


## Second job `job` with every one of its skills at max level (tests only), HP full.
static func make_second(main: Node, job: String) -> void:
	make_first(main, str(PAIRS[job]))
	var ps = PlayerState
	ps.change_job(job)
	for sid in GameData.jobs[job]["skills"]:
		ps.ch.skills[sid] = int(GameData.skill(sid).get("max", 1))
	ps.ch.stats["int"] = 60
	ps.ch.stats["dex"] = 60
	ps.ch.stats["str"] = 50
	ps.ch.hp = ps.ch.max_hp()
	var pe = main.me()
	pe.s2 = {}
	pe.skill_ally = 0
	SelfTest.ready_to_act(pe)


static func run(main: Node) -> void:
	print("SELFTEST jobs2")
	data_tests()
	await change_tests(main)
	await damage_tests(main)
	await mechanics_tests(main)
	save_tests(main)
	await net_test(main)
	# Knight's golden dragon (mount_test.gd; loaded at run time, it calls back into this script's helpers)
	await load("res://scripts/mount_test.gd").run(main, load("res://scripts/jobs2_test.gd"))
	SelfTest.clear_mobs(main)


# ---------------------------------------------------------------- 1. data

static func data_tests() -> void:
	for job in PAIRS:
		var d: Dictionary = GameData.jobs.get(job, {})
		check(not d.is_empty() and str(d.get("parent", "")) == PAIRS[job] and int(d.get("max_job", 0)) == 50, "%s is a second job of %s, Job Lv 50" % [job, PAIRS[job]])
		check((d.get("hp_base", []) as Array).size() == 99 and (d.get("job_bonus", []) as Array).size() == 50,
			"%s: HP table (99) + job bonus (50)" % job)
		var hp: Array = d.get("hp_base", [0])
		var hp1: Array = GameData.jobs[PAIRS[job]].get("hp_base", [0])
		check(int(hp[98]) > int(hp1[98]), "%s Lv99 base HP %d > %s %d" % [job, int(hp[98]), PAIRS[job], int(hp1[98])])
		for sid in d.get("skills", []):
			var sk := GameData.skill(sid)
			check(not sk.is_empty() and sk.has("desc") and sk.has("max"), "%s skill %s has data" % [job, sid])
			if not sk.get("passive", false):
				check(str(sk.get("target", "")) in ["self", "enemy", "ground"], "%s target kind %s" % [sid, str(sk.get("target", ""))])
			for r in sk.get("requires", {}):
				check(not GameData.skill(r).is_empty(), "%s prerequisite %s exists" % [sid, r])
		var st: Dictionary = d.get("starter", {})
		for iid in st:
			check(not GameData.item(iid).is_empty(), "%s starter item %s exists" % [job, iid])
		check(ModelLoader.resolve("hero_" + job) == "hero_" + str(PAIRS[job]) or ModelLoader.has_model("hero_" + job),
			"%s borrows the %s hero model until it has its own" % [job, PAIRS[job]])
		var npc := SelfTest.find_npc(job + "_master")
		check(not npc.is_empty() and str(npc.get("kind", "")) == "job" and str(npc.get("job", "")) == job, "%s job-change NPC in Aldoria" % job)
	# classic numbers (skill_db.yml / job_aspd.yml / job_exp.yml), spot checks
	check(is_equal_approx(GameData.skill_val("storm_gust", "cast", 10), 15.0), "Glacier Gale: 15 s cast at Lv 10")
	check(int(GameData.skill_val("jupitel_thunder", "hits", 10)) == 12, "Thunderlance 12 hits at Lv 10")
	check(is_equal_approx(GameData.skill_val("magnus_exorcismus", "dur", 10), 14.0), "Sacred Lattice 14 s")
	check(is_equal_approx(float(GameData.jobs["knight"]["aspd_base"]["two_hand_sword"]), 1.1), "Knight two-hand sword base delay 1.1 s (550 amotion)")
	check(GameData.jobs["assassin"]["aspd_base"].has("katar") and not GameData.jobs["wizard"]["aspd_base"].has("bow"), "Assassin katars, no Wizard bows")
	check(GameData.jexp_next("knight", 1) == int(GameData.exp_tables["job_second"][0]) and GameData.jexp_next("knight", 1) != GameData.jexp_next("swordman", 1),
		"second jobs use the second-class job EXP table (%d at JL1)" % GameData.jexp_next("knight", 1))
	check(GameData.skill("two_hand_mastery").get("requires", {}).has("sword_mastery") and GameData.skill("bowling_bash").get("requires", {}).get("two_hand_quicken", 0) == 10,
		"prerequisites mapped from skill_tree.yml")
	check(GameData.skill_name("storm_gust") == "Glacier Gale", "own skill names")


# ---------------------------------------------------------------- 2. job change

static func change_tests(main: Node) -> void:
	var ps = PlayerState
	for job in PAIRS:
		var first: String = PAIRS[job]
		make_first(main, first)
		var other: String = "wizard" if job != "wizard" else "knight"
		check(ps.can_change_job(other) != "", "%s cannot become a %s" % [first, other])
		ps.ch.skill_points = 3
		check("Spend" in ps.can_change_job(job), "%s: unspent skill points block the change (%s)" % [job, ps.can_change_job(job)])
		ps.ch.skill_points = 0
		ps.ch.job_lv = 39
		check("Job Level 40" in ps.can_change_job(job), "%s: needs Job Lv 40" % job)
		ps.ch.job_lv = 40
		ps.ch.zeny = 10
		check("fee" in ps.can_change_job(job), "%s: guild fee" % job)
		ps.ch.zeny = 5000
		check(ps.can_change_job(job) == "", "%s: first job at Job Lv 40 with no points left may change" % job)
		var ok: bool = main.send({"t": "job_change", "job": job})["ok"]
		check(ok and ps.job == job and ps.job_lv == 1 and ps.ch.skill_points == 0 and ps.ch.zeny == 5000 - Jobs2.change_fee(job),
			"%s -> %s through the server command (Job Lv 1, fee %d)" % [first, job, Jobs2.change_fee(job)])
		var st: Dictionary = ps.jd().get("starter", {})
		var gifts := true
		for iid in st:
			gifts = gifts and ps.count(iid) >= int(st[iid])
		check(gifts, "%s starter items handed out %s" % [job, str(st.keys())])
		var wid := ""
		for iid in st:
			if GameData.is_equip(iid) and ps.ch.can_equip(iid) == "":
				wid = iid
				break
		check(wid != "" and ps.ch.equip(wid), "%s can equip its starter weapon %s" % [job, wid])
		var tree: Array = ps.job_skills()
		check(tree.has(GameData.jobs[first]["skills"][0]) and tree.has(GameData.jobs[job]["skills"][0]), "%s keeps the %s skill tree" % [job, first])
		check(ps.can_change_job(first) != "" and ps.can_change_job(job) != "", "%s cannot change again" % job)
		ps.gain_exp(0, GameData.jexp_next(job, 1) * 20)
		check(ps.job_lv > 1 and ps.ch.skill_points > 0, "%s gains job levels + skill points (JL %d)" % [job, ps.job_lv])


# ---------------------------------------------------------------- 3. damage per class (formula = result)

## Seeded: the damage dealt by `cb` equals the expected roll made with the same seed.
static func _same(main: Node, mon, s: int, expected: Callable, act: Callable) -> Array:
	seed(s)
	var want: int = expected.call()
	mon.hp = 100000
	seed(s)
	act.call()
	await frames(main, 1)
	return [want, 100000 - mon.hp]


static func damage_tests(main: Node) -> void:
	var ps = PlayerState
	var pe = main.me()
	var at: Vector3 = pe.global_position + Vector3(1.2, 0, 0)
	# Knight: Skewer on a large monster = 3 hits of 110% + 10%/lv (one roll)
	make_second(main, "knight")
	ps.ch.equip("ashwood_pike")
	var mon = SelfTest.dummy(main, "pudding_slime", at, {"size": "large", "def": 10})
	var md: Dictionary = mon.combat_data()
	var r: Array = await _same(main, mon, 4242, func(): return int(Combat.player_attack(ps.ch, md, {"pct": 200.0 * 3, "hit_mult": 1.5, "can_crit": false})["dmg"]),
		func(): Skills2.on_enemy(pe, "pierce", 10, mon))
	check(r[0] > 0 and r[0] == r[1], "Knight Skewer Lv10 on a large target = 3 x 200%% (%d / %d)" % [r[1], r[0]])
	# Wizard: Thunderlance = Lv + 2 hits of 100% MATK wind
	make_second(main, "wizard")
	mon = SelfTest.dummy(main, "pudding_slime", at)
	md = mon.combat_data()
	r = await _same(main, mon, 777, func(): return int(Combat.magic(ps.ch, md, {"element": "wind", "hits": 12})["dmg"]),
		func(): Skills2.on_enemy(pe, "jupitel_thunder", 10, mon))
	check(r[0] > 0 and r[0] == r[1], "Wizard Thunderlance Lv10 = 12 x 100%% MATK wind (%d / %d)" % [r[1], r[0]])
	# Hunter: Stone Burst Trap = Lv x (DEX + 75) x (100 + INT) / 100, earth
	make_second(main, "hunter")
	mon = SelfTest.dummy(main, "pudding_slime", at)
	md = mon.combat_data()
	var want := int(Skills2.trap_dmg(ps.ch, "land_mine", 5) * GameData.element_pct("earth", str(md.get("element", "neutral")), int(md.get("element_lv", 1))) / 100.0)
	check(Skills2.trap_dmg(ps.ch, "land_mine", 5) == int(5 * (ps.stat("dex") + 75) * (100.0 + ps.stat("int")) / 100.0), "Stone Burst Trap formula")
	var kits: int = ps.count("snare_kit")
	main.send({"t": "skill", "id": "land_mine", "at": mon.global_position})
	await secs(main, 0.5)
	check(100000 - mon.hp == want and ps.count("snare_kit") == kits - 1, "Hunter Stone Burst Trap Lv5 sprung by a monster (%d / %d), 1 Snare Kit used" % [100000 - mon.hp, want])
	# Priest: Banish Undead on an undead boss = BaseLv + INT + 10 x Lv, holy
	make_second(main, "priest")
	mon = SelfTest.dummy(main, "rattlebones", at)
	mon.is_boss = true
	md = mon.combat_data()
	want = int(Skills2.turn_undead_dmg(ps.ch, 10) * GameData.element_pct("holy", str(md.get("element", "neutral")), int(md.get("element_lv", 1))) / 100.0)
	Skills2.on_enemy(pe, "turn_undead", 10, mon)
	await frames(main, 1)
	check(want > 0 and 100000 - mon.hp == want, "Priest Banish Undead fails on a boss -> BaseLv + INT + 100 holy (%d / %d)" % [100000 - mon.hp, want])
	# Assassin: Eightfold Rend = 300% + 50% x Lv
	make_second(main, "assassin")
	ps.ch.equip("nightfang_katar")
	mon = SelfTest.dummy(main, "pudding_slime", at)
	md = mon.combat_data()
	r = await _same(main, mon, 99, func(): return int(Combat.player_attack(ps.ch, md, {"pct": 800.0, "can_crit": false})["dmg"]),
		func(): Skills2.on_enemy(pe, "sonic_blow", 10, mon))
	check(r[0] > 0 and r[0] == r[1], "Assassin Eightfold Rend Lv10 = 800%% (%d / %d)" % [r[1], r[0]])
	# Blacksmith: Mighty Swing Lv5 = +25% on the ratio; Full Force = max weapon damage every swing
	make_second(main, "blacksmith")
	ps.ch.equip("forgehand_hammer")
	mon = SelfTest.dummy(main, "pudding_slime", at)
	md = mon.combat_data()
	ps.ch.add_buff("power_thrust", 60.0, 5)
	seed(31)
	var a := int(Combat.player_attack(ps.ch, md, {"can_crit": false})["dmg"])
	ps.ch.remove_buff("power_thrust")
	seed(31)
	var b := int(Combat.player_attack(ps.ch, md, {"pct": 125.0, "can_crit": false})["dmg"])
	check(a > 0 and a == b, "Blacksmith Mighty Swing Lv5 = +25%% ratio (%d / %d)" % [a, b])
	ps.ch.add_buff("maximize_power", 60.0, 5)
	var rolls := {}
	for i in 6:
		seed(100 + i)
		rolls[int(Combat.player_attack(ps.ch, md, {"can_crit": false, "sure_hit": true})["dmg"])] = true
	check(rolls.size() == 1, "Full Force: every swing deals the same (maximum) weapon damage %s" % str(rolls.keys()))
	ps.ch.remove_buff("maximize_power")


# ---------------------------------------------------------------- 3b. mechanics

static func mechanics_tests(main: Node) -> void:
	var ps = PlayerState
	var pe = main.me()
	var map = pe.map
	var evs: Array = []
	var grab := func(e): evs.append(e)
	main.server.event.connect(grab)
	# Knight: Greatblade Frenzy -30% attack delay with a two-handed sword only; Riposte Stance
	make_second(main, "knight")
	ps.ch.equip("warden_greatsword")
	var d0: float = ps.ch.attack_delay()
	main.send({"t": "skill", "id": "two_hand_quicken"})
	check(ps.buffs.has("two_hand_quicken") and absf(ps.ch.attack_delay() - d0 * 0.7) < 0.005, "Greatblade Frenzy: attack delay x0.7 (%.3f -> %.3f)" % [d0, ps.ch.attack_delay()])
	ps.ch.equip("ashwood_pike")
	check(absf(ps.ch.attack_delay() - ps.ch.base_delay() * (1.0 - (ps.stat("agi") * 4 + ps.stat("dex")) / 1000.0)) < 0.005, "Greatblade Frenzy does nothing with a spear")
	var mon = SelfTest.dummy(main, "pudding_slime", pe.global_position + Vector3(1.2, 0, 0))
	ps.ch.add_buff("counter_attack", 2.0, 5)
	var hp0: int = ps.hp
	pe.take_hit(mon)
	check(ps.hp == hp0 and mon.hp < 100000 and not ps.buffs.has("counter_attack"), "Riposte Stance: the monster's blow is parried and answered (%d dmg)" % (100000 - mon.hp))
	# Wizard: Glacier Gale freezes on the 3rd wave; the undead are never frozen
	make_second(main, "wizard")
	var spot: Vector3 = pe.global_position + Vector3(4, 0, 0)
	SelfTest.clear_mobs(main)
	var m1 = main._spawn("pudding_slime", spot, 0.0, false)
	var m2 = main._spawn("rattlebones", spot + Vector3(1, 0, 1), 0.0, false)
	for m in [m1, m2]:
		m.data = m.data.duplicate(true)
		m.data["speed"] = 0.0
		m.roam = 0.0
		m.max_hp = 100000
		m.hp = 100000
		m.atk_t = 1e6
	Skills2.on_ground(pe, "storm_gust", 10, spot)
	check(evs.any(func(e): return e["t"] == "spawn" and e.get("kind", "") == "zone" and e.get("skill", "") == "storm_gust"), "Glacier Gale: a zone spawns for the clients")
	await secs(main, 1.3)
	check(m1.buffs.has("frozen") and not m2.buffs.has("frozen") and m1.hp < 100000 and m2.hp < 100000, "Glacier Gale: frozen after the 3rd wave (undead stays unfrozen, both hit)")
	await secs(main, 3.8)
	check(map.zones_of(pe, "storm_gust").is_empty(), "Glacier Gale ends after 4.6 s")
	# Wizard: Mire Pool slows + lowers AGI / DEX
	SelfTest.clear_mobs(main)
	var mq = SelfTest.dummy(main, "pudding_slime", spot)
	var agi0 := Combat.mon_stat(mq.combat_data(), "agi")
	Skills2.on_ground(pe, "quagmire", 5, spot)
	await secs(main, 0.3)
	check(mq.buffs.has("quagmire") and Combat.mon_stat(mq.combat_data(), "agi") == maxi(0, agi0 - 50), "Mire Pool Lv5: AGI -50 in the mud")
	# Priest: Hallowed Ground heals everyone in it every second; Aegis of Mercy soaks blows
	make_second(main, "priest")
	SelfTest.clear_mobs(main)
	ps.ch.hp = 50
	evs.clear()
	Skills2.on_ground(pe, "sanctuary", 10, pe.global_position)
	await secs(main, 0.2)
	check(evs.any(func(e): return e["t"] == "heal_text" and int(e["id"]) == pe.id and int(e["n"]) == 777) and ps.hp >= mini(ps.max_hp(), 827), "Hallowed Ground Lv10 heals 777 a second")
	var mk = SelfTest.dummy(main, "pudding_slime", pe.global_position + Vector3(1.2, 0, 0), {"lv": 99, "stats": {"dex": 300}, "atk": [30, 30]})
	ps.ch.hp = ps.ch.max_hp()
	Skills2.on_self(pe, "kyrie_eleison", 10)
	var bar: Array = Skills2.kyrie_barrier(ps.ch, 10)
	hp0 = ps.hp
	for i in 3:
		pe.take_hit(mk)
	check(ps.buffs.has("kyrie_eleison") and ps.hp == hp0 and int(ps.buffs.list["kyrie_eleison"]["hp"]) < int(bar[0]), "Aegis of Mercy soaks the blows (barrier %d HP / %d hits)" % [int(bar[0]), int(bar[1])])
	# Priest: Seal of Judgement doubles the next hit, even on a boss
	mk.is_boss = true
	Skills2.on_enemy(pe, "lex_aeterna", 1, mk)
	mk.hp = 100000
	pe._apply_hit(mk, {"miss": false, "crit": false, "dmg": 100})
	check(100000 - mk.hp == 200 and not mk.buffs.has("aeterna"), "Seal of Judgement doubles one hit")
	# Priest: Fortune's Hymn LUK +30
	var luk0: int = ps.stat("luk")
	Skills2.on_self(pe, "gloria", 5)
	check(ps.stat("luk") == luk0 + 30, "Fortune's Hymn LUK +30")
	# Hunter: Root Snare roots a monster, Disarm gives the kit back, falcon auto-strike formula
	make_second(main, "hunter")
	var ms = SelfTest.dummy(main, "pudding_slime", pe.global_position + Vector3(2, 0, 0))
	Skills2.on_ground(pe, "ankle_snare", 5, ms.global_position)
	await secs(main, 0.2)
	check(ms.buffs.has("ankle") and map.zones_of(pe, "ankle_snare").is_empty(), "Root Snare: the monster that steps on it is rooted, the trap is used up")
	var kits: int = ps.count("snare_kit")
	var far: Vector3 = pe.global_position + Vector3(-2, 0, -2)
	main.send({"t": "skill", "id": "claymore_trap", "at": far})
	await secs(main, 0.3)
	check(map.zones_of(pe, "claymore_trap").size() == 1 and ps.count("snare_kit") == kits - 2, "Inferno Trap placed (2 Snare Kits)")
	SelfTest.ready_to_act(pe)
	main.send({"t": "skill", "id": "remove_trap", "at": far})
	await secs(main, 0.8)
	check(map.zones_of(pe, "claymore_trap").is_empty() and ps.count("snare_kit") == kits, "Disarm picks the trap up and returns its kits")
	check(Skills2.blitz_dmg(ps.ch) == (ps.stat("dex") / 10 + ps.stat("int") / 2 + 30 + 40) * 2, "Talon Strike (DEX/10 + INT/2 + Ironfeather x3 + 40) x 2")
	# Assassin: katars double the crit chance; Veil Step hides you while you walk
	make_second(main, "assassin")
	ps.ch.equip("cutter")
	var c0: float = ps.ch.crit_rate()
	ps.ch.equip("nightfang_katar")
	check(absf(ps.ch.crit_rate() - c0 * 2.0) < 0.001, "katar doubles the crit chance (%.1f -> %.1f)" % [c0, ps.ch.crit_rate()])
	main.send({"t": "skill", "id": "cloaking"})
	var mv = SelfTest.dummy(main, "pudding_slime", pe.global_position + Vector3(2, 0, 0))
	check(ps.is_hidden() and not mv.can_target(pe) and ps.ch.move_speed() > 0.0, "Veil Step: hidden from monsters, can still walk")
	SelfTest.ready_to_act(pe)
	main.send({"t": "skill", "id": "cloaking"})
	check(not ps.is_hidden(), "Veil Step toggles off")
	main.server.event.disconnect(grab)
	SelfTest.clear_mobs(main)


# ---------------------------------------------------------------- 4. save / load

static func save_tests(main: Node) -> void:
	var ps = PlayerState
	make_second(main, "priest")
	ps.ch.add_buff("gloria", 20.0, 5)
	var d: Dictionary = ps.ch.to_dict()
	var back = JSON.parse_string(JSON.stringify(d))
	var c2 := CharacterState.new()
	c2.from_dict(back)
	check(c2.job == "priest" and c2.job_lv == ps.job_lv and int(c2.skills.get("magnus_exorcismus", 0)) == 10 and int(c2.skills.get("heal", 0)) == int(ps.ch.skills.get("heal", 0)),
		"save / load keeps the second job, its skills and the first-job skills")
	check(c2.buffs.has("gloria") and c2.stat("luk") == ps.stat("luk") and c2.max_hp() == ps.max_hp() and GameData.jexp_next(c2.job, 1) == int(GameData.exp_tables["job_second"][0]), "save / load keeps buffs and the job's HP / EXP tables")


# ---------------------------------------------------------------- 5. network validation

static func net_test(main: Node) -> void:
	print("SELFTEST jobs2: headless server + net front")
	_wipe(ROOT)
	var LocalServer = load("res://scripts/server/local_server.gd")
	var hs = LocalServer.new()
	hs.autosave_enabled = false
	hs.store_dir = ROOT + "/srv/chars"
	hs.emit_snapshots = false
	hs.name = "Jobs2Server"
	main.add_child(hs)
	var ns = NetServer.new()
	ns.server = hs
	ns.data_root = ROOT + "/srv"
	ns.dedicated = true
	ns.pw_iter = 200
	ns.slog = ServerLog.new(ROOT + "/srv", false)
	main.add_child(ns)
	ns._open_store()
	hs.event.connect(ns._on_event)
	var ca := CharacterState.new()
	var cb := CharacterState.new()
	var cc := CharacterState.new()
	for pair in [[ca, "Pria", "acolyte", "priest"], [cb, "Kara", "swordman", "knight"], [cc, "Lone", "thief", "assassin"]]:
		var c = pair[0]
		c.new_character(str(pair[1]))
		c.gain_exp(0, 100000)
		c.change_job(str(pair[2]))
		c.gain_exp(20000, 50000000)
		c.skill_points = 0
		c.zeny = 5000
		c.change_job(str(pair[3]))
		c.stats["dex"] = 150
		c.hp = c.max_hp()
	ca.skills["kyrie_eleison"] = 10
	ca.skills["resurrection"] = 4
	ca.add_item("azure_shard", 2)
	var pa = hs.add_player(ca, true, 201)
	var pb = hs.add_player(cb, true, 202)
	var pc = hs.add_player(cc, true, 203)
	for pair in [[201, pa, "Pria"], [202, pb, "Kara"], [203, pc, "Lone"]]:
		ns._on_peer_connected(int(pair[0]))
		var s: Dictionary = ns.sessions[int(pair[0])]
		s["pid"] = pair[1].id
		s["name"] = pair[2]
		s["user"] = str(pair[2]).to_lower()
		s["state"] = "playing"
		s["known"] = {pair[1].id: true}
		s["econ"] = NetServer._econ_state(pair[1].ch)
		ns.pid_peer[pair[1].id] = int(pair[0])
	for p in [pa, pb, pc]:
		hs.enter_map(p, GameData.start_map(), WorldBuilder.TOWN_SPAWN + Vector3(randf_range(-1, 1), 0, 2))
	await frames(main, 3)
	hs.party_invite(pa, "Kara")
	hs.party_accept(pb)
	var sa: Dictionary = ns.sessions[201]
	# support skill on a party member, sent like a client does
	ns.handle_cmd(201, sa, {"t": "skill", "id": "kyrie_eleison", "self": true, "ally": pb.id})
	await frames(main, 3)
	check(cb.buffs.has("kyrie_eleison") and not ca.buffs.has("kyrie_eleison"), "net: Aegis of Mercy sent by a client lands on the party member")
	# not in the party -> refused by the server (before any catalyst is paid)
	SelfTest.ready_to_act(pa)
	var shard_a: int = ca.count("azure_shard")
	ns.handle_cmd(201, sa, {"t": "skill", "id": "kyrie_eleison", "self": true, "ally": pc.id})
	await frames(main, 3)
	check(not cc.buffs.has("kyrie_eleison") and ca.count("azure_shard") == shard_a, "net: support skill on a stranger is refused, nothing paid")
	# a skill the character does not have / a claimed value -> refused
	ns.handle_cmd(202, ns.sessions[202], {"t": "skill", "id": "storm_gust", "at": [0, 0]})
	await frames(main, 2)
	check(pb.map.zones_of(pb, "storm_gust").is_empty(), "net: a Knight cannot cast a Wizard skill")
	var strikes := int(ns.sessions[202].get("strikes", 0))
	ns.handle_cmd(202, ns.sessions[202], {"t": "skill", "id": "two_hand_quicken", "dmg": 99999})
	check(int(ns.sessions[202].get("strikes", 0)) > strikes and not cb.buffs.has("two_hand_quicken"), "net: a skill command with a claimed value is an anti-cheat strike")
	# Revive: only a fallen party member, costs an Azure Shard
	pb.die()
	await frames(main, 2)
	SelfTest.ready_to_act(pa)
	var shards: int = ca.count("azure_shard")
	ns.handle_cmd(201, sa, {"t": "skill", "id": "resurrection", "self": true, "ally": pb.id})
	await frames(main, 3)
	check(not pb.dead and cb.hp == maxi(1, cb.max_hp() * 80 / 100) and ca.count("azure_shard") == shards - 1, "net: Revive Lv4 brings the party member back with 80% HP")
	for p in [pa, pb, pc]:
		hs.remove_player(p)
	# the server saved them into its character store: a second job loads back from disk
	var CharStore = load("res://scripts/server/char_store.gd")
	var back := CharacterState.new()
	check(CharStore.load_into(back, "Pria", hs.store_dir) and back.job == "priest" and int(back.skills.get("resurrection", 0)) == 4,
		"net: the server's character file of a Priest loads back as a Priest")
	ns.queue_free()
	hs.queue_free()
	await frames(main, 2)
	_wipe(ROOT)


static func _wipe(d: String) -> void:
	if not DirAccess.dir_exists_absolute(d):
		return
	for sub in DirAccess.get_directories_at(d):
		_wipe(d.path_join(sub))
	for fn in DirAccess.get_files_at(d):
		DirAccess.remove_absolute(d.path_join(fn))
	DirAccess.remove_absolute(d)
