extends RefCounted
## One character: data + classic-style formulas (server side, one object per character; N1 step 1).
## The PlayerState autoload is a thin facade that forwards to the local character, so HUD / AUTO code
## keeps its API. No scene nodes here; to_dict / from_dict are the save format.

signal changed
signal message(text: String, color: Color)
signal leveled(kind: String)
## Short pickup / reward line for the HUD notification strip, e.g. ("Base EXP", "+27").
signal noticed(text: String, value: String)
## Experience actually gained (after the exp rates), for the AUTO session summary.
signal exp_gained(base: int, job: int)

const Buffs = preload("res://scripts/buffs.gd")
const Refine = preload("res://scripts/refine.gd")
const QuestLog = preload("res://scripts/quest_log.gd")
const Combat = preload("res://scripts/server/combat.gd")
const Jobs2 = preload("res://scripts/server/jobs2.gd")
const Comfort = preload("res://scripts/server/comfort.gd")
const CombatV2 = preload("res://scripts/server/combat_v2.gd")
const JobSlots = preload("res://scripts/server/job_slots.gd")
const JobWheel = preload("res://scripts/server/job_wheel.gd")
const Mounts = preload("res://scripts/server/mounts.gd")
## Life skills: fishing / cooking / homestead (server/life.gd, docs/LIFE_SKILLS.md).
const Life = preload("res://scripts/server/life.gd")
const Awaken = preload("res://scripts/server/awaken.gd")
const Asura = preload("res://scripts/server/asura.gd")
## Beast Constellation card dials (server/constellation.gd): album, dials, crests, Hunt Log.
const Constellation = preload("res://scripts/server/constellation.gd")

const STATS := ["str", "agi", "vit", "int", "dex", "luk"]
## "costume_weapon": weapon costume skin (weapon_skin.gd) - looks only, no stats.
const EQUIP_SLOTS := ["weapon", "armor", "garment", "head", "shoes", "accessory", "costume_weapon", "shield"]
const SAVE_PATH := "user://save.json"
const SAVE_VERSION := 4
## AUTO settings (auto_play.gd / auto_ui.gd). Saved as "auto"; missing keys fall back to these.
const AUTO_DEFAULTS := {
	# combat
	"leash": 20.0, "priority": "aggressive", "allow_boss": false, "allow_mini": false, "fight_back": true,
	"keep_distance": true, "manual": "pause", "manual_pause": 3.0, "exclude": [],
	# skills
	"slots": [true, true, true, true, true, true, true, true, true], "heal_pct": 60.0, "aoe_min": 3.0, "buffs": true, "auto_asura": true, "asura_big_only": true,
	# potions + resting
	"hp_pot": 50.0, "hp_item": "", "auto_sit": false, "sit_hp": 40.0,
	# loot
	"loot_on": true, "loot_use": true, "loot_etc": true, "loot_equip": true, "loot_card": true, "loot_rarity": "any",
	"loot_radius": 14.0, "loot_weight": 90.0,
	# safety
	"retreat_pct": 25.0, "retreat_wing": false, "auto_respawn": false, "stop_weight": 90.0, "stop_on_weight": false,
	# quest / path
	"auto_turnin": true, "show_route": true,
}
## One-click AUTO presets: only the listed keys change.
const AUTO_PRESETS := {
	"safe": {"leash": 15.0, "priority": "aggressive", "allow_boss": false, "allow_mini": false, "hp_pot": 65.0,
		"retreat_pct": 35.0, "retreat_wing": false, "keep_distance": true, "auto_sit": true, "aoe_min": 3.0, "auto_respawn": false},
	"aggressive": {"leash": 30.0, "priority": "nearest", "allow_boss": false, "allow_mini": true, "hp_pot": 40.0,
		"retreat_pct": 20.0, "keep_distance": false, "auto_sit": false, "aoe_min": 2.0, "fight_back": true},
	"boss": {"leash": 40.0, "priority": "lowest_hp", "allow_boss": true, "allow_mini": true, "hp_pot": 70.0,
		"retreat_pct": 30.0, "retreat_wing": false, "keep_distance": true, "auto_sit": false, "buffs": true},
}
const HOTBAR_SIZE := 9
const MAX_BASE_LV := 200
## Stat cap (Classic: 99; Boss raised to 200) and fastest attack (ASPD 199).
const MAX_STAT := 200
const MIN_ATTACK_DELAY := 0.02

var char_name := "Boss"
var job := "novice"
var base_lv := 1
var job_lv := 1
var base_exp := 0
var job_exp := 0
var stats: Dictionary = {}
var stat_points := 48
var skill_points := 0
var skills: Dictionary = {}
var hp := 40
var zeny := 500
## Stackable items (consumables, loot, ores, cards): id -> count.
var inventory: Dictionary = {}
## Equipment pieces in the bag, one instance each: {"id": item id, "refine": 0-10, "cards": [card ids]}.
var gear: Array = []
## Worn equipment: slot -> instance (same shape as `gear` entries).
var equipment: Dictionary = {}
var hotbar: Array = []
var pos := Vector3.ZERO
var save_pos := Vector3.ZERO
## Map the player is on / map of the save point (data/maps.json ids; main.gd keeps map_id current).
var map_id := ""
var save_map := ""
var is_new := true
## Knight on the golden dragon (Dragon Rider skill; rules in server/jobs2.gd). Saved.
var mounted := false
## Mounts for every class + Star Shards + Starwell pity (server/mounts.gd, starwell.gd). Saved as "stable".
var stable: Dictionary = {}
## Beast Constellation (server/constellation.gd): album / dials / crests / Card Dust ("constel") and the Hunt Log
## (kills per monster, own save key "hunt" so a kill resends only that small dictionary online).
var constel: Dictionary = {}
var hunt: Dictionary = {}
## Field PK record (server/pvp.gd): mode peace / guild / free, karma, karma decay seconds.
var pk: Dictionary = {}
## Arena 3v3 record (server/arena.gd): ratings per ladder, wins / losses.
var arena: Dictionary = {}
## Guild id (server/guild.gd registry), "" = none.
var guild := ""
## Life skills (server/life.gd): fishing / cooking XP, fish journal, recipes, current meal, homestead layout +
## garden. Saved as "life".
var life: Dictionary = {}
## Active buffs/debuffs on the player (Blessing, Increase AGI, Hiding...). Saved with their time left.
var buffs = Buffs.new()
var quests = QuestLog.new()
var auto_cfg: Dictionary = AUTO_DEFAULTS.duplicate(true)
## Combat v2 Job Slots (server/job_slots.gd): per-slot snapshots, the active index, the switch cooldown
## (server time, s), the one-time class choice after a migration, and the old classic job a save came from.
var job_slots: Array = []
var active_slot := 0
var slot_cd_until := 0.0
var class_pick := false
var migrated_from := ""
## Server time (s) until which the character counts as in combat (player_entity.gd sets it on hits).
var combat_until := 0.0
## Combat v2 skill wheel of the active slot ({node: rank}, job_wheel.gd), and runtime skill state:
## class stack passive (skills_v2.gd tick), its crit / HIT riders, when damage was last dealt, and
## cooldown end times (server time, s) so AUTO sees what is ready.
var wheel: Dictionary = {}
var v2_stacks := 0.0
## Awakening (server/awaken.gd; UI contract): gauge 0..100 and awakened-until time (s, server ticks clock), both
## synced in the char dict and saved; awaken_fill = per-second fill buckets (not saved)
var awaken_gauge := 0.0
var awaken_until := 0.0
var awaken_fill: Dictionary = {}
## Asura Rider (server/asura.gd): the saved collection / tickets / nodes, and the ride end + next-ride times
## (s, server ticks clock; synced in the char dict like awaken_until)
var asura: Dictionary = {}
var asura_until := 0.0
var asura_cd := 0.0
var v2_last_dmg := -99.0
var v2_crit_bonus := 0.0
var v2_hit_bonus := 0
var skill_cd: Dictionary = {}
## Bonus skill points already granted to the active class (job_slots.gd bonus_sp; per slot in job_slots).
var sp_bonus := 0
## % potions share a cooldown (config "potion_cd" s): time (Time ticks, s) when the next one may be drunk.
var pot_cd_until := 0.0


