Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ through the *Pal Genetic Data* terminal in-game.
```

(there's one numbered folder per account — see [Backing up your save](#-backing-up-your-save) for how to find it.)
3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)).
3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, cumulative XP, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)). The XP controls show progress toward the next level, flag level/XP mismatches, and can set the current level's minimum or maximum XP or derive the level from XP.
4. **Save.** Choose **File → Save**. The first save of each session automatically copies your original file into a `PalEdit-backups` folder next to it, just in case.
5. **Pick your changes up in-game** — see below.

Expand Down
108 changes: 104 additions & 4 deletions palworld_pal_edit/PalEdit.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,8 @@ def onselect(self, evt):

self.title.config(text=f"{pal.GetNickname()}")
self.levelvar.set(str(pal.GetLevel()) if pal.GetLevel() > 0 else "?")
self.expvar.set(str(pal.GetExp()))
self.updateexperiencedisplay(pal)

self._fruit_all = [PalInfo.PalAttacks[aval] for aval in self.availableAttacks(pal)]
self.fruitOptions['values'] = self._fruit_all
Expand Down Expand Up @@ -1533,6 +1535,79 @@ def setlevelfromentry(self, *_):
else:
self.levelvar.set(str(lv)) # normalise (clamp / stray whitespace)

def setexpfromentry(self, *_):
"""Set the selected pal's cumulative XP from the XP field."""
if not self.isPalSelected():
return
i = int(self.listdisplay.curselection()[0])
pal = self.FilteredPals()[i]
try:
exp = int(self.expvar.get())
except (ValueError, tk.TclError):
self.expvar.set(str(pal.GetExp()))
self.updateexperiencedisplay(pal)
return
pal.SetExp(exp)
self.expvar.set(str(pal.GetExp()))
self.updateexperiencedisplay(pal)

def updateexperiencedisplay(self, pal):
progress = PalInfo.experience_progress(
pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap)
self.expprogress['value'] = progress["percent"]

if not progress["matches_level"]:
derived = PalInfo.level_for_experience(
pal.GetExp(), PalEditConfig.levelcap)
text = f"XP matches Lv. {derived}, not Lv. {pal.GetLevel()}"
colour = "#A00000"
elif progress["next_threshold"] is None:
text = f"Maximum level · {pal.GetExp():,} XP"
colour = "#1F6B2A"
else:
text = (f"{progress['remaining']:,} XP to Lv. {pal.GetLevel() + 1} "
f"({pal.GetExp():,} / {progress['next_threshold']:,})")
colour = "#202020"
self.expstatus.config(text=text, fg=colour)

def setexptolevelminimum(self):
if not self.isPalSelected():
return
i = int(self.listdisplay.curselection()[0])
pal = self.FilteredPals()[i]
progress = PalInfo.experience_progress(
pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap)
pal.SetExp(progress["minimum"])
self.expvar.set(str(pal.GetExp()))
self.updateexperiencedisplay(pal)

def setexptolevelmaximum(self):
if not self.isPalSelected():
return
i = int(self.listdisplay.curselection()[0])
pal = self.FilteredPals()[i]
progress = PalInfo.experience_progress(
pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap)
pal.SetExp(progress["maximum"])
self.expvar.set(str(pal.GetExp()))
self.updateexperiencedisplay(pal)

def derivelevelfromexp(self):
if not self.isPalSelected():
return
i = int(self.listdisplay.curselection()[0])
pal = self.FilteredPals()[i]
experience = pal.GetExp()
level = PalInfo.level_for_experience(
experience, PalEditConfig.levelcap)
if level != pal.GetLevel():
pal.SetLevel(level)
pal.SetExp(experience)
self.handleMaxHealthUpdates(pal)
self.refresh(i)
else:
self.updateexperiencedisplay(pal)

# ------------------------------------------------------------------
# Custom passive-skill presets (build named sets, stamp onto pals)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -2837,9 +2912,8 @@ def filterfruit(evt=None):
headerframe.grid_columnconfigure((0, 2), uniform="equal")
headerframe.grid_columnconfigure(1, weight=1)

# "Lv." label + a typeable entry: type a level and press Enter (or click
# away) to set it directly; the ➖ / ➕ buttons still work and the field
# updates to match
# Type a level or cumulative XP and press Enter (or click away) to set
# it directly. The ➖ / ➕ buttons still update both fields.
lvlframe = tk.Frame(headerframe, bg="darkgrey")
lvlframe.grid(row=0, column=1, sticky="nsew")
lvlframe.grid_rowconfigure(0, weight=1)
Expand All @@ -2854,8 +2928,18 @@ def filterfruit(evt=None):
self.level.grid(row=0, column=2)
self.level.bind("<Return>", self.setlevelfromentry)
self.level.bind("<FocusOut>", self.setlevelfromentry)

explabel = tk.Label(lvlframe, text="XP", bg="darkgrey", font=(PalEditConfig.font, 10))
explabel.grid(row=1, column=1, sticky="e")
self.expvar = tk.StringVar()
self.exp = tk.Entry(lvlframe, textvariable=self.expvar, width=12, justify="center",
font=(PalEditConfig.font, 10), relief="flat", bd=0,
highlightthickness=0, bg="darkgrey", disabledbackground="darkgrey")
self.exp.grid(row=1, column=2, sticky="w")
self.exp.bind("<Return>", self.setexpfromentry)
self.exp.bind("<FocusOut>", self.setexpfromentry)
# keep the owner-on-hover readout that the old level label had
for _w in (lvlframe, lvllabel, self.level):
for _w in (lvlframe, lvllabel, self.level, explabel, self.exp):
_w.bind("<Enter>", lambda evt, num="owner": self.changetext(num))
_w.bind("<Leave>", lambda evt, num=-1: self.changetext(num))

Expand All @@ -2869,6 +2953,22 @@ def filterfruit(evt=None):
bg="darkgrey")
addlvlbtn.grid(row=0, column=2, sticky="nsew")

xpdetailframe = tk.Frame(deckview, bg="darkgrey", padx=5, pady=3)
xpdetailframe.pack(fill=tk.constants.X)
self.expprogress = ttk.Progressbar(xpdetailframe, maximum=100)
self.expprogress.pack(fill=tk.constants.X)
self.expstatus = tk.Label(xpdetailframe, text="", bg="darkgrey",
font=(PalEditConfig.font, 9))
self.expstatus.pack(fill=tk.constants.X)
xpbuttons = tk.Frame(xpdetailframe, bg="darkgrey")
xpbuttons.pack(fill=tk.constants.X)
tk.Button(xpbuttons, text="Level minimum", command=self.setexptolevelminimum,
font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X)
tk.Button(xpbuttons, text="Level maximum", command=self.setexptolevelmaximum,
font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X)
tk.Button(xpbuttons, text="Level from XP", command=self.derivelevelfromexp,
font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X)

baseinfoview = tk.Frame(deckview)
baseinfoview.pack(fill=tk.constants.BOTH)
labelview = tk.Frame(baseinfoview, bg="lightgrey", pady=0, padx=16)
Expand Down
52 changes: 50 additions & 2 deletions palworld_pal_edit/PalInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,47 @@
while len(xpthresholds) < 80:
xpthresholds.append(xpthresholds[-1])

MAX_EXPERIENCE = 2_147_483_647


def level_for_experience(experience, level_cap=80):
"""Return the highest level threshold reached by cumulative XP."""
experience = max(0, min(MAX_EXPERIENCE, experience))
level_cap = max(1, min(level_cap, len(xpthresholds)))
for level in range(level_cap, 0, -1):
if experience >= xpthresholds[level - 1]:
return level
return 1


def experience_progress(level, experience, level_cap=80):
"""Describe cumulative XP progress and consistency for a selected level."""
level_cap = max(1, min(level_cap, len(xpthresholds)))
level = max(1, min(level, level_cap))
experience = max(0, min(MAX_EXPERIENCE, experience))
minimum = xpthresholds[level - 1]
next_threshold = xpthresholds[level] if level < level_cap else None
maximum = next_threshold - 1 if next_threshold is not None else MAX_EXPERIENCE
matches_level = minimum <= experience <= maximum

if next_threshold is None:
percent = 100 if experience >= minimum else 0
remaining = None
else:
required = next_threshold - minimum
earned = max(0, min(required, experience - minimum))
percent = earned * 100 // required
remaining = max(0, next_threshold - experience)

return {
"minimum": minimum,
"maximum": maximum,
"next_threshold": next_threshold,
"remaining": remaining,
"percent": percent,
"matches_level": matches_level,
}



class PalGender(Enum):
Expand Down Expand Up @@ -303,7 +344,7 @@ def __init__(self, data):

if not "Exp" in self._obj:
self._obj['Exp'] = copy.deepcopy(EmptyExpObject)
# We don't store Exp yet
self._exp = self._obj['Exp']['value']

self._nickname = ""
if "NickName" in self._obj:
Expand Down Expand Up @@ -703,11 +744,18 @@ def GetOwner(self):
def GetLevel(self):
return self._level

def GetExp(self):
return self._exp

def SetExp(self, value):
value = max(0, min(MAX_EXPERIENCE, value))
self._obj['Exp']['value'] = self._exp = value

def SetLevel(self, value):
# We need this check until we fix adding missing nodes
if "Level" in self._obj and "Exp" in self._obj:
self._obj['Level']['value']["value"] = self._level = value
self._obj['Exp']['value'] = xpthresholds[value - 1]
self.SetExp(xpthresholds[value - 1])
self.CleanseAttacks() # self.SetLevelMoves()
else:
print(f"[ERROR:] Failed to update level for: '{self.GetName()}'")
Expand Down
Empty file added tests/__init__.py
Empty file.
83 changes: 83 additions & 0 deletions tests/test_pal_experience.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import unittest

from palworld_pal_edit.PalInfo import (
PalEntity,
experience_progress,
level_for_experience,
xpthresholds,
)


class PalExperienceTests(unittest.TestCase):
def make_pal(self, level=12, experience=1600):
pal = PalEntity.__new__(PalEntity)
pal._level = level
pal._exp = experience
pal._obj = {
"Level": {"value": {"value": level}},
"Exp": {"value": experience},
}
pal.CleanseAttacks = lambda: None
return pal

def test_experience_can_be_read_and_written(self):
pal = self.make_pal()

self.assertEqual(1600, pal.GetExp())

pal.SetExp(1750)

self.assertEqual(1750, pal.GetExp())
self.assertEqual(1750, pal._obj["Exp"]["value"])

def test_changing_level_updates_the_experience_value(self):
pal = self.make_pal()

pal.SetLevel(20)

self.assertEqual(xpthresholds[19], pal.GetExp())
self.assertEqual(xpthresholds[19], pal._obj["Exp"]["value"])

def test_experience_is_kept_in_the_save_integer_range(self):
pal = self.make_pal()

pal.SetExp(-1)
self.assertEqual(0, pal.GetExp())

pal.SetExp(2_147_483_648)
self.assertEqual(2_147_483_647, pal.GetExp())

def test_experience_progress_reports_the_current_level_range(self):
progress = experience_progress(level=1, experience=10, level_cap=80)

self.assertEqual(0, progress["minimum"])
self.assertEqual(24, progress["maximum"])
self.assertEqual(25, progress["next_threshold"])
self.assertEqual(15, progress["remaining"])
self.assertEqual(40, progress["percent"])
self.assertTrue(progress["matches_level"])

def test_experience_progress_flags_values_outside_the_level_range(self):
below = experience_progress(level=12, experience=xpthresholds[11] - 1, level_cap=80)
above = experience_progress(level=12, experience=xpthresholds[12], level_cap=80)

self.assertFalse(below["matches_level"])
self.assertFalse(above["matches_level"])

def test_max_level_progress_has_no_next_level(self):
progress = experience_progress(level=80, experience=2_147_483_647, level_cap=80)

self.assertEqual(2_147_483_647, progress["maximum"])
self.assertIsNone(progress["next_threshold"])
self.assertIsNone(progress["remaining"])
self.assertEqual(100, progress["percent"])
self.assertTrue(progress["matches_level"])

def test_level_can_be_derived_from_experience(self):
self.assertEqual(1, level_for_experience(24, level_cap=80))
self.assertEqual(2, level_for_experience(25, level_cap=80))
self.assertEqual(80, level_for_experience(2_147_483_647, level_cap=80))


if __name__ == "__main__":
unittest.main()