diff --git a/pyproject.toml b/pyproject.toml index db9e26f..2e1fc90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ keywords = ["palworld", "editor", "pal"] license = {file = "LICENSE"} requires-python = ">=3.11" readme = "README.md" -version = "0.6.0" +version = "0.7.0" dependencies = [ "setuptools==69.1.0", "palworld_save_tools==0.22.0", diff --git a/src/palworld_pal_editor/api/save.py b/src/palworld_pal_editor/api/save.py index 1918aaa..f412e3a 100644 --- a/src/palworld_pal_editor/api/save.py +++ b/src/palworld_pal_editor/api/save.py @@ -1,3 +1,4 @@ +import platform import traceback from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required @@ -13,7 +14,7 @@ @save_blueprint.route("/fetch_config", methods=["GET"]) def fetch_config(): tk_status = False - if Config.mode == "gui": + if Config.mode == "gui" and platform.system() != "Darwin": try: import tkinter as tk tk_status = True diff --git a/src/palworld_pal_editor/assets/data/pal_xp_thresholds.json b/src/palworld_pal_editor/assets/data/pal_xp_thresholds.json index b893ccb..16242ad 100644 --- a/src/palworld_pal_editor/assets/data/pal_xp_thresholds.json +++ b/src/palworld_pal_editor/assets/data/pal_xp_thresholds.json @@ -3,5 +3,5 @@ 4007, 5021, 6253, 7747, 9555, 11740, 14378, 17559, 21392, 26007, 31561, 38241, 46272, 55925, 67524, 81458, 98195, 118294, 142429, 171406, 206194, 247955, 298134, 358305, 430525, 517205, 621236, 746089, 895928, 1075751, 1291554, - 1550533, 1861323, 2234286, 2681857 + 1550533, 1861323, 2234286, 2681807 ] diff --git a/src/palworld_pal_editor/config.py b/src/palworld_pal_editor/config.py index da4a380..638cba7 100644 --- a/src/palworld_pal_editor/config.py +++ b/src/palworld_pal_editor/config.py @@ -13,7 +13,7 @@ CONFIG_PATH = PROGRAM_PATH / 'config.json' -VERSION = "0.6.0" +VERSION = "0.7.0" class Config: i18n: str = "en" diff --git a/src/palworld_pal_editor/core/pal_entity.py b/src/palworld_pal_editor/core/pal_entity.py index af533e5..ca24580 100644 --- a/src/palworld_pal_editor/core/pal_entity.py +++ b/src/palworld_pal_editor/core/pal_entity.py @@ -7,7 +7,7 @@ from palworld_pal_editor.config import Config from palworld_pal_editor.utils import LOGGER, clamp, DataProvider -from palworld_pal_editor.core.pal_objects import PalObjects, PalGender, PalRank, get_attr_value, get_nested_attr, toUUID +from palworld_pal_editor.core.pal_objects import PalObjects, PalGender, PalRank, get_nested_attr, dumps from palworld_pal_editor.utils.util import type_guard @@ -24,17 +24,13 @@ def __init__(self, pal_obj: dict) -> None: if self.InstanceId is None: raise Exception(f"No GUID, skipping {self}") - if get_attr_value(self._pal_param, "IsPlayer"): + if PalObjects.get_BaseType(self._pal_param.get("IsPlayer")): raise TypeError("Expecting pal_obj, received player_obj: {} - {} - {}".format(self.NickName, self.PlayerUId, self.InstanceId)) - # self._derived_hp_scaling = self._derive_hp_scaling() self._display_name_cache = {} self.owner_player_entity = None - ## TODO - # self._isBoss_cache = {} - # self._raw_specie_key_cache = {} - # self._data_access_key_cache = {} self.is_unreferenced_pal = False + self.is_new_pal = False def __str__(self) -> str: return "{} - {} - {}".format(self.DisplayName, self.OwnerName, self.InstanceId) @@ -68,7 +64,7 @@ def group_id(self, id: UUID | str): @property def PlayerUId(self) -> Optional[UUID]: # should be EMPTY UUID, but sometimes it's set to player uid in singleplayer game, weird - return get_attr_value(self._pal_key, "PlayerUId") + return PalObjects.get_BaseType(self._pal_key.get("PlayerUId")) @PlayerUId.setter def PlayerUId(self, id: UUID | str) -> Optional[UUID]: @@ -76,7 +72,7 @@ def PlayerUId(self, id: UUID | str) -> Optional[UUID]: @property def InstanceId(self) -> Optional[UUID]: - return get_attr_value(self._pal_key, "InstanceId") + return PalObjects.get_BaseType(self._pal_key.get("InstanceId")) @InstanceId.setter def InstanceId(self, id: UUID | str): @@ -84,8 +80,8 @@ def InstanceId(self, id: UUID | str): @property def OwnerPlayerUId(self) -> Optional[UUID]: - return get_attr_value(self._pal_param, "OwnerPlayerUId") - + return PalObjects.get_BaseType(self._pal_param.get("OwnerPlayerUId")) + @property def LastOwnerPlayerUId(self) -> Optional[UUID]: if self.OldOwnerPlayerUIds: @@ -123,13 +119,13 @@ def SlotIndex(self) -> Optional[int]: @property def CharacterID(self) -> Optional[str]: - return get_attr_value(self._pal_param, "CharacterID") + return PalObjects.get_BaseType(self._pal_param.get("CharacterID")) @CharacterID.setter @LOGGER.change_logger('CharacterID') @type_guard def CharacterID(self, value: str) -> None: - og_specie = self._RawSpecieKey + og_specie = self.RawSpecieKey if self.CharacterID is None: self._pal_param["CharacterID"] = PalObjects.NameProperty(value) @@ -149,19 +145,21 @@ def CharacterID(self, value: str) -> None: # well, just randomly picked lol self.Gender = PalGender.FEMALE - new_specie = self._RawSpecieKey + new_specie = self.RawSpecieKey if new_specie != og_specie: self.remove_unique_attacks() self.learn_attacks() + if self.IsTower or self.IsRAID: + self.equip_all_pal_attacks() + self.heal_pal() self.clear_worker_sick() - # self.MaxHP = self.ComputedMaxHP if maxHP := self.ComputedMaxHP: self.HP = maxHP @property - def _RawSpecieKey(self) -> Optional[str]: + def RawSpecieKey(self) -> Optional[str]: key = self.CharacterID if self._IsBOSS: if "Boss_" in key: @@ -207,7 +205,7 @@ def IconAccessKey(self) -> Optional[str]: if self.IsHuman: return "Human" if self.IsRAID: - return self._RawSpecieKey + return self.RawSpecieKey return self.DataAccessKey @property @@ -217,7 +215,7 @@ def DataAccessKey(self) -> Optional[str]: if self.IsRAID: return self.CharacterID - key = self._RawSpecieKey + key = self.RawSpecieKey match key: case "Sheepball": key = "SheepBall" @@ -281,10 +279,9 @@ def IsTower(self) -> bool: @type_guard def IsTower(self, value: bool) -> None: if not value: - self.CharacterID = self._RawSpecieKey + self.CharacterID = self.RawSpecieKey else: - self.CharacterID = f"GYM_{self._RawSpecieKey}" - # self.MaxHP = self.ComputedMaxHP + self.CharacterID = f"GYM_{self.RawSpecieKey}" if maxHP := self.ComputedMaxHP: self.HP = maxHP @@ -302,9 +299,9 @@ def _IsBOSS(self) -> bool: @type_guard def _IsBOSS(self, value: bool) -> None: if not value: - self.CharacterID = self._RawSpecieKey + self.CharacterID = self.RawSpecieKey elif not self._IsBOSS and value: - self.CharacterID = f"BOSS_{self._RawSpecieKey}" + self.CharacterID = f"BOSS_{self.RawSpecieKey}" @property def IsBOSS(self) -> bool: @@ -325,14 +322,13 @@ def IsBOSS(self, value: bool) -> None: if self.IsRarePal and value: self.IsRarePal = False self._IsBOSS = value - # Update MaxHP - # self.MaxHP = self.ComputedMaxHP + if maxHP := self.ComputedMaxHP: self.HP = maxHP @property def IsRarePal(self) -> Optional[bool]: - return get_attr_value(self._pal_param, "IsRarePal") + return PalObjects.get_BaseType(self._pal_param.get("IsRarePal")) @IsRarePal.setter @LOGGER.change_logger('IsRarePal') @@ -341,8 +337,6 @@ def IsRarePal(self, value: bool) -> None: # Boss and Rare can only exist one if self.IsBOSS and not value: return - # if self.IsBOSS and value: - # self.IsBOSS = False if self.IsRarePal is None: self._pal_param["IsRarePal"] = PalObjects.BoolProperty(value) @@ -356,7 +350,7 @@ def IsRarePal(self, value: bool) -> None: @property def NickName(self) -> Optional[str]: - return get_attr_value(self._pal_param, "NickName") + return PalObjects.get_BaseType(self._pal_param.get("NickName")) @NickName.setter @LOGGER.change_logger('NickName') @@ -366,13 +360,13 @@ def NickName(self, value: str) -> None: self._pal_param["NickName"] = PalObjects.StrProperty(value) else: self._pal_param["NickName"]["value"] = value - # clear up + if not self.NickName: self._pal_param.pop("NickName", None) @property def Level(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Level") + return PalObjects.get_BaseType(self._pal_param.get("Level")) @Level.setter @LOGGER.change_logger('Level') @@ -384,16 +378,15 @@ def Level(self, value: int) -> None: else: self._pal_param["Level"]["value"] = value self.Exp = DataProvider.get_level_xp(self.Level) - # Update MaxHP - # self.MaxHP = self.ComputedMaxHP + if maxHP := self.ComputedMaxHP: self.HP = maxHP - # Learn Attacks + self.learn_attacks() @property def Exp(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Exp") + return PalObjects.get_BaseType(self._pal_param.get("Exp")) @Exp.setter @LOGGER.change_logger('Exp') @@ -406,7 +399,7 @@ def Exp(self, value: int) -> None: @property def Rank(self) -> Optional[PalRank]: - return PalRank.from_value(get_attr_value(self._pal_param, "Rank")) + return PalRank.from_value(PalObjects.get_BaseType(self._pal_param.get("Rank"))) @Rank.setter @LOGGER.change_logger('Rank') @@ -425,7 +418,6 @@ def Rank(self, rank: PalRank | int) -> None: else: PalObjects.set_BaseType(self._pal_param["Rank"], pal_rank.value) - # self.MaxHP = self.ComputedMaxHP if maxHP := self.ComputedMaxHP: self.HP = maxHP @@ -434,26 +426,25 @@ def Rank(self, rank: PalRank | int) -> None: @property def Rank_HP(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Rank_HP") + return PalObjects.get_BaseType(self._pal_param.get("Rank_HP")) @property def Rank_Attack(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Rank_Attack") + return PalObjects.get_BaseType(self._pal_param.get("Rank_Attack")) @property def Rank_Defence(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Rank_Defence") + return PalObjects.get_BaseType(self._pal_param.get("Rank_Defence")) @property def Rank_CraftSpeed(self) -> Optional[int]: - return get_attr_value(self._pal_param, "Rank_CraftSpeed") + return PalObjects.get_BaseType(self._pal_param.get("Rank_CraftSpeed")) @Rank_HP.setter @LOGGER.change_logger('Rank_HP') @type_guard def Rank_HP(self, rank: int) -> None: self._set_soul_rank('Rank_HP', rank) - # self.MaxHP = self.ComputedMaxHP if maxHP := self.ComputedMaxHP: self.HP = maxHP @@ -549,22 +540,6 @@ def HP(self, value: int) -> None: else: PalObjects.set_FixedPoint64(self._pal_param["HP"], value) - # Deprecated since Palworld 0.2.x - # @property - # def MaxHP(self) -> Optional[int]: - # return PalObjects.get_FixedPoint64(self._pal_param.get("MaxHP")) - - # Deprecated since Palworld 0.2.x - # @MaxHP.setter - # @LOGGER.change_logger("MaxHP") - # def MaxHP(self, val: int) -> None: - # if self.MaxHP is None: - # self._pal_param["MaxHP"] = PalObjects.FixedPoint64(val) - # else: - # PalObjects.set_FixedPoint64(self._pal_param["MaxHP"], val) - - # self.HP = self.MaxHP - @property def PassiveSkillList(self) -> Optional[list[str]]: return PalObjects.get_ArrayProperty(self._pal_param.get("PassiveSkillList")) @@ -589,8 +564,7 @@ def add_PassiveSkillList(self, skill: str) -> bool: self.PassiveSkillList.append(skill) LOGGER.info(f"Added {DataProvider.get_passive_i18n(skill)[0]} to PassiveSkillList") - # Update MaxHP, but no such skill atm. - # self.MaxHP = self.ComputedMaxHP + # Update HP, but no such skill atm. # if maxHP := self.ComputedMaxHP: # self.HP = maxHP return True @@ -602,8 +576,9 @@ def pop_PassiveSkillList(self, idx: int = None, item: str = None) -> Optional[st idx = self.PassiveSkillList.index(item) skill = self.PassiveSkillList.pop(int(idx)) LOGGER.info(f"Removed {DataProvider.get_passive_i18n(skill)[0]} from PassiveSkillList") - # Update MaxHP, but no such skill atm. - # self.MaxHP = self.ComputedMaxHP + # Update HP, but no such skill atm. + # if maxHP := self.ComputedMaxHP: + # self.HP = maxHP return skill except Exception as e: LOGGER.warning(f"{e}") @@ -614,7 +589,7 @@ def EquipWaza(self) -> Optional[list[str]]: @LOGGER.change_logger('EquipWaza') @type_guard - def add_EquipWaza(self, waza: str) -> bool: + def add_EquipWaza(self, waza: str, force=False) -> bool: """ Normally you can't add the same "waza" twice on a pal. """ @@ -628,7 +603,7 @@ def add_EquipWaza(self, waza: str) -> bool: LOGGER.warning(f"{self} has already equipped waza {waza}, skipping") return False - if len(self.EquipWaza) >= 3: + if len(self.EquipWaza) >= 3 and not force: LOGGER.warning(f"{self} EquipWaza has maxed out: {self.EquipWaza}, consider add to MasteredWaza instead.") return False @@ -663,12 +638,6 @@ def num_EmptyEquipWaza(self) -> int: def MasteredWaza(self) -> Optional[list[str]]: return PalObjects.get_ArrayProperty(self._pal_param.get("MasteredWaza")) - # @property - # def MasteredWazaSet(self) -> Optional[set[str]]: - # # Unused - # # We need a way to cache it, otherwise it's no better than idx into a list - # return set(self.MasteredWaza) if self.MasteredWaza is not None else None - @LOGGER.change_logger('MasteredWaza') @type_guard def add_MasteredWaza(self, waza: str) -> bool: @@ -687,7 +656,7 @@ def add_MasteredWaza(self, waza: str) -> bool: return False self.MasteredWaza.append(waza) - # PalObjects.add_ArrayProperty(self._pal_param["MasteredWaza"], waza) + LOGGER.info(f"Added {DataProvider.get_attack_i18n(waza)[0]} to MasteredWaza") if self.num_EmptyEquipWaza > 0: @@ -704,7 +673,7 @@ def pop_MasteredWaza(self, idx: int = None, item: str = None) -> Optional[str]: waza = self.MasteredWaza.pop(int(idx)) if waza in (self.EquipWaza or []): self.pop_EquipWaza(item=waza) - # return PalObjects.pop_ArrayProperty(self._pal_param["MasteredWaza"], idx) + LOGGER.info(f"Removed {DataProvider.get_attack_i18n(waza)[0]} from MasteredWaza") return waza except Exception as e: @@ -731,7 +700,7 @@ def Talent_Defense(self) -> Optional[int]: @type_guard def Talent_HP(self, value: int): self._set_iv("Talent_HP", value) - # self.MaxHP = self.ComputedMaxHP + if maxHP := self.ComputedMaxHP: self.HP = maxHP @@ -865,8 +834,7 @@ def heal_pal(self): self._pal_param.pop("PalReviveTimer", None) if self.PhysicalHealth == "EPalStatusPhysicalHealthType::Dying": self._pal_param.pop("PhysicalHealth", None) - # if not self.MaxHP: - # self.MaxHP = self.ComputedMaxHP + if maxHP := self.ComputedMaxHP: self.HP = maxHP @@ -904,9 +872,14 @@ def learn_attacks(self): for atk in DataProvider.get_attacks_to_learn(self.DataAccessKey, self.Level or 1): if atk not in (self.MasteredWaza or []): self.add_MasteredWaza(atk) - # for atk in DataProvider.get_attacks_to_forget(self.DataAccessKey, self.Level): - # if atk in self.MasteredWaza: - # self.pop_MasteredWaza(atk) + + def equip_all_pal_attacks(self): + atks = DataProvider.get_attacks_to_learn(self.DataAccessKey, self.Level or 1) + if not atks: return + (self.EquipWaza or []).clear() + for atk in atks: + if atk not in (self.EquipWaza or []): + self.add_EquipWaza(atk, True) def remove_unique_attacks(self): if self.MasteredWaza is None: @@ -957,7 +930,7 @@ def print_obj(self): print(self.dump_obj()) def dump_obj(self) -> str: - return str(self._pal_obj) + return dumps(self._pal_obj) def _set_soul_rank(self, property_name: str, rank: int): rank = clamp(0, 10, rank) diff --git a/src/palworld_pal_editor/core/pal_objects.py b/src/palworld_pal_editor/core/pal_objects.py index 3d44905..00e112c 100644 --- a/src/palworld_pal_editor/core/pal_objects.py +++ b/src/palworld_pal_editor/core/pal_objects.py @@ -1,9 +1,15 @@ from enum import Enum +import json from typing import Any, Optional +import uuid from palworld_save_tools.archive import UUID +from palworld_save_tools.json_tools import CustomEncoder from palworld_pal_editor.utils import LOGGER, clamp +def dumps(data: dict) -> str: + return json.dumps(data, indent=4, cls=CustomEncoder, ensure_ascii=False) + def isUUIDStr(uuid_str: str) -> Optional[UUID]: try: @@ -26,47 +32,6 @@ def UUID2HexStr(uuid: str | UUID) -> str: return str(uuid).upper().replace("-", "") -def get_attr_value( - data_container: dict, attr_name: str, nested_keys: list = None -) -> Optional[Any]: - """ - Generic method to retrieve the value of an attribute from the pal data. - - Parameters: - attr_name (str): The name of the attribute to retrieve. - nested_keys (list): A list of keys to navigate through nested dictionaries if necessary. - - Returns: - Optional[Any]: The value of the attribute, or None if the attribute does not exist. - """ - try: - if data_container is None: - raise TypeError("Expected dict, get None.") - attr = data_container.get(attr_name) - - if attr is None: - raise IndexError(f"Providing dict does not have `{attr_name}` attribute.") - - if nested_keys: - for key in nested_keys: - attr = attr.get(key, None) - if attr is None: - raise KeyError( - f"trying to get attr `{attr_name}`, but nested key `{key}` not found in dict {data_container}." - ) - - if attr and "value" in attr: - return attr["value"] - else: - raise KeyError( - f"trying to get attr `{attr_name}`, but final key `value` not found in dict {data_container}." - ) - - except Exception as e: - # LOGGER.warning(e) - return None - - def get_nested_attr(container: dict, keys: list) -> Optional[Any]: """ Retrieve a value from a nested dictionary using a sequence of keys. @@ -121,6 +86,7 @@ def from_value(value: int): class PalObjects: EMPTY_UUID = toUUID("00000000-0000-0000-0000-000000000000") + TIME = 638486453957560000 @staticmethod def StrProperty(value: str): @@ -344,6 +310,16 @@ def set_PalCharacterSlotId( PalObjects.set_PalContainerId(container["value"]["ContainerId"], container_id) PalObjects.set_BaseType(container["value"]["SlotIndex"], slot_idx) + @staticmethod + def FloatContainer(value: dict): + return { + "struct_type": "FloatContainer", + "struct_id": PalObjects.EMPTY_UUID, + "id": None, + "value": value, + "type": "StructProperty", + } + @staticmethod def get_container_value(container: dict) -> Optional[Any]: case_1 = { @@ -400,6 +376,32 @@ def Vector(x, y, z): }, "type": "StructProperty", } + + @staticmethod + def PalLoggedinPlayerSaveDataRecordData(value: dict = None): + return { + "struct_type": "PalLoggedinPlayerSaveDataRecordData", + "struct_id": PalObjects.EMPTY_UUID, + "id": None, + "value": value or {}, + "type": "StructProperty" + } + + @staticmethod + def MapProperty(key_type: str, value_type: str, key_struct_type=None, value_struct_type=None): + return { + "key_type": key_type, + "value_type": value_type, + "key_struct_type": key_struct_type, + "value_struct_type": value_struct_type, + "id": None, + "value": [], + "type": "MapProperty" + } + + @staticmethod + def get_MapProperty(container: dict) -> Optional[list[dict]]: + return get_nested_attr(container, ["value"]) EPalWorkSuitabilities = [ "EPalWorkSuitability::EmitFlame", @@ -491,9 +493,7 @@ def PalSaveParameter(InstanceId, OwnerPlayerUId, ContainerId, SlotIndex, group_i "NameProperty", {"values": []} ), "MP": PalObjects.FixedPoint64(10000), - "OwnedTime": PalObjects.DateTime( - 638478651098960000 - ), + "OwnedTime": PalObjects.DateTime(PalObjects.TIME), "OwnerPlayerUId": PalObjects.Guid(OwnerPlayerUId), "OldOwnerPlayerUIds": PalObjects.ArrayProperty( "StructProperty", @@ -505,8 +505,11 @@ def PalSaveParameter(InstanceId, OwnerPlayerUId, ContainerId, SlotIndex, group_i "id": PalObjects.EMPTY_UUID, }, ), - # "MaxHP": PalObjects.FixedPoint64(545000), # MaxHP is no longer stored in the game save. + # MaxHP is no longer stored in the game save. + # "MaxHP": PalObjects.FixedPoint64(545000), "CraftSpeed": PalObjects.IntProperty(70), + # Do not omit CraftSpeeds, otherwise the pal works super slow + # TODO use accurate data (even tho this is useless) "CraftSpeeds": PalObjects.ArrayProperty( "StructProperty", { @@ -522,24 +525,14 @@ def PalSaveParameter(InstanceId, OwnerPlayerUId, ContainerId, SlotIndex, group_i "id": PalObjects.EMPTY_UUID, }, ), - # "EquipItemContainerId": { - # "struct_type": "PalContainerId", - # "struct_id": PalObjects.EMPTY_UUID, - # "id": None, - # "value": { - # "ID": { - # "struct_type": "Guid", - # "struct_id": PalObjects.EMPTY_UUID, - # "id": None, - # "value": "2ee46d97-4a5a-4e11-837c-276e4c6b9c7b", - # "type": "StructProperty", - # } - # }, - # "type": "StructProperty", - # }, + "SanityValue": PalObjects.FloatProperty(100.0), + "EquipItemContainerId": PalObjects.PalContainerId( + str(uuid.uuid4()) + ), "SlotID": PalObjects.PalCharacterSlotId( SlotIndex, ContainerId ), + # TODO Need accurate values "MaxFullStomach": PalObjects.FloatProperty(150.0), "GotStatusPointList": PalObjects.ArrayProperty( "StructProperty", @@ -567,12 +560,14 @@ def PalSaveParameter(InstanceId, OwnerPlayerUId, ContainerId, SlotIndex, group_i "id": PalObjects.EMPTY_UUID, }, ), - "LastJumpedLocation": PalObjects.Vector(0, 0, 0), + "DecreaseFullStomachRates": PalObjects.FloatContainer({}), + "CraftSpeedRates": PalObjects.FloatContainer({}), + "LastJumpedLocation": PalObjects.Vector(0, 0, 7088.5), }, "type": "StructProperty", } }, - "unknown_bytes": (0, 0, 0, 0), + "unknown_bytes": [0, 0, 0, 0], "group_id": group_id, }, ".worldSaveData.CharacterSaveParameterMap.Value.RawData", diff --git a/src/palworld_pal_editor/core/player_entity.py b/src/palworld_pal_editor/core/player_entity.py index 4ac4767..f682683 100644 --- a/src/palworld_pal_editor/core/player_entity.py +++ b/src/palworld_pal_editor/core/player_entity.py @@ -4,7 +4,8 @@ from palworld_pal_editor.utils import LOGGER, alphanumeric_key from palworld_pal_editor.core.pal_entity import PalEntity -from palworld_pal_editor.core.pal_objects import PalObjects, get_attr_value +from palworld_pal_editor.core.pal_objects import PalObjects +from palworld_pal_editor.utils.data_provider import DataProvider class PlayerEntity: @@ -18,6 +19,7 @@ def __init__( ) -> None: self._player_obj: dict = player_obj self._palbox: dict[str, PalEntity] = palbox + self._new_palbox: dict[str, PalEntity] = {} self._gvas_file: GvasFile = gvas_file self._gvas_compression_times: int = compression_times self.group_id = group_id @@ -36,11 +38,10 @@ def __init__( self._player_param: dict = self._player_obj["value"]["RawData"]["value"][ "object" ]["SaveParameter"]["value"] - - if not get_attr_value(self._player_param, "IsPlayer"): + if not PalObjects.get_BaseType(self._player_param.get("IsPlayer")): raise TypeError( "Expecting player_obj, received pal_obj: {} - {} - {} - {}".format( - get_attr_value(self._player_param, "CharacterID"), + PalObjects.get_BaseType(self._player_param.get("CharacterID")), self.NickName, self.PlayerUId, self.InstanceId, @@ -76,15 +77,15 @@ def __eq__(self, __value: object) -> bool: @property def PlayerUId(self) -> Optional[UUID]: - return get_attr_value(self._player_key, "PlayerUId") + return PalObjects.get_BaseType(self._player_key.get("PlayerUId")) @property def InstanceId(self) -> Optional[UUID]: - return get_attr_value(self._player_key, "InstanceId") + return PalObjects.get_BaseType(self._player_key.get("InstanceId")) @property def NickName(self) -> Optional[str]: - return get_attr_value(self._player_param, "NickName") + return PalObjects.get_BaseType(self._player_param.get("NickName")) @property def OtomoCharacterContainerId(self) -> Optional[UUID]: @@ -142,21 +143,109 @@ def add_pal(self, pal_entity: PalEntity) -> bool: pal_guid = str(pal_entity.InstanceId) if pal_guid in self._palbox: return False + + if pal_entity.is_new_pal: + self._new_palbox[pal_guid] = pal_entity + self._palbox[pal_guid] = pal_entity pal_entity.set_owner_player_entity(self) return True + + def try_create_pal_record_data(self): + if "RecordData" not in self._player_save_data: + self._player_save_data["RecordData"] = PalObjects.PalLoggedinPlayerSaveDataRecordData() + record_data = self._player_save_data["RecordData"]["value"] + + if "PalCaptureCount" not in record_data: + record_data["PalCaptureCount"] = PalObjects.MapProperty("NameProperty", "IntProperty") + + if "PaldeckUnlockFlag" not in record_data: + record_data["PaldeckUnlockFlag"] = PalObjects.MapProperty("NameProperty", "BoolProperty") + + @property + def PalCaptureCount(self) -> Optional[list]: + if not (record_data := self._player_save_data.get("RecordData", None)): + return None + record_data: dict = record_data["value"] + return PalObjects.get_MapProperty(record_data.get("PalCaptureCount", None)) + + @property + def PaldeckUnlockFlag(self) -> Optional[list]: + if not (record_data := self._player_save_data.get("RecordData", None)): + return None + record_data: dict = record_data["value"] + return PalObjects.get_MapProperty(record_data.get("PaldeckUnlockFlag", None)) + + def get_pal_capture_count(self, name: str) -> int: + try: + return self._player_save_data["RecordData"]["value"]["PalCaptureCount"]["value"][name] + except: + return 0 + + def inc_pal_capture_count(self, name: str): + self.try_create_pal_record_data() + for record in self.PalCaptureCount: + if record['key'].lower() == name.lower(): + record['value'] += 1 + return + self.PalCaptureCount.append({ + 'key': name, + 'value': 1 + }) + + def unlock_paldeck(self, name: str): + self.try_create_pal_record_data() + for record in self.PaldeckUnlockFlag: + if record['key'].lower() == name.lower(): + record['value'] = True + return + self.PaldeckUnlockFlag.append({ + 'key': name, + 'value': True + }) + + def save_new_pal_records(self): + """ + This should only be called on save + """ + def handle_special_keys(key) -> str: + match key: + case 'PlantSlime_Flower': return 'PlantSlime' + case 'SheepBall': return 'Sheepball' + case 'LazyCatFish': return 'LazyCatfish' + return key + + for guid in self._new_palbox: + pal_entity = self._new_palbox[guid] + if DataProvider.is_pal_invalid(pal_entity.DataAccessKey): + LOGGER.info(f"Skip player records update for invalid pal: {pal_entity}") + continue + if pal_entity.IsHuman or not DataProvider.get_pal_sorting_key(pal_entity.DataAccessKey): + LOGGER.info(f"Skip player records update for pal: {pal_entity}") + continue + + key = handle_special_keys(pal_entity.RawSpecieKey) + self.inc_pal_capture_count(key) + self.unlock_paldeck(key) + pal_entity.is_new_pal = False + + self._new_palbox.clear() def get_pals(self) -> list[PalEntity]: return self._palbox.values() def pop_pal(self, guid: str | UUID) -> Optional[PalEntity]: + if guid in self._new_palbox: + self._new_palbox.pop(guid) return self._palbox.pop(guid, None) - def get_pal(self, guid: UUID | str) -> Optional[PalEntity]: + def get_pal(self, guid: UUID | str, disable_warning=False) -> Optional[PalEntity]: guid = str(guid) if guid in self._palbox: return self._palbox[guid] - LOGGER.warning(f"Player {self} has no pal {guid}.") + + if not disable_warning: + LOGGER.warning(f"Player {self} has no pal {guid}.") def get_sorted_pals(self, sorting_key="paldeck") -> list[PalEntity]: match sorting_key: diff --git a/src/palworld_pal_editor/core/save_manager.py b/src/palworld_pal_editor/core/save_manager.py index 4334048..9c6ac1f 100644 --- a/src/palworld_pal_editor/core/save_manager.py +++ b/src/palworld_pal_editor/core/save_manager.py @@ -8,7 +8,6 @@ from palworld_save_tools.gvas import GvasFile from palworld_save_tools.archive import FArchiveReader, FArchiveWriter, UUID -from palworld_save_tools.json_tools import CustomEncoder from palworld_save_tools.palsav import compress_gvas_to_sav, decompress_sav_to_gvas from palworld_save_tools.paltypes import PALWORLD_CUSTOM_PROPERTIES, PALWORLD_TYPE_HINTS @@ -16,7 +15,7 @@ from palworld_pal_editor.core.container_data import ContainerData -from palworld_pal_editor.core.pal_objects import PalObjects, UUID2HexStr, get_attr_value, toUUID +from palworld_pal_editor.core.pal_objects import PalObjects, UUID2HexStr, toUUID from palworld_pal_editor.core.player_entity import PlayerEntity from palworld_pal_editor.core.pal_entity import PalEntity from palworld_pal_editor.utils import LOGGER, alphanumeric_key @@ -111,15 +110,13 @@ def skip_encode(writer: FArchiveWriter, property_type: str, properties: dict) -> MAIN_SKIP_PROPERTIES[".worldSaveData.InvaderSaveData"] = (skip_decode, skip_encode) MAIN_SKIP_PROPERTIES[".worldSaveData.DungeonPointMarkerSaveData"] = (skip_decode, skip_encode) MAIN_SKIP_PROPERTIES[".worldSaveData.GameTimeSaveData"] = (skip_decode, skip_encode) -# PALEDITOR_CUSTOM_PROPERTIES[".worldSaveData.CharacterContainerSaveData"] = (skip_decode, skip_encode) -# PALEDITOR_CUSTOM_PROPERTIES[".worldSaveData.GroupSaveDataMap"] = (skip_decode, skip_encode) PLAYER_SKIP_PROPERTIES = copy.deepcopy(PALWORLD_CUSTOM_PROPERTIES) PLAYER_SKIP_PROPERTIES[".SaveData.PlayerCharacterMakeData"] = (skip_decode, skip_encode) PLAYER_SKIP_PROPERTIES[".SaveData.LastTransform"] = (skip_decode, skip_encode) PLAYER_SKIP_PROPERTIES[".SaveData.inventoryInfo"] = (skip_decode, skip_encode) -PLAYER_SKIP_PROPERTIES[".SaveData.RecordData"] = (skip_decode, skip_encode) +# PLAYER_SKIP_PROPERTIES[".SaveData.RecordData"] = (skip_decode, skip_encode) class SaveManager: # Although these are class attrs, SaveManager itself is singleton so it should be fine? @@ -173,7 +170,7 @@ def get_pal(self, guid: UUID | str) -> Optional[PalEntity]: if guid in self._dangling_pals: return self._dangling_pals[guid] for player in self.get_players(): - if pal := player.get_pal(guid): + if pal := player.get_pal(guid, disable_warning=True): return pal LOGGER.warning(f"Can't find pal {guid}") @@ -192,10 +189,10 @@ def _load_entities(self): LOGGER.warning(f"Non-player/pal data found in CharacterSaveParameterMap, skipping {entity}") continue - entity_param = entity_struct['value'] + entity_param: dict = entity_struct['value'] try: - if get_attr_value(entity_param, "IsPlayer"): - uid_str = str(get_attr_value(entity['key'], "PlayerUId")) + if PalObjects.get_BaseType(entity_param.get("IsPlayer")): + uid_str = str(PalObjects.get_BaseType(entity["key"].get("PlayerUId"))) if uid_str in self.player_mapping: LOGGER.error(f"Duplicated player found: \n\t{self.player_mapping[uid_str]}, skipping...") @@ -299,6 +296,8 @@ def open(self, file_path: str) -> Optional[GvasFile]: self._raw_gvas, PALWORLD_TYPE_HINTS, MAIN_SKIP_PROPERTIES ) + PalObjects.TIME = PalObjects.get_BaseType(self.gvas_file.properties.get("Timestamp")) or PalObjects.TIME + try: self.group_data = GroupData(self.gvas_file) except Exception as e: @@ -386,7 +385,7 @@ def add_pal(self, player_uid: str | UUID, pal_obj: dict = None) -> Optional[PalE LOGGER.warning(f"Player {player_uid} not found") return None - player_container_ids = [player.PalStorageContainerId, player.OtomoCharacterContainerId] + player_container_ids = [player.OtomoCharacterContainerId, player.PalStorageContainerId] pal_container = None for id in player_container_ids: @@ -420,13 +419,19 @@ def add_pal(self, player_uid: str | UUID, pal_obj: dict = None) -> Optional[PalE pal_entity.InstanceId = pal_instanceId pal_entity.SlotID = (container_id, slot_idx) # I don't know why some captured pals have PlayerUId, - # and having this non-empty ID will cause the game to discard the duped pal + # But having non-empty ID will cause the game to hide the duped pal pal_entity.PlayerUId = PalObjects.EMPTY_UUID - pal_entity._pal_param.pop("EquipItemContainerId", None) - # (pal_entity.OldOwnerPlayerUIds or []).clear() + # It seems the item container id is not necessarily referenced in the ItemContainerSaveData + # so just assign a randomly for now. + pal_entity._pal_param["EquipItemContainerId"] = PalObjects.PalContainerId(str(uuid.uuid4())) + # pal_entity._pal_param.pop("EquipItemContainerId", None) + pal_entity.NickName = "!!!DUPED PAL!!!" - player.add_pal(pal_entity) + pal_entity.is_new_pal = True + + if not player.add_pal(pal_entity): + raise Exception("Duplicated Pal ID, Try Again!") self._entities_list.append(pal_obj) except: LOGGER.error(f"Failed adding pal: {traceback.format_exc()}") @@ -503,6 +508,7 @@ def save_player_sav(self, player_entity: PlayerEntity) -> bool: if player_entity.PlayerGVAS is None: return False + player_entity.save_new_pal_records() gvas_file, compression_times = player_entity.PlayerGVAS player_path: Path = self._file_path / "Players" / f"{UUID2HexStr(player_entity.PlayerUId)}.sav"