func new_character(n: String) -> void:
	char_name = n
	job = "novice"
	base_lv = 1
	job_lv = 1
	base_exp = 0
	job_exp = 0
	stats = {}
	for s in STATS:
		stats[s] = 1
	stat_points = 48
	skill_points = 0
	sp_bonus = 0
	skills = {"first_aid": 1}
	zeny = 500
	inventory = {"red_tonic": 5, "return_wing": 1}
	gear = [make_inst("dagger"), make_inst("cotton_shirt")]
	equipment = {}
	quests.clear()
	hotbar = []
	hotbar.resize(HOTBAR_SIZE)
	hotbar.fill("")
	hotbar[0] = "item:red_tonic"
	hotbar[1] = "skill:first_aid"
	buffs.clear()
	auto_cfg = AUTO_DEFAULTS.duplicate(true)
	stable = {}
	constel = {}
	asura = {}
	asura_until = 0.0
	asura_cd = 0.0
	hunt = {}
	pk = {}
	arena = {}
	guild = ""
	# the starter beast now, so its collection bonus is in max_hp before hp is filled (a new hero starts full)
	Asura.ensure(self)
	Constellation.touch(self)
	life = {}
	job_slots = []
	active_slot = 0
	class_pick = false
	migrated_from = ""
	if CombatV2.enabled():
		# v2: no Novice - start as the default class and choose on the first login (job_slots.gd)
		job = str(JobSlots.cfg().get("pick_default", "defend_counter"))
		class_pick = true
		for k in jd().get("starter", {}):
			add_item(k, int(jd()["starter"][k]))
		JobSlots.grant_bonus_sp(self)
		JobSlots.sync(self)
	hp = max_hp()
	is_new = true
	changed.emit()


func msg(t: String, c: Color = Color.WHITE) -> void:
	message.emit(t, c)


func notice(text: String, value: String) -> void:
	noticed.emit(text, value)


# ---------------------------------------------------------------- derived stats
# Formulas follow classic; see docs/CLASSIC_SYSTEMS.md (section numbers in comments).

func jd() -> Dictionary:
	return GameData.jobs.get(job, {})


func job_name() -> String:
	return str(jd().get("name", job))


func max_job_lv() -> int:
	return int(jd().get("max_job", 10))


## Flat bonus from worn equipment and the cards slotted in it: a top-level key ("def", "max_hp",
## "batk", "hit", "flee", "crit", "perfect_dodge") or a stat inside "stats" ("str".."luk").
func equip_bonus(key: String) -> int:
	var t := 0
	for slot in equipment:
		var inst: Dictionary = equipment[slot]
		t += _item_bonus(str(inst["id"]), key)
		for c in inst["cards"]:
			t += _item_bonus(str(c), key)
	if not wheel.is_empty():
		t += JobWheel.bonus(self, key)
	# owned mounts: small permanent collection stats (server/mounts.gd)
	if not stable.is_empty():
		t += Mounts.bonus(self, key)
	# Beast Constellation: echo blocks, resonance, crests, album milestones (server/constellation.gd, BR-capped)
	if not constel.is_empty():
		t += Constellation.bonus(self, key)
	# Asura Rider: owned beasts' collection stats + the ridden beast's nodes (server/asura.gd)
	if not asura.is_empty():
		t += Asura.bonus(self, key)
	# guild level perks for members online (server/guild_act.gd)
	if has_meta("guild_perks"):
		t += int((get_meta("guild_perks") as Dictionary).get(key, 0))
	# life skills: the current meal + the homestead's comfort tier (server/life.gd)
	if not life.is_empty():
		t += Life.bonus(self, key)
	if not (key in STATS):
		# conditional card bonuses ("cond": [{"stat": "str", "min": 80, "batk": 20}], Giant Whisper Card);
		# never for stats themselves, so reading a stat here cannot recurse
		for e in worn_list("cond"):
			if e.has(key) and stat(str(e["stat"])) >= int(e["min"]):
				t += int(e[key])
	return t


## Every worn item / card's list-valued key concatenated ("autospell", "cond").
func worn_list(key: String) -> Array:
	var out: Array = []
	for slot in equipment:
		var inst: Dictionary = equipment[slot]
		var ids: Array = [str(inst["id"])]
		ids.append_array(inst["cards"])
		for id in ids:
			out.append_array(GameData.item(str(id)).get(key, []))
	return out


## Skill level granted by worn cards ("grant_skill", e.g. Smokie Card = Hiding 1).
func granted_skill_lv(id: String) -> int:
	return card_pct("grant_skill", id)


static func _item_bonus(id: String, key: String) -> int:
	var it: Dictionary = GameData.item(id)
	var st: Dictionary = it.get("stats", {})
	return int(it.get(key, 0)) + int(st.get(key, 0))


## Card % bonuses of one category summed over everything worn (4.6: same category adds up).
## kind: add_race / add_size / add_ele / add_class (damage dealt), sub_race / sub_ele (damage taken).
func card_pct(kind: String, key: String) -> int:
	var t := 0
	for slot in equipment:
		var inst: Dictionary = equipment[slot]
		var ids: Array = [str(inst["id"])]
		ids.append_array(inst["cards"])
		for id in ids:
			var d: Dictionary = GameData.item(str(id)).get(kind, {})
			t += int(d.get(key, 0))
	# Beast Constellation: awakened cards (race / size capped) + same-element links (add_ele)
	if not constel.is_empty():
		t += Constellation.pct(self, kind, key)
	return t


## Chance % that a normal hit inflicts `eff` ("add_eff", Skeleton Card: stun 2%), summed over worn cards.
func card_eff_chance(eff: String) -> float:
	var t := 0.0
	for slot in equipment:
		var inst: Dictionary = equipment[slot]
		var ids: Array = [str(inst["id"])]
		ids.append_array(inst["cards"])
		for id in ids:
			t += float(GameData.item(str(id)).get("add_eff", {}).get(eff, 0.0))
	return t


## Weapon-card damage multiplier vs one monster (4.1 step 11): categories multiply,
## e.g. +20% race and +20% element = x1.44.
func card_mult(mon: Dictionary) -> float:
	var m := (100.0 + card_pct("add_race", str(mon.get("race", "")))) / 100.0
	m *= (100.0 + card_pct("add_ele", str(mon.get("element", "neutral")))) / 100.0
	m *= (100.0 + card_pct("add_size", str(mon.get("size", "medium")))) / 100.0
	m *= (100.0 + card_pct("add_class", "boss" if bool(mon.get("boss", false)) else "normal")) / 100.0
	return m


## Hidden "+N" stat bonus from job level (1.3).
func job_bonus(s: String) -> int:
	var jb: Array = jd().get("job_bonus", [])
	if jb.is_empty():
		return 0
	var row: Dictionary = jb[clampi(job_lv, 1, jb.size()) - 1]
	return int(row.get(s, 0))


## Bonus shown as "+N" in the status window (job bonus + equipment + skills/buffs).
func stat_bonus(s: String) -> int:
	return job_bonus(s) + equip_bonus(s) + skill_stat_bonus(s)


## Passive skills and active buffs that raise a stat (10.3 Owl's Eye, 10.4 Blessing / Increase AGI,
## 10.3 Improve Concentration = % of base + job stats only).
func skill_stat_bonus(s: String) -> int:
	var n := 0
	if s == "str" or s == "int" or s == "dex":
		n += buffs.lv("blessing")
	if s == "dex":
		n += skill_lv("owls_eye")
	if s == "agi" and buffs.has("increase_agi"):
		n += 2 + buffs.lv("increase_agi")
	if (s == "agi" or s == "dex") and buffs.has("improve_concentration"):
		n += (int(stats.get(s, 1)) + job_bonus(s)) * (2 + buffs.lv("improve_concentration")) / 100
	n += Jobs2.stat_bonus(self, s)
	return n


func stat(s: String) -> int:
	return int(stats.get(s, 1)) + stat_bonus(s)


func _table(key: String, lv: int, fallback: float) -> float:
	var t: Array = jd().get(key, [])
	if lv >= 1 and lv <= t.size():
		return float(t[lv - 1])
	if lv > t.size() and t.size() >= 2:
		# past the classic table (Lv 99): keep growing at the table's last per-level step
		return float(t[-1]) + float(lv - t.size()) * (float(t[-1]) - float(t[-2]))
	return fallback


# 2. MaxHP = floor(BaseHP * (1 + VIT/100)) + flat bonuses
func max_hp() -> int:
	var base := _table("hp_base", base_lv, 35.0 + base_lv * float(jd().get("hp_growth", 5)))
	# Combat v2 role bonus (combat_v2.json role_bonus: casters / archers / support +HP%) adds to the HP rate
	return (int(base * (1.0 + stat("vit") / 100.0)) + equip_bonus("max_hp")) * (100 + equip_bonus("max_hp_rate") + int(CombatV2.role_bonus(self, "hp"))) / 100


## Item id worn in a slot ("" = empty).
func equipped_id(slot: String) -> String:
	return str(equipment[slot]["id"]) if equipment.has(slot) else ""


func weapon() -> Dictionary:
	return GameData.item(equipped_id("weapon"))


func weapon_refine() -> int:
	return int(equipment["weapon"]["refine"]) if equipment.has("weapon") else 0


## 8. Refine ATK of the weapon (added after DEF).
func refine_atk() -> int:
	if not equipment.has("weapon"):
		return 0
	return Refine.weapon_atk(weapon_lv(), weapon_refine())


## 8. Over-upgrade: max of the random 1..N added to each hit (0 at or below the safe limit).
func over_upgrade() -> int:
	if not equipment.has("weapon"):
		return 0
	return Refine.over_upgrade(weapon_lv(), weapon_refine())


func weapon_type() -> String:
	var w := weapon()
	return "bare_hand" if w.is_empty() else str(w.get("wtype", "bare_hand"))


## Element of weapon attacks: a coating (Venom Coat / Consecrate Blade) or the weapon's own.
func weapon_element() -> String:
	var e := Jobs2.weapon_element(self)
	return e if e != "" else str(weapon().get("element", "neutral"))


## Damage-taken reduction % for an element: armor cards + Forge Hide.
func sub_ele_pct(el: String) -> int:
	return card_pct("sub_ele", el) + Jobs2.sub_ele(self, el)


func is_ranged() -> bool:
	return weapon_type() in ["bow", "instrument", "whip", "gun"]


## Normal-attack reach in metres (1 classic cell ~ 1 m). Bows: 9 + Vulture's Eye. Fire guns 7 and Flying
## Sword focus weapons 5 (they shoot without ammo, SHOT_WEAPONS).
func attack_range() -> float:
	if weapon_type() == "bow":
		return 9.0 + skill_lv("vultures_eye")
	return float(SHOT_WEAPONS.get(weapon_type(), 1.7))


## Weapon types whose normal attack is a projectile without ammo (-> reach in m).
const SHOT_WEAPONS := {"gun": 7.0, "flying_sword": 5.0}


## True when the normal attack is an ammo-free projectile (fire gun, flying sword).
func shoots() -> bool:
	return SHOT_WEAPONS.has(weapon_type())


## ATK of the arrows in the bag (0 without a bow or without arrows).
func ammo_atk() -> int:
	if weapon_type() != "bow" or count("arrow") <= 0:
		return 0
	return int(GameData.item("arrow").get("atk", 0))


# 3.1 Status ATK (melee: STR main, ranged: DEX main)
func status_atk() -> int:
	var main := stat("dex") if is_ranged() else stat("str")
	var other := stat("str") if is_ranged() else stat("dex")
	var b := main / 10
	return main + b * b + other / 5 + stat("luk") / 5


func weapon_atk() -> int:
	return int(weapon().get("atk", 0)) + Jobs2.watk_bonus(self)


func weapon_lv() -> int:
	return int(weapon().get("wlv", 1))


func mastery_atk() -> int:
	var wt := weapon_type()
	# second-job masteries (spear / two-hand sword / mace / katar, Arms Study, Grip Binding)
	var n := Jobs2.mastery(self, wt)
	if wt == "dagger" or wt == "sword":
		n += skill_lv("sword_mastery") * 4
	# Combat v2 class mastery (jobs.json flags.mastery: Defend&Counter 40, Shadow Assassin 30)
	n += CombatV2.class_mastery(self)
	return n


## Mastery ATK against one monster: adds Demon Bane vs Undead/Demon (10.4).
func mastery_vs(mon: Dictionary) -> int:
	var n := mastery_atk()
	if skill_lv("demon_bane") > 0 and is_undead_or_demon(mon):
		n += skill_lv("demon_bane") * (base_lv / 20 + 3)
	n += Jobs2.mastery_vs(self, mon)
	return n


func atk_display() -> int:
	return status_atk() + equip_bonus("batk") + weapon_atk() + refine_atk() + mastery_atk()


# 3.2 MATK (Rod-type weapons add a % on top)
func matk_min() -> int:
	var i := stat("int")
	return i + (i / 7) * (i / 7) + equip_bonus("matk")


func matk_max() -> int:
	var i := stat("int")
	return i + (i / 5) * (i / 5) + equip_bonus("matk")


func matk_rate() -> float:
	return 1.0 + float(weapon().get("matk_rate", 0)) / 100.0


# 3.4 soft MDEF (shown in the status window)
func soft_mdef() -> int:
	return stat("int") + stat("vit") / 2


## Hard MDEF from worn gear ("mdef", off-hand shields); status display only - monsters have no magic attacks yet.
func hard_mdef() -> int:
	return mini(100, equip_bonus("mdef"))


# 3.9 variable cast time multiplier: 1 - DEX/150, 0 at 150 DEX
func cast_rate() -> float:
	return clampf(1.0 - stat("dex") / 150.0, 0.0, 1.0)


# 3.3 DEF (+ 8. armor refine: 0.7 per level over all armor pieces, rounded)
func hard_def() -> int:
	var r := 0
	for slot in equipment:
		if slot != "weapon" and Refine.kind(str(equipment[slot]["id"])) == "armor":
			r += int(equipment[slot]["refine"])
	return mini(100, equip_bonus("def") + Refine.armor_def(r) + int(CombatV2.role_bonus(self, "def")))


## Max VIT-DEF shown in the status window (classic display formula).
func soft_def() -> int:
	var v := stat("vit")
	return v / 2 + maxi(int(v * 0.3), v * v / 150 - 1)


## Random VIT-DEF actually subtracted from a hit on the player (server/combat.gd).
func roll_soft_def(mon: Dictionary = {}) -> int:
	return Combat.roll_soft_def(self, mon)


# 3.5
func hit() -> int:
	return base_lv + stat("dex") + skill_lv("vultures_eye") + equip_bonus("hit") + Jobs2.hit_bonus(self) + v2_hit_bonus


func flee() -> int:
	return base_lv + stat("agi") + 3 * skill_lv("improve_dodge") + equip_bonus("flee") + int(CombatV2.class_flag(self, "flee"))


func perfect_dodge() -> float:
	return 1.0 + stat("luk") / 10.0 + equip_bonus("perfect_dodge")


# 3.6 (classic value; classic uses LUK/3)
func crit_rate() -> float:
	return (1.0 + stat("luk") * 0.3 + equip_bonus("crit")) * Jobs2.crit_mult(self)


# 3.7 ASPD: base delay per job x weapon type, reduced by (4*AGI + DEX)/1000, floor 0.2 s (ASPD 190)
func base_delay() -> float:
	var t: Dictionary = jd().get("aspd_base", {})
	if t.has(weapon_type()):
		return float(t[weapon_type()])
	return float(jd().get("base_delay", 1.2)) * float(weapon().get("delay_mod", 1.0))


## Seconds between normal attacks. Combat v2 caps the rate (combat_v2.json haste.auto_min_delay); the ASPD past
## the cap becomes Haste (haste_pct).
func attack_delay() -> float:
	return maxf(CombatV2.auto_min_delay(), raw_attack_delay()) / overweight_mult()


## Owner 2026-09-30: at config "overweight_pct" (90) % weight you still fight, but attacks, skill cooldowns and
## after-skill delays run at config "overweight_speed_mult" (0.8 = 20% slower) and nothing is picked up.
func overweight() -> bool:
	return weight_pct() >= float(GameData.config.get("overweight_pct", 90.0))


## Speed factor of an overweight character (1.0 otherwise); delays / cooldowns are divided by it.
func overweight_mult() -> float:
	return clampf(float(GameData.config.get("overweight_speed_mult", 0.8)), 0.1, 1.0) if overweight() else 1.0


## Haste % (Combat v2): skill cooldown reduction from the ASPD above the auto-attack cap (CombatV2.haste).
func haste_pct() -> float:
	return CombatV2.haste(self) * 100.0


## Classic attack delay before the Combat v2 cap (ASPD 199 floor).
func raw_attack_delay() -> float:
	# equipment/card "aspd_rate" = +N% attack speed (Classic bAspdRate: delay x (100 - N) / 100); Combat v2 counts
	# ASPD potions at haste.potion_mult of their rate
	var pot := float(buffs.lv("aspd_potion"))
	if CombatV2.enabled():
		pot *= float(CombatV2.g().get("haste", {}).get("potion_mult", 1.0))
	var rate := clampf(1.0 - (equip_bonus("aspd_rate") + pot + Jobs2.aspd_pct(self)) / 100.0, 0.3, 1.0)
	# riding the dragon: +50% delay, -10% per Dragon Bond level (Jobs2.aspd_mult)
	rate *= Jobs2.aspd_mult(self)
	# ASPD cap 199 (Boss: max 200; 200 would be a zero delay) = 0.02 s between swings
	return maxf(MIN_ATTACK_DELAY, base_delay() * (1.0 - (stat("agi") * 4 + stat("dex")) / 1000.0) * rate)


func aspd() -> int:
	return int(200.0 - attack_delay() * 50.0)


# 3.8 (+25% with Increase AGI; cannot move while hidden)
func move_speed() -> float:
	if buffs.has("hiding"):
		return 0.0
	# Increase AGI and riding share one +25% bonus (classic: the larger one, they do not stack)
	var fast: bool = buffs.has("increase_agi") or Jobs2.speed_bonus(self)
	# riding a mount (server/mounts.gd, +40..55%): the larger bonus counts, no stacking
	# awakened (server/awaken.gd, +50%): one more candidate for the largest bonus - it never stacks with a mount / Increase AGI
	return 5.0 * maxf(maxf(1.25 if fast else 1.0, Mounts.speed_mult(self)), maxf(Awaken.speed_mult(self), Asura.speed_mult(self))) * Jobs2.speed_mult(self)


## Mount shown under the character ("" = on foot): the Knight's golden dragon (server/jobs2.gd).
func mount_id() -> String:
	# riding an asura: "asura:<id>" (the client builds the beast from data/asura.json, asura_ride.gd)
	if asura_until > 0.0 and Asura.riding(self):
		return "asura:" + Asura.active_id(self)
	var dr := Jobs2.mount_of(self)
	return dr if dr != "" else Mounts.ridden(self)


## Hidden from monsters: Hiding (cannot act) or Veil Step (Assassin Cloaking: can walk and attack).
func is_hidden() -> bool:
	return buffs.has("hiding") or Jobs2.is_veiled(self)


## Counts buffs down (called by the player node every physics frame).
func tick_buffs(delta: float) -> void:
	var gone: Array = buffs.tick(delta)
	for id in gone:
		msg("%s has worn off." % Buffs.label(id), Color(0.75, 0.75, 0.85))
	if not gone.is_empty():
		clamp_vitals()
		changed.emit()


func add_buff(id: String, dur: float, lv: int = 1) -> void:
	buffs.add(id, dur, lv)
	clamp_vitals()
	changed.emit()


func remove_buff(id: String) -> void:
	if buffs.remove(id):
		clamp_vitals()
		changed.emit()


# 6.1 natural regen (HP every 6 s; stops while walking)
## Cards: "hp_recov_rate" % HP regen.
func hp_regen() -> int:
	return (stat("vit") / 5 + maxi(1, max_hp() / 200)) * (100 + equip_bonus("hp_recov_rate")) / 100


## Increase HP Recovery, every 10 s while standing still.
func skill_hp_regen() -> int:
	# HP Regeneration (5 x lv) and Inner Calm (3 x lv) both add their share of MaxHP / 500 per level
	var lv := skill_lv("hp_recovery")
	var calm := skill_lv("inner_calm")
	return lv * 5 + lv * max_hp() / 500 + calm * 3 + calm * max_hp() / 500


## Learned level, or the level a worn card grants when that is higher (a card: Detoxify 1).
func skill_lv(id: String) -> int:
	return maxi(int(skills.get(id, 0)), granted_skill_lv(id))


# ---------------------------------------------------------------- combat (4.1 damage pipeline)
# The formulas live in server/combat.gd (pure functions); these keep the old PlayerState API.

static func mon_stat(mon: Dictionary, s: String) -> int:
	return Combat.mon_stat(mon, s)


static func mon_hit(mon: Dictionary) -> int:
	return Combat.mon_hit(mon)


static func mon_flee(mon: Dictionary) -> int:
	return Combat.mon_flee(mon)


static func is_undead(mon: Dictionary) -> bool:
	return Combat.is_undead(mon)


static func is_undead_or_demon(mon: Dictionary) -> bool:
	return Combat.is_undead_or_demon(mon)


## This character hits a monster. opts: pct, hit_mult, hit_bonus, can_crit, element, flat.
func roll_player_attack(mon: Dictionary, opts: Dictionary = {}) -> Dictionary:
	return Combat.player_attack(self, mon, opts)


## Magic hit (never misses). opts: pct, hits, element, ignore_mdef.
func roll_magic(mon: Dictionary, opts: Dictionary = {}) -> Dictionary:
	return Combat.magic(self, mon, opts)


## A monster hits this character (normal attack).
func roll_monster_attack(mon: Dictionary) -> Dictionary:
	return Combat.monster_attack(self, mon)


## Heal skill amount at level `lv`.
func heal_amount(lv: int) -> int:
	return Combat.heal_amount(self, lv)


## Everything a skill needs before it can go off (cooldown, HP, zeny, weapon, arrows). "" when OK.
func skill_check(id: String, lv: int) -> String:
	var sk := GameData.skill(id)
	if bool(sk.get("v2", false)):
		# Combat v2: cooldown-driven (no mana)
		var left := float(skill_cd.get(id, 0.0)) - Time.get_ticks_msec() / 1000.0
		if left > 0.0:
			return "%s is not ready yet (%.1fs)." % [GameData.skill_name(id), left]
		return ""
	var hp_cost := int(GameData.skill_val(id, "hp_cost", lv, 0))
	if hp_cost > 0 and hp <= hp_cost:
		return "Not enough HP."
	var zeny_cost := int(GameData.skill_val(id, "zeny_cost", lv, 0))
	if zeny < zeny_cost:
		return "Not enough gold (%d needed)." % zeny_cost
	if sk.has("weapon") and weapon_type() != str(sk["weapon"]):
		return "%s needs a %s." % [GameData.skill_name(id), str(sk["weapon"])]
	if int(sk.get("ammo", 0)) > 0 and count("arrow") < int(sk.get("ammo", 0)):
		return "You have no arrows."
	return Jobs2.skill_check(self, id, lv)


func damage(n: int) -> void:
	hp = maxi(0, hp - n)
	changed.emit()


func heal(h: int) -> void:
	hp = mini(max_hp(), hp + h)
	changed.emit()


## A save made while dead: the character starts at its save point with half HP.
func revive_if_dead() -> bool:
	if hp > 0:
		return false
	hp = maxi(1, max_hp() / 2)
	map_id = save_map if save_map != "" else GameData.start_map()
	pos = save_pos
	changed.emit()
	return true


func clamp_vitals() -> void:
	hp = mini(hp, max_hp())


# ---------------------------------------------------------------- progression

## Highest Base Lv reachable for now (config "level_cap", owner 2026-09-30: starts at 99, raised step by step).
func level_cap() -> int:
	return clampi(int(GameData.config.get("level_cap", MAX_BASE_LV)), 1, MAX_BASE_LV)


## Base EXP a character at (or over) the level cap may hold: config "exp_overflow_levels" levels' worth.
func exp_store() -> int:
	var n := 0
	for i in int(GameData.config.get("exp_overflow_levels", 0)):
		n += GameData.exp_next(base_lv + i)
	return n


func gain_exp(b: int, j: int) -> void:
	b = int(b * GameData.rate("exp_rate"))
	j = int(j * GameData.rate("job_exp_rate"))
	var cap := level_cap()
	var full := base_lv >= MAX_BASE_LV or (base_lv >= cap and base_exp >= exp_store())
	if b > 0 and not full:
		notice("Base EXP", "+%d" % b)
	if j > 0 and job_lv < max_job_lv():
		notice("Job EXP", "+%d" % j)
	if base_lv < MAX_BASE_LV:
		base_exp += b
	if job_lv < max_job_lv():
		job_exp += j
	exp_gained.emit(0 if full else b, j)
	while base_lv < cap and base_exp >= GameData.exp_next(base_lv):
		base_exp -= GameData.exp_next(base_lv)
		stat_points += base_lv / 5 + 3
		JobSlots.on_base_level(self, base_lv / 5 + 3)
		base_lv += 1
		hp = max_hp()
		msg("Base Level up! You are now level %d." % base_lv, Color(1, 0.9, 0.3))
		leveled.emit("base")
		# Awakening (Lv 50) / Asura Rider (Lv 80) unlock quests open (server/unlocks.gd)
		load("res://scripts/server/unlocks.gd").on_level(self)
		if base_lv == cap and cap < MAX_BASE_LV:
			msg("Base Lv %d is the highest level for now. EXP is kept for later (up to %d level)." % [cap, int(GameData.config.get("exp_overflow_levels", 0))], Color(1, 0.8, 0.4))
	if base_lv >= cap and base_lv < MAX_BASE_LV and base_exp >= exp_store():
		# level cap: EXP past the stored level(s) is lost; they come in when the cap is raised
		base_exp = exp_store()
		if not full:
			msg("Max level for now (Base Lv %d): EXP storage is full." % cap, Color(1, 0.8, 0.4))
	JobSlots.grant_bonus_sp(self)
	while job_lv < max_job_lv() and job_exp >= GameData.jexp_next(job, job_lv):
		job_exp -= GameData.jexp_next(job, job_lv)
		job_lv += 1
		skill_points += 1
		msg("Job Level up! Job level %d." % job_lv, Color(0.6, 1, 0.6))
		leveled.emit("job")
	if job_lv >= max_job_lv():
		job_exp = 0
	changed.emit()


func death_penalty() -> void:
	if job == "novice":
		msg("Novices lose no experience on death.", Color(0.8, 0.8, 0.8))
		return
	var loss := int(GameData.exp_next(base_lv) * 0.01)
	base_exp = maxi(0, base_exp - loss)
	var jloss := 0
	if job_lv < max_job_lv():
		jloss = int(GameData.jexp_next(job, job_lv) * 0.01)
		job_exp = maxi(0, job_exp - jloss)
	msg("You lost %d base / %d job exp." % [loss, jloss], Color(1, 0.5, 0.5))
	changed.emit()


func stat_cost(s: String) -> int:
	return (int(stats[s]) - 1) / 10 + 2


func raise_stat(s: String) -> bool:
	if int(stats[s]) >= MAX_STAT:
		return false
	var c := stat_cost(s)
	if stat_points < c:
		return false
	stat_points -= c
	stats[s] = int(stats[s]) + 1
	clamp_vitals()
	changed.emit()
	return true


func job_skills() -> Array:
	var out: Array = []
	out.append_array(GameData.jobs["novice"].get("skills", []))
	if job != "novice":
		# a second job keeps the tree of the first job it grew out of
		if Jobs2.parent_of(job) != "":
			out.append_array(GameData.jobs.get(Jobs2.parent_of(job), {}).get("skills", []))
		out.append_array(jd().get("skills", []))
	return out


func can_learn(id: String) -> String:
	var sk := GameData.skill(id)
	if sk.is_empty() or not (id in job_skills()):
		return "Not a skill of your job"
	if sk.get("free", false):
		return "Learned automatically"
	if int(skills.get(id, 0)) >= int(sk.get("max", 1)):
		return "Already at max level"
	var req: Dictionary = sk.get("requires", {})
	for r in req:
		if skill_lv(r) < int(req[r]):
			return "Requires %s Lv %d" % [GameData.skill_name(r), int(req[r])]
	if bool(sk.get("v2", false)):
		# v2 skill-level gate (Base Lv 1/10/20/.../99) and the ultimate from Base Lv 15
		if base_lv < int(sk.get("min_base", 1)):
			return "Requires Base Level %d" % int(sk.get("min_base", 1))
		var next := int(skills.get(id, 0)) + 1
		if not bool(sk.get("ult", false)) and next > CombatV2.skill_lv_cap(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])]
	if skill_points <= 0:
		return "No skill points"
	return ""


func learn_skill(id: String) -> bool:
	var err := can_learn(id)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return false
	skill_points -= 1
	skills[id] = int(skills.get(id, 0)) + 1
	msg("%s is now level %d." % [GameData.skill_name(id), int(skills[id])], Color(0.6, 0.9, 1))
	if not GameData.skill(id).get("passive", false):
		assign_hotbar("skill:" + id)
	changed.emit()
	return true


func can_change_job(to: String) -> String:
	if not GameData.jobs.has(to):
		return "Unknown job"
	if CombatV2.enabled() or JobSlots.is_v2_job(to):
		return "Classes are chosen through Job Slots now."
	if Jobs2.tier(to) >= 2:
		return Jobs2.can_change(self, to)
	if job != "novice":
		return "You already have a job."
	if job_lv < max_job_lv():
		return "You need Job Level %d first (now %d)." % [max_job_lv(), job_lv]
	return ""


func change_job(to: String) -> bool:
	var err := can_change_job(to)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return false
	zeny -= Jobs2.change_fee(to)
	job = to
	job_lv = 1
	job_exp = 0
	skill_points = 0
	var st: Dictionary = jd().get("starter", {})
	for k in st:
		add_item(k, int(st[k]))
	clamp_vitals()
	msg("Congratulations! You are now a %s!" % job_name(), Color(1, 0.85, 0.3))
	leveled.emit("job")
	changed.emit()
	return true


# ---------------------------------------------------------------- items
# Stackables live in `inventory` (id -> count). Equipment is kept per piece in `gear` / `equipment`
# so every piece has its own refine level and slotted cards (8, 9). count/add_item/remove_item work
# on both kinds; for equipment they pick plain (+0, no cards) pieces first.

## Next equipment instance id ("uid"): runtime only, never saved - commands name a piece by it
## (equip / refine / insert_card / sell_inst) instead of passing the dictionary around.
static var _next_uid := 1


static func make_inst(id: String, refine: int = 0, cards: Array = []) -> Dictionary:
	var uid := _next_uid
	_next_uid += 1
	return {"id": id, "refine": refine, "cards": cards.duplicate(), "uid": uid}


## An instance as saved: without the runtime "uid".
static func inst_save(inst: Dictionary) -> Dictionary:
	return {"id": inst["id"], "refine": inst["refine"], "cards": inst["cards"]}


## The bag or worn piece with this uid ({} when there is none).
func find_uid(uid: int) -> Dictionary:
	for inst in gear:
		if int(inst.get("uid", 0)) == uid:
			return inst
	for slot in equipment:
		if int(equipment[slot].get("uid", 0)) == uid:
			return equipment[slot]
	return {}


## uid of a piece (gives an older dictionary without one a uid first).
static func uid_of(inst: Dictionary) -> int:
	if not inst.has("uid"):
		inst["uid"] = _next_uid
		_next_uid += 1
	return int(inst["uid"])


## Normalises a saved instance (JSON numbers come back as floats).
static func _inst_from(v) -> Dictionary:
	if v is String:
		return make_inst(v)
	var d: Dictionary = v
	var cards: Array = []
	for c in d.get("cards", []):
		cards.append(str(c))
	return make_inst(str(d.get("id", "")), int(d.get("refine", 0)), cards)


static func slot_count(inst: Dictionary) -> int:
	return int(GameData.item(str(inst["id"])).get("slots", 0))


## "+7 Iron Blade [3]": refine prefix, name, slot count (Classic style).
static func inst_name(inst: Dictionary) -> String:
	var r := int(inst.get("refine", 0))
	var n := slot_count(inst)
	return "%s%s%s" % ["+%d " % r if r > 0 else "", GameData.item_name(str(inst["id"])), " [%d]" % n if n > 0 else ""]


## Tooltip: description, refine info, slotted cards.
static func inst_desc(inst: Dictionary) -> String:
	var it: Dictionary = GameData.item(str(inst["id"]))
	var lines: Array = [GameData.item_desc(str(inst["id"]))]  # th-pass3
	var k := Refine.kind(str(inst["id"]))
	if k != "" and k != "armor":
		lines.append("Weapon Lv %s    Refine ATK +%d" % [k, Refine.weapon_atk(int(k), int(inst["refine"]))])
	elif k == "armor" and int(inst["refine"]) > 0:
		lines.append("Refine +%d (armor DEF +0.7 each)" % int(inst["refine"]))
	elif k == "":
		lines.append("Cannot be refined.")
	var n := slot_count(inst)
	if n > 0:
		var cs: Array = []
		for c in inst["cards"]:
			cs.append(GameData.item_name(str(c)))
		for i in n - (inst["cards"] as Array).size():
			cs.append("(empty)")
		lines.append("Slots: " + ", ".join(cs))
	return "\n".join(lines)


static func is_plain(inst: Dictionary) -> bool:
	return int(inst["refine"]) == 0 and (inst["cards"] as Array).is_empty()


# 7. Weight. Tracked in tenths so 0.1-weight arrows add up exactly.
func item_weight10(id: String) -> int:
	return roundi(float(GameData.item(id).get("weight", 0)) * 10.0)


func weight10() -> int:
	var w := 0
	for id in inventory:
		w += item_weight10(id) * int(inventory[id])
	for inst in gear:
		w += item_weight10(str(inst["id"]))
	for slot in equipment:
		w += item_weight10(str(equipment[slot]["id"]))
	return w


func weight() -> int:
	return weight10() / 10


## MaxWeight = job base + 30 * base STR + 200 per Enlarge Weight Limit level + Porter's Pack quests
## (server/comfort.gd weight_bonus).
func max_weight() -> int:
	return int(jd().get("weight_base", 2000)) + 30 * int(stats.get("str", 1)) + 200 * skill_lv("enlarge_weight") + Jobs2.weight_bonus(self) + Comfort.weight_bonus(self)


func weight_pct() -> float:
	return weight10() / 10.0 / maxf(1.0, max_weight()) * 100.0


func can_carry(id: String, qty: int = 1) -> bool:
	return weight10() + item_weight10(id) * qty <= max_weight() * 10


## Pieces in the bag (equipment ids) or stack size (everything else). Worn items are not counted.
func count(id: String) -> int:
	if GameData.is_equip(id):
		var n := 0
		for inst in gear:
			if str(inst["id"]) == id:
				n += 1
		return n
	return int(inventory.get(id, 0))


func add_item(id: String, qty: int = 1) -> void:
	if GameData.is_equip(id):
		for i in qty:
			gear.append(make_inst(id))
	else:
		inventory[id] = count(id) + qty
	changed.emit()


func add_inst(inst: Dictionary) -> void:
	gear.append(inst)
	changed.emit()


## Bag pieces of one item id, plain ones first (so selling/quest turn-ins never eat refined gear).
func find_gear(id: String) -> Array:
	var plain: Array = []
	var other: Array = []
	for inst in gear:
		if str(inst["id"]) == id:
			if is_plain(inst):
				plain.append(inst)
			else:
				other.append(inst)
	plain.append_array(other)
	return plain


func remove_item(id: String, qty: int = 1) -> bool:
	var have := count(id)
	if have < qty:
		return false
	if GameData.is_equip(id):
		var list := find_gear(id)
		for i in qty:
			_take_from_bag(list[i])
	elif have == qty:
		inventory.erase(id)
	else:
		inventory[id] = have - qty
	changed.emit()
	return true


## Removes this exact piece from the bag (identity, not equal contents).
func _take_from_bag(inst: Dictionary) -> bool:
	for i in gear.size():
		if is_same(gear[i], inst):
			gear.remove_at(i)
			return true
	return false


## Worn slot of this exact piece, "" when it is not worn.
func worn_slot(inst: Dictionary) -> String:
	for slot in equipment:
		if is_same(equipment[slot], inst):
			return slot
	return ""


## Uses a consumable. Returns the world effect tag ("" = none, "fail" = could not use).
func use_item(id: String) -> String:
	var it := GameData.item(id)
	if str(it.get("type", "")) != "use" or count(id) <= 0:
		return "fail"
	# life skills: meals (one food buff at a time), cookbooks, the Hearthbell Staff (server/life.gd)
	if it.has("food") or it.has("learn_recipe") or it.has("helper") or it.has("blueprint"):
		var le := Life.use_item(self, id, it)
		if le != "":
			return le
	# % potions (owner 2026-09-30: Red / Orange / White Tonic heal 10 / 20 / 35% of Max HP on top of the flat
	# roll) share a cooldown: the potion's "heal_cd" s (Red 1.5 / Orange 2 / White 3), else config "potion_cd";
	# "wait" while it runs (nothing is used)
	var pct := float(it.get("heal_pct", 0))
	if pct > 0.0:
		if not potion_ready():
			return "wait"
		pot_cd_until = Time.get_ticks_msec() / 1000.0 + potion_cd(id)
	if it.has("heal_hp"):
		var r: Array = it["heal_hp"]
		var bonus := 1.0 + (2 * stat("vit") + 10 * skill_lv("hp_recovery")) / 100.0
		heal(int(randi_range(int(r[0]), int(r[1])) * bonus) + int(max_hp() * pct / 100.0))
	for s in it.get("cure", []):
		remove_buff(str(s))
	if it.has("buff"):
		var b: Array = it["buff"]
		add_buff(str(b[0]), float(b[1]), int(b[2]))  # test phase: no job/level/stacking limits, newest wins
	remove_item(id)
	return str(it.get("effect", ""))


## Cooldown (s) a % potion starts: its "heal_cd", else config "potion_cd" (0 = none).
static func potion_cd(id: String) -> float:
	return float(GameData.item(id).get("heal_cd", GameData.config.get("potion_cd", 0.0))) if GameData.config.has("potion_cd") else 0.0


## False while the shared cooldown of the % potions runs (config "potion_cd").
func potion_ready() -> bool:
	return Time.get_ticks_msec() / 1000.0 >= pot_cd_until


func can_equip(id: String) -> String:
	var it := GameData.item(id)
	if str(it.get("type", "")) != "equip":
		return "That is not equipment."
	var eq_job := job
	if JobSlots.is_v2_job(job) and str(it.get("slot", "")) == "weapon":
		# v2: classes are bound to weapons; another slot's weapon switches class (equip_inst)
		var si := JobSlots.slot_for_weapon(self, str(it.get("wtype", "")))
		if si < 0:
			# testing aid: equipping opens a free Job Slot for that weapon's class (JobSlots.free_unlock_for_weapon)
			eq_job = JobSlots.free_job_for_weapon(str(it.get("wtype", "")), self)
			if eq_job == "":
				# another class's weapon before the main class reached Lv 50 (per-class levels)
				if JobSlots.free_job_for_weapon(str(it.get("wtype", ""))) != "" and JobSlots.change_block(self) != "":
					return JobSlots.change_block(self)
				return "%s cannot use this weapon type." % job_name()
		else:
			eq_job = str(job_slots[si].get("job", job)) if si != active_slot and si < job_slots.size() else job
	var ok_jobs: Array = it.get("jobs", [])
	if not ok_jobs.is_empty() and not Jobs2.job_allowed(eq_job, ok_jobs):
		return "Your job cannot equip %s." % it.get("name", id)
	if base_lv < int(it.get("req_lv", 1)):
		return "Requires Base Level %d." % int(it.get("req_lv", 1))
	if JobSlots.is_v2_job(job) and str(it.get("slot", "")) == "weapon":
		return ""
	var aspd_t: Dictionary = jd().get("aspd_base", {})
	if str(it.get("slot", "")) == "weapon" and not aspd_t.is_empty() and not aspd_t.has(str(it.get("wtype", ""))):
		return "%s cannot use this weapon type." % job_name()
	return ""


## Equips the first bag piece of `id` (the best refined one).
func equip(id: String) -> bool:
	var list := find_gear(id)
	if list.is_empty():
		return false
	var best: Dictionary = list[0]
	for inst in list:
		if int(inst["refine"]) + (inst["cards"] as Array).size() > int(best["refine"]) + (best["cards"] as Array).size():
			best = inst
	return equip_inst(best)


func equip_inst(inst: Dictionary) -> bool:
	var id := str(inst["id"])
	if JobSlots.is_v2_job(job) and str(GameData.item(id).get("slot", "")) == "weapon":
		JobSlots.free_unlock_for_weapon(self, str(GameData.item(id).get("wtype", "")))
	var err := can_equip(id)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return false
	var slot := str(GameData.item(id).get("slot", ""))
	if slot == "weapon" and JobSlots.is_v2_job(job):
		var si := JobSlots.slot_for_weapon(self, str(GameData.item(id).get("wtype", "")))
		if si != active_slot:
			var now := Time.get_ticks_msec() / 1000.0
			var serr := JobSlots.switch_to(self, si, now < combat_until, now)
			if serr != "":
				msg(serr, Color(1, 0.5, 0.5))
				return false
	if not _take_from_bag(inst):
		return false
	if equipment.has(slot):
		unequip(slot)
	equipment[slot] = inst
	clamp_vitals()
	# a card that enables a skill puts it on the hotbar (it is not in the job's skill tree)
	for c in inst["cards"]:
		for sk in GameData.item(str(c)).get("grant_skill", {}):
			if not GameData.skill(str(sk)).get("passive", false) and not hotbar.has("skill:" + str(sk)):
				assign_hotbar("skill:" + str(sk))
	msg("Equipped %s." % inst_name(inst), Color(0.8, 0.9, 1))
	changed.emit()
	return true


func unequip(slot: String) -> void:
	if not equipment.has(slot):
		return
	var inst: Dictionary = equipment[slot]
	equipment.erase(slot)
	gear.append(inst)
	clamp_vitals()
	changed.emit()


# ---------------------------------------------------------------- cards (9)

## "" when `card_id` can go into this bag piece: free slot, matching location, not worn.
func can_insert(card_id: String, inst: Dictionary) -> String:
	var c := GameData.item(card_id)
	if str(c.get("type", "")) != "card" or count(card_id) <= 0:
		return "You have no such card."
	if worn_slot(inst) != "":
		return "Take the item off first."
	if str(GameData.item(str(inst["id"])).get("slot", "")) != str(c.get("loc", "")):
		return "This card only fits %s equipment." % str(c.get("loc", "?"))
	if (inst["cards"] as Array).size() >= slot_count(inst):
		return "No free slot."
	return ""


## Bag pieces that can take this card right now.
func insert_targets(card_id: String) -> Array:
	var out: Array = []
	for inst in gear:
		if can_insert(card_id, inst) == "":
			out.append(inst)
	return out


## Compounds a card into equipment. Permanent (classic has no card removal).
func insert_card(card_id: String, inst: Dictionary) -> bool:
	var err := can_insert(card_id, inst)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return false
	remove_item(card_id)
	inst["cards"].append(card_id)
	msg("%s was inserted into %s." % [GameData.item_name(card_id), inst_name(inst)], Color(1, 0.85, 0.35))
	changed.emit()
	return true


# ---------------------------------------------------------------- refining (8)

## Worn and bag pieces that the Refiner can work on.
func refinable_items() -> Array:
	var out: Array = []
	for slot in EQUIP_SLOTS:
		if equipment.has(slot) and Refine.kind(str(equipment[slot]["id"])) != "":
			out.append(equipment[slot])
	for inst in gear:
		if Refine.kind(str(inst["id"])) != "":
			out.append(inst)
	return out


## "" when this piece can be refined now, else the reason.
func refine_check(inst: Dictionary) -> String:
	var k := Refine.kind(str(inst["id"]))
	if k == "":
		return "This item cannot be refined."
	if int(inst["refine"]) >= Refine.max_level():
		return "Already at +%d." % Refine.max_level()
	var mat := Refine.material(k)
	if count(mat) <= 0:
		return "You need 1 %s." % GameData.item_name(mat)
	if zeny < Refine.fee(k):
		return "You need %d zeny." % Refine.fee(k)
	return ""


## "Purify Rough Ores" (Classic Refiner): 5 Rough Oridium -> 1 Oridium, 5 Rough Elunite -> 1 Elunite,
## no fee. Returns "" when possible, else the reason.
func purify_check(ore: String) -> String:
	var p: Dictionary = Refine.purify_list().get(ore, {})
	if p.is_empty():
		return "That ore cannot be purified."
	if count(str(p["from"])) < int(p["count"]):
		return "You need %d %s." % [int(p["count"]), GameData.item_name(str(p["from"]))]
	return ""


func purify(ore: String) -> bool:
	var err := purify_check(ore)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return false
	var p: Dictionary = Refine.purify_list()[ore]
	remove_item(str(p["from"]), int(p["count"]))
	add_item(ore)
	msg("The Refiner purified %d %s into 1 %s." % [int(p["count"]), GameData.item_name(str(p["from"])), GameData.item_name(ore)], Color(0.6, 1, 0.6))
	changed.emit()
	return true


## Success % of the next refine on this piece.
func refine_rate(inst: Dictionary) -> float:
	return Refine.rate(Refine.kind(str(inst["id"])), int(inst["refine"]))


## True when failing the next refine would destroy the piece (above the safe limit).
func refine_is_risky(inst: Dictionary) -> bool:
	return refine_rate(inst) < 100.0


## One refine attempt: pays 1 ore + the fee, then rolls. Failure destroys the piece and its cards.
## roll: forced 0..100 value for tests (< 0 = random). Returns "success", "destroyed" or "" (not possible).
func refine_item(inst: Dictionary, roll: float = -1.0) -> String:
	var err := refine_check(inst)
	if err != "":
		msg(err, Color(1, 0.5, 0.5))
		return ""
	var k := Refine.kind(str(inst["id"]))
	remove_item(Refine.material(k))
	zeny -= Refine.fee(k)
	var r := randf() * 100.0 if roll < 0.0 else roll
	if r < Refine.rate(k, int(inst["refine"])):
		inst["refine"] = int(inst["refine"]) + 1
		msg("Success! %s" % inst_name(inst), Color(0.6, 1, 0.6))
		quests.on_event(self, "refine")
		clamp_vitals()
		changed.emit()
		return "success"
	var nm := inst_name(inst)
	var slot := worn_slot(inst)
	if slot != "":
		equipment.erase(slot)
	else:
		_take_from_bag(inst)
	msg("The refine failed... %s was destroyed." % nm, Color(1, 0.45, 0.4))
	clamp_vitals()
	changed.emit()
	return "destroyed"


# ---------------------------------------------------------------- shop

## Discount / Overcharge: 5 + 2*lv %, lv10 = 24% (classic pc_modifybuyvalue/sellvalue).
static func trade_rate(lv: int) -> int:
	return 0 if lv <= 0 else 5 + 2 * lv - (1 if lv >= 10 else 0)


func buy_price(id: String) -> int:
	var p := int(GameData.item(id).get("price", 0))
	# red names (field PK karma, server/pvp.gd) pay more
	p = int(round(float(p) * load("res://scripts/server/pvp.gd").shop_factor(self)))
	var r := trade_rate(skill_lv("discount"))
	return maxi(1, p * (100 - r) / 100) if r > 0 and p > 0 else p


func buy(id: String, qty: int = 1) -> bool:
	var price := buy_price(id) * qty
	if zeny < price:
		msg("Not enough zeny.", Color(1, 0.5, 0.5))
		return false
	if not can_carry(id, qty):
		msg("You cannot carry that much. (Weight %d / %d)" % [weight(), max_weight()], Color(1, 0.5, 0.5))
		return false
	zeny -= price
	add_item(id, qty)
	msg("Bought %s x%d for %d z." % [GameData.item_name(id), qty, price], Color(1, 0.9, 0.6))
	return true


func sell_price(id: String) -> int:
	var p := int(GameData.item(id).get("price", 0)) / 2
	return p * (100 + trade_rate(skill_lv("overcharge"))) / 100


func sell(id: String, qty: int = 1) -> bool:
	if Comfort.is_bound(id):
		msg("%s is bound to you and cannot be sold." % GameData.item_name(id), Color(1, 0.5, 0.5))
		return false
	qty = mini(qty, count(id))
	if qty <= 0 or not remove_item(id, qty):
		return false
	zeny += sell_price(id) * qty
	msg("Sold %s x%d for %d z." % [GameData.item_name(id), qty, sell_price(id) * qty], Color(1, 0.9, 0.6))
	changed.emit()
	return true


## Sells one exact bag piece (refine and cards do not change the NPC price, like classic).
func sell_inst(inst: Dictionary) -> bool:
	if Comfort.is_bound(str(inst["id"])):
		msg("%s is bound to you and cannot be sold." % inst_name(inst), Color(1, 0.5, 0.5))
		return false
	if not _take_from_bag(inst):
		return false
	var p := sell_price(str(inst["id"]))
	zeny += p
	msg("Sold %s for %d z." % [inst_name(inst), p], Color(1, 0.9, 0.6))
	changed.emit()
	return true


## Loot that "Sell all loot" may sell: plain etc items, not ores, quest items or items an active
## collect quest still needs.
func is_junk(id: String) -> bool:
	var it := GameData.item(id)
	# life items (fish, crops, seeds, furniture...) never go with "Sell all loot"
	if bool(it.get("keep", false)):
		return false
	if str(it.get("type", "")) != "etc" or it.has("refine_mat") or bool(it.get("quest_item", false)) or Comfort.is_bound(id):
		return false
	for ore in Refine.purify_list():
		if str(Refine.purify_list()[ore]["from"]) == id:
			return false
	for qid in quests.active:
		var q: Dictionary = GameData.quest(qid)
		if str(q.get("type", "")) == "collect" and str(q.get("target", "")) == id:
			return false
	return true


func assign_hotbar(entry: String) -> int:
	var at := hotbar.find(entry)
	if at >= 0:
		return at
	var i := hotbar.find("")
	if i >= 0:
		hotbar[i] = entry
		changed.emit()
	else:
		msg("Hotbar is full. Right-click a slot to clear it.", Color(1, 0.7, 0.4))
	return i


func clear_hotbar(i: int) -> void:
	hotbar[i] = ""
	changed.emit()


# ---------------------------------------------------------------- save / load
# version 1: equipment was slot -> item id and equipment pieces were counted in `inventory`.
# version 2: `gear` / `equipment` hold instances {id, refine, cards}; buffs and quests are saved too.
# version 3: "map" (current map id) and "save_map" (map of the save point); older saves -> map 1 (Aldoria).
# version 4: "auto" (AUTO settings); older saves get AUTO_DEFAULTS, unknown keys are dropped.

func to_dict() -> Dictionary:
	var eq := {}
	for slot in equipment:
		eq[slot] = inst_save(equipment[slot])
	var bag: Array = []
	for inst in gear:
		bag.append(inst_save(inst))
	var bl := {}
	for id in buffs.list:
		bl[id] = {"t": float(buffs.list[id]["t"]), "lv": int(buffs.list[id]["lv"])}
	var out := {
		"version": SAVE_VERSION, "name": char_name, "job": job, "base_lv": base_lv, "job_lv": job_lv,
		"base_exp": base_exp, "job_exp": job_exp, "stats": stats, "stat_points": stat_points,
		"skill_points": skill_points, "skills": skills, "hp": hp, "zeny": zeny,
		"inventory": inventory, "gear": bag, "equipment": eq, "hotbar": hotbar,
		"buffs": bl, "quests": quests.to_dict(), "auto": auto_cfg, "mounted": mounted, "stable": Mounts.ensure(self), "constel": Constellation.ensure(self), "asura": Asura.ensure(self), "asura_until": asura_until, "asura_cd": asura_cd, "hunt": hunt, "pk": load("res://scripts/server/pvp.gd").pk_save(pk), "arena": load("res://scripts/server/arena.gd").rec_save(arena), "guild": guild, "life": Life.ensure(self),
		"pos": [pos.x, pos.z], "save_pos": [save_pos.x, save_pos.z], "awaken_gauge": awaken_gauge, "awaken_until": awaken_until,
		"map": map_id if map_id != "" else GameData.start_map(), "save_map": save_map if save_map != "" else GameData.start_map(),
	}
	if not job_slots.is_empty():
		JobSlots.sync(self)
		out["job_slots"] = job_slots.duplicate(true)
		out["active_slot"] = active_slot
		out["class_pick"] = class_pick
	return out


func from_dict(d: Dictionary) -> void:
	char_name = str(d.get("name", "Boss"))
	job = str(d.get("job", "novice"))
	base_lv = int(d.get("base_lv", 1))
	job_lv = int(d.get("job_lv", 1))
	base_exp = int(d.get("base_exp", 0))
	job_exp = int(d.get("job_exp", 0))
	var ds: Dictionary = d.get("stats", {})
	stats = {}
	for s in STATS:
		stats[s] = int(ds.get(s, 1))
	stat_points = int(d.get("stat_points", 0))
	skill_points = int(d.get("skill_points", 0))
	sp_bonus = 0
	skills = {}
	var dk: Dictionary = d.get("skills", {})
	for k in dk:
		skills[k] = int(dk[k])
	inventory = {}
	gear = []
	var di: Dictionary = d.get("inventory", {})
	for k in di:
		if GameData.item(k).is_empty():
			continue
		if GameData.is_equip(k):
			# old saves: equipment was counted in the bag
			for i in int(di[k]):
				gear.append(make_inst(k))
		else:
			inventory[k] = int(di[k])
	for v in d.get("gear", []):
		var inst := _inst_from(v)
		if GameData.is_equip(str(inst["id"])):
			gear.append(inst)
	equipment = {}
	var de: Dictionary = d.get("equipment", {})
	for k in de:
		var inst2 := _inst_from(de[k])
		if GameData.is_equip(str(inst2["id"])):
			equipment[str(k)] = inst2
	hotbar = []
	hotbar.resize(HOTBAR_SIZE)
	hotbar.fill("")
	var dh: Array = d.get("hotbar", [])
	for i in mini(dh.size(), HOTBAR_SIZE):
		hotbar[i] = str(dh[i])
	zeny = int(d.get("zeny", 0))
	var p: Array = d.get("pos", [0, 0])
	pos = Vector3(float(p[0]), 0, float(p[1]))
	var sv: Array = d.get("save_pos", [0, 0])
	save_pos = Vector3(float(sv[0]), 0, float(sv[1]))
	# maps (save version 3); unknown / missing map ids fall back to Aldoria Meadows
	map_id = str(d.get("map", ""))
	save_map = str(d.get("save_map", ""))
	if GameData.map(map_id).is_empty():
		map_id = GameData.start_map()
	if GameData.map(save_map).is_empty():
		save_map = GameData.start_map()
	buffs.clear()
	var db: Dictionary = d.get("buffs", {})
	for id in db:
		var e: Dictionary = db[id]
		if float(e.get("t", 0)) > 0.0:
			buffs.add(str(id), float(e["t"]), int(e.get("lv", 1)))
	quests.from_dict(d.get("quests", {}))
	set_auto_cfg(d.get("auto", {}))
	mounted = bool(d.get("mounted", false))
	awaken_gauge = clampf(float(d.get("awaken_gauge", 0.0)), 0.0, 100.0)
	# an awakened time only means something on this clock within its duration (old saves / other clocks -> 0)
	var au := float(d.get("awaken_until", 0.0))
	var tnow := Time.get_ticks_msec() / 1000.0
	awaken_until = au if au > tnow and au < tnow + 60.0 else 0.0
	awaken_fill = {}
	stable = Mounts.from_save(d.get("stable", {}))
	Mounts.ensure(self)
	constel = Constellation.from_save(d.get("constel", {}))
	asura = Asura.from_save(d.get("asura", {}))
	# ride / rest times only mean something on this clock (old saves / other clocks -> 0)
	var asu := float(d.get("asura_until", 0.0))
	var acd := float(d.get("asura_cd", 0.0))
	asura_until = asu if asu > tnow and asu < tnow + 120.0 else 0.0
	asura_cd = acd if acd > tnow and acd < tnow + 600.0 else 0.0
	Asura.ensure(self)
	hunt = Constellation.hunt_from_save(d.get("hunt", {}))
	pk = load("res://scripts/server/pvp.gd").pk_load(d.get("pk"))
	arena = load("res://scripts/server/arena.gd").rec_save(d.get("arena"))
	guild = str(d.get("guild", ""))
	Constellation.ensure(self)
	Constellation.touch(self)
	life = Life.from_save(d.get("life", {}))
	Life.ensure(self)
	job_slots = []
	for s in d.get("job_slots", []):
		if s is Dictionary and GameData.jobs.has(str(s.get("job", ""))):
			job_slots.append(s)
	active_slot = clampi(int(d.get("active_slot", 0)), 0, maxi(0, job_slots.size() - 1))
	class_pick = bool(d.get("class_pick", false))
	migrated_from = ""
	slot_cd_until = 0.0
	if not job_slots.is_empty():
		JobSlots.restore(self, job_slots[active_slot])
	if CombatV2.enabled():
		# old classic-tree save -> nearest new class, full free refund (job_slots.gd migrate)
		if JobSlots.migrate(self):
			msg("Classes were redesigned: you are now a %s. All stat and skill points were refunded." % job_name(), Color(1, 0.85, 0.3))
		# per-class levels (Boss 2026-10-02): slot 1 keeps the shared level, other classes start at Lv 1
		if JobSlots.ensure_levels(self):
			msg("Every class now has its own level: your main class keeps Lv %d, the other classes start at Lv 1 (their points were refunded)." % int(job_slots[0].get("base_lv", base_lv)), Color(1, 0.85, 0.3))
		# bonus skill points by Base Lv (once per class; older v2 saves get theirs on this load)
		var sp0 := skill_points
		JobSlots.grant_bonus_sp(self)
		if skill_points > sp0:
			msg("Bonus skill points: +%d (every class now gets extra points as its Base Level grows)." % (skill_points - sp0), Color(1, 0.85, 0.3))
	# retired items (items.json "deprecated", e.g. the old mana potions): an old stack is sold at its NPC price
	var refund := sell_deprecated()
	if refund > 0:
		call_deferred("msg", "Some retired items were sold for %d gold." % refund, Color(1, 0.85, 0.3))
	hp = int(d.get("hp", 1))
	clamp_vitals()
	is_new = false
	changed.emit()


## Items marked "deprecated" in items.json (retired items) leave the bag for their sell price; hotbar slots
## and AUTO settings pointing at them are cleared. Returns the gold paid.
func sell_deprecated() -> int:
	var paid := 0
	for id in inventory.keys():
		if bool(GameData.item(str(id)).get("deprecated", false)):
			paid += sell_price(str(id)) * int(inventory[id])
			inventory.erase(id)
			for i in hotbar.size():
				if str(hotbar[i]) == "item:" + str(id):
					hotbar[i] = ""
			if str(auto_cfg.get("hp_item", "")) == str(id):
				auto_cfg["hp_item"] = ""
	zeny += paid
	return paid


## AUTO settings from a save (or a preset): defaults, then every known key of `d` with the default's type.
func set_auto_cfg(d, base: Dictionary = AUTO_DEFAULTS) -> void:
	var out: Dictionary = base.duplicate(true)
	if typeof(d) == TYPE_DICTIONARY:
		for k in d:
			if not AUTO_DEFAULTS.has(k):
				continue
			var dv = AUTO_DEFAULTS[k]
			var v = d[k]
			match typeof(dv):
				TYPE_BOOL:
					out[k] = bool(v)
				TYPE_FLOAT:
					out[k] = float(v)
				TYPE_STRING:
					out[k] = str(v)
				TYPE_ARRAY:
					if typeof(v) == TYPE_ARRAY:
						var arr: Array = []
						for e in v:
							arr.append(bool(e) if k == "slots" else str(e))
						if k == "slots":
							while arr.size() < HOTBAR_SIZE:
								arr.append(true)
							arr.resize(HOTBAR_SIZE)
						out[k] = arr
	auto_cfg = out


## Applies an AUTO preset ("safe", "aggressive", "boss") on top of the current settings.
func apply_auto_preset(id: String) -> void:
	var cur: Dictionary = auto_cfg.duplicate(true)
	var pre: Dictionary = AUTO_PRESETS.get(id, {})
	for k in pre:
		cur[k] = pre[k]
	set_auto_cfg(cur)


func save_game(path: String = SAVE_PATH) -> bool:
	var f := FileAccess.open(path, FileAccess.WRITE)
	if f == null:
		return false
	f.store_string(JSON.stringify(to_dict(), "\t"))
	f.close()
	return true


func load_game(path: String = SAVE_PATH) -> bool:
	if not FileAccess.file_exists(path):
		return false
	var d = JSON.parse_string(FileAccess.get_file_as_string(path))
	if typeof(d) != TYPE_DICTIONARY:
		return false
	from_dict(d)
	return true
