543 lines
18 KiB
Lua
543 lines
18 KiB
Lua
local DungeonUtils = {}
|
|
|
|
function DungeonUtils.checkCanOpenPhase(args)
|
|
|
|
local dungeonId = args.dungeonId or ""
|
|
local dungeonSeriesId = args.dungeonSeriesId or ""
|
|
|
|
local dungeonIdValid = Tables.dungeonTable:ContainsKey(dungeonId)
|
|
local dungeonSeriesIdValid = Tables.dungeonSeriesTable:ContainsKey(dungeonSeriesId)
|
|
|
|
if not dungeonIdValid and not dungeonSeriesIdValid then
|
|
logger.error("open failed: both dungeonId and dungeonSeriesId invalid")
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function DungeonUtils.isDungeonUnlock(dungeonId)
|
|
return GameInstance.dungeonManager:IsDungeonUnlocked(dungeonId)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonPassed(dungeonId)
|
|
return GameInstance.dungeonManager:IsDungeonPassed(dungeonId)
|
|
end
|
|
|
|
|
|
function DungeonUtils.isDungeonActive(dungeonId)
|
|
return GameInstance.dungeonManager:IsDungeonActive(dungeonId)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonHasHunterMode(dungeonId)
|
|
return not string.isEmpty(Tables.dungeonTable[dungeonId].hunterModeRewardId)
|
|
end
|
|
|
|
function DungeonUtils.isHunterModeUnlocked()
|
|
return GameInstance.dungeonManager:IsHunterModeUnlocked()
|
|
end
|
|
|
|
function DungeonUtils.isDungeonCostStamina(dungeonId)
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local hasHunterMode = DungeonUtils.isDungeonHasHunterMode(dungeonId)
|
|
local hunterModeOpen = DungeonUtils.isHunterModeUnlocked()
|
|
|
|
if hasHunterMode and hunterModeOpen and dungeonCfg.hunterModeCostStamina > 0 then
|
|
return true, dungeonCfg.hunterModeCostStamina
|
|
end
|
|
|
|
if not hasHunterMode and dungeonCfg.costStamina > 0 then
|
|
return true, dungeonCfg.costStamina
|
|
end
|
|
|
|
return false, 0
|
|
end
|
|
|
|
function DungeonUtils.diffActionByConditionId(conditionId)
|
|
local conditionCfg = Tables.gameMechanicConditionTable[conditionId]
|
|
local conditionType = conditionCfg.conditionType
|
|
local param = conditionCfg.parameter[0]
|
|
if conditionType == GEnums.ConditionType.CheckPassGameMechanicsId then
|
|
local preDungeonId = param.valueStringList[0]
|
|
local dungeonTypeCfg = Tables.dungeonTypeTable[Tables.dungeonTable[preDungeonId].dungeonCategory]
|
|
local _, instId = GameInstance.player.mapManager:GetMapMarkInstId(dungeonTypeCfg.mapMarkType, Tables.dungeonTable[preDungeonId].dungeonSeriesId)
|
|
MapUtils.openMap(instId)
|
|
elseif conditionType == GEnums.ConditionType.CheckSceneGrade then
|
|
local levelId = param.valueStringList[0]
|
|
MapUtils.openMap(nil, levelId)
|
|
elseif conditionType == GEnums.ConditionType.QuestStateEqual then
|
|
local questId = param.valueStringList[0]
|
|
local missionId = GameInstance.player.mission:GetMissionIdByQuestId(questId)
|
|
PhaseManager:OpenPhase(PhaseId.Mission, {autoSelect = missionId, useBlackMask = true})
|
|
elseif conditionType == GEnums.ConditionType.MissionStateEqual then
|
|
local missionId = param.valueStringList[0]
|
|
PhaseManager:OpenPhase(PhaseId.Mission, {autoSelect = missionId, useBlackMask = true})
|
|
else
|
|
Notify(MessageConst.SHOW_TOAST, "Error")
|
|
end
|
|
end
|
|
|
|
function DungeonUtils.getConditionCanJump(dungeonId, conditionId)
|
|
local conditionCfg = Tables.gameMechanicConditionTable[conditionId]
|
|
local conditionType = conditionCfg.conditionType
|
|
if conditionCfg.parameter.length == 0 then
|
|
return false
|
|
end
|
|
local param = conditionCfg.parameter[0]
|
|
if conditionType == GEnums.ConditionType.CheckPassGameMechanicsId then
|
|
local preDungeonId = param.valueStringList[0]
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local preDungeonCfg = Tables.dungeonTable[preDungeonId]
|
|
return dungeonCfg.dungeonSeriesId ~= preDungeonCfg.dungeonSeriesId
|
|
end
|
|
return true
|
|
end
|
|
|
|
function DungeonUtils.getUncompletedConditionIds(dungeonId)
|
|
local uncompletedConditionIds = {}
|
|
local _, gameUnlockCondition = GameInstance.player.subGameSys:TryGetSubGameUnlockCondition(dungeonId)
|
|
for conditionId, completed in pairs(gameUnlockCondition.unlockConditionFlags) do
|
|
if not completed then
|
|
table.insert(uncompletedConditionIds, conditionId)
|
|
end
|
|
end
|
|
|
|
return uncompletedConditionIds
|
|
end
|
|
|
|
function DungeonUtils.groupDungeonsByCondition(dungeonIds)
|
|
|
|
local rootDungeonIds = {}
|
|
for _, v in ipairs(dungeonIds) do
|
|
if Tables.GameMechanicGroupByConditionTable:ContainsKey(v) then
|
|
table.insert(rootDungeonIds, v)
|
|
end
|
|
end
|
|
|
|
local groupDungeonIds = {}
|
|
for _, rootDungeonId in ipairs(rootDungeonIds) do
|
|
local group = {}
|
|
table.insert(group, rootDungeonId)
|
|
local _, data = Tables.GameMechanicGroupByConditionTable:TryGetValue(rootDungeonId)
|
|
for _, childDungeonId in pairs(data.childGameMechanicsId) do
|
|
local exist = false
|
|
for _, v in ipairs(dungeonIds) do
|
|
if v == childDungeonId then
|
|
exist = true
|
|
end
|
|
end
|
|
if exist then
|
|
table.insert(group, childDungeonId)
|
|
end
|
|
end
|
|
table.insert(groupDungeonIds, group)
|
|
end
|
|
return #rootDungeonIds > 0, groupDungeonIds
|
|
end
|
|
|
|
function DungeonUtils.getEntryLocation(levelId, ignoreDomain)
|
|
if string.isEmpty(levelId) then
|
|
return ""
|
|
end
|
|
|
|
local domainId = DataManager.levelBasicInfoTable:get_Item(levelId).domainName
|
|
local levelName = Tables.levelDescTable[levelId].showName
|
|
|
|
if ignoreDomain then
|
|
return levelName
|
|
else
|
|
local succ, domainDataCfg = Tables.domainDataTable:TryGetValue(domainId)
|
|
if succ then
|
|
return domainDataCfg.domainName.."-"..levelName
|
|
else
|
|
|
|
return levelName
|
|
end
|
|
end
|
|
end
|
|
|
|
function DungeonUtils.getListByStr(str)
|
|
return string.isEmpty(str) and {} or string.split(str, "\n")
|
|
end
|
|
|
|
function DungeonUtils.getEntryText(dungeonId)
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local succ, dungeonTypeCfg = Tables.dungeonTypeTable:TryGetValue(dungeonCfg.dungeonCategory)
|
|
local entryText = succ and dungeonTypeCfg.entryText or dungeonCfg.dungeonCategory
|
|
return entryText
|
|
end
|
|
|
|
function DungeonUtils.onClickExitDungeonBtn()
|
|
local dungeonId = GameInstance.dungeonManager.curDungeonId
|
|
if string.isEmpty(dungeonId) then
|
|
return
|
|
end
|
|
|
|
|
|
if GameWorld.worldInfo.subGame == nil then
|
|
return
|
|
end
|
|
|
|
if not string.isEmpty(GameInstance.player.systemActionConflictManager.curProcessingSystemAction) then
|
|
logger.warn("DungeonUtils.onClickExitDungeonBtn systemConflict:", GameInstance.player.systemActionConflictManager:GetCurProcessingSystemActionInfo())
|
|
return
|
|
end
|
|
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local confirmHint
|
|
local succ, dungeonTypeCfg = Tables.dungeonTypeTable:TryGetValue(dungeonCfg.dungeonCategory)
|
|
if succ then
|
|
confirmHint = GameWorld.worldInfo.subGame.isPass and dungeonTypeCfg.afterSuccStopConfirmText or
|
|
dungeonTypeCfg.beforeSuccStopConfirmText
|
|
else
|
|
confirmHint = "副本类型表中没有配置:" .. dungeonCfg.dungeonCategory
|
|
end
|
|
local arg = {
|
|
content = confirmHint,
|
|
onConfirm = function()
|
|
GameInstance.dungeonManager:LeaveDungeon()
|
|
end,
|
|
freezeWorld = true,
|
|
pauseGame = true,
|
|
showGameSettingBtn = true,
|
|
interrupt = {
|
|
interruptMessage = { MessageConst.SHOW_DEATH_INFO },
|
|
},
|
|
}
|
|
|
|
if succ and dungeonTypeCfg.dungeonType == "dungeon_weeklyraid" then
|
|
AudioAdapter.PostEvent("Au_UI_Menu_StripMenuPauseTick_Open")
|
|
arg.onCancel = function()
|
|
AudioAdapter.PostEvent("Au_UI_Menu_StripMenuPauseTick_Close")
|
|
end
|
|
end
|
|
|
|
Notify(MessageConst.SHOW_POP_UP, arg)
|
|
end
|
|
|
|
|
|
function DungeonUtils.getDungeonChestCount(sceneId)
|
|
|
|
local collectionManager = GameInstance.player.collectionManager
|
|
|
|
local sceneCollectionData = collectionManager:GetSceneData(sceneId)
|
|
if not sceneCollectionData then
|
|
return 0, 0
|
|
end
|
|
|
|
local chestTag = Tables.dungeonConst.dungeonChestCollectionTag
|
|
local _, chestIdList = Tables.collectionLabelTable:TryGetValue(chestTag)
|
|
local gainedCount = 0
|
|
local maxCount = 0
|
|
for _, idCfg in pairs(chestIdList.list) do
|
|
local gained = sceneCollectionData:GetItemCurCnt(idCfg.prefabId)
|
|
local total = sceneCollectionData:GetItemTotalCnt(idCfg.prefabId)
|
|
gainedCount = gainedCount + gained
|
|
maxCount = maxCount + total
|
|
end
|
|
|
|
return gainedCount, maxCount
|
|
end
|
|
|
|
|
|
function DungeonUtils.TryShowDungeonInsufficientStaminaPopup(dungeonId, confirmCallback)
|
|
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local serializedHintKey = string.format(DungeonConst.IGNORE_STAMINA_SHORT_HINT_FORMAT, dungeonCfg.dungeonSeriesId)
|
|
local succ, ignoreHint = ClientDataManagerInst:GetBool(serializedHintKey, false, false, DungeonConst.SERIALIZED_CATEGORY)
|
|
|
|
if ignoreHint then
|
|
confirmCallback()
|
|
else
|
|
local hasHunterMode = DungeonUtils.isDungeonHasHunterMode(dungeonId)
|
|
local hintContent
|
|
if hasHunterMode then
|
|
if GameInstance.dungeonManager:IsDungeonFirstPassRewardGained(dungeonId) then
|
|
hintContent = Language.LUA_DUNGEON_HUNTER_MODE_STAMINA_SHORT_CONFIRM_HINT
|
|
else
|
|
hintContent = Language.LUA_DUNGEON_HUNTER_MODE_STAMINA_SHORT_WITH_REWARD_CONFIRM_HINT
|
|
end
|
|
else
|
|
hintContent = Language.LUA_DUNGEON_STAMINA_SHORT_CONFIRM_HINT
|
|
end
|
|
|
|
local closuresIsOn = false
|
|
Notify(MessageConst.SHOW_POP_UP, {
|
|
toggle = {
|
|
onValueChanged = function(isOn)
|
|
closuresIsOn = isOn
|
|
end,
|
|
toggleText = Language.LUA_DUNGEON_TODAY_IGNORE_SHORT_STAMINA_HINT,
|
|
isOn = false,
|
|
},
|
|
content = hintContent,
|
|
onConfirm = function()
|
|
ClientDataManagerInst:SetBool(serializedHintKey, closuresIsOn, false, DungeonConst.SERIALIZED_CATEGORY, true, EClientDataTimeValidType.CurrentDay)
|
|
confirmCallback()
|
|
end,
|
|
onCancel = function()
|
|
end
|
|
})
|
|
end
|
|
end
|
|
|
|
function DungeonUtils.isDungeonPerfectComplete(dungeonId)
|
|
|
|
local dungeonManager = GameInstance.dungeonManager
|
|
local isComplete = dungeonManager:IsDungeonPassed(dungeonId)
|
|
local _, dungeonCfg = Tables.dungeonTable:TryGetValue(dungeonId)
|
|
local _, rewardCfg = Tables.rewardTable:TryGetValue(dungeonCfg.extraRewardId)
|
|
local hasExtraReward = rewardCfg ~= nil
|
|
local collectChestNum, maxChestNum = DungeonUtils.getDungeonChestCount(dungeonCfg.sceneId)
|
|
local isPerfectComplete = isComplete and
|
|
(not hasExtraReward or dungeonManager:IsDungeonExtraRewardGained(dungeonId)) and
|
|
(maxChestNum < 1 or collectChestNum >= maxChestNum)
|
|
return isPerfectComplete
|
|
end
|
|
|
|
|
|
|
|
|
|
function DungeonUtils.genFirstPartRewardsInfo(dungeonId)
|
|
local firstRowRewards = {}
|
|
|
|
local dungeonMgr = GameInstance.dungeonManager
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
|
|
local gained = dungeonMgr:IsDungeonFirstPassRewardGained(dungeonId)
|
|
local rewardId = dungeonCfg.firstPassRewardId
|
|
if not string.isEmpty(rewardId) then
|
|
local rewardCfg = Tables.rewardTable[rewardId]
|
|
for _, itemBundle in pairs(rewardCfg.itemBundles) do
|
|
local itemCfg = Tables.itemTable[itemBundle.id]
|
|
table.insert(firstRowRewards, {
|
|
id = itemBundle.id,
|
|
count = itemBundle.count,
|
|
gained = gained,
|
|
sortId1 = itemCfg.sortId1,
|
|
sortId2 = itemCfg.sortId2,
|
|
})
|
|
end
|
|
end
|
|
|
|
|
|
local hasRecycleReward = not string.isEmpty(dungeonCfg.rewardId)
|
|
if hasRecycleReward then
|
|
local rewardCfg = Tables.rewardTable[dungeonCfg.rewardId]
|
|
for _, itemBundle in pairs(rewardCfg.itemBundles) do
|
|
local itemCfg = Tables.itemTable[itemBundle.id]
|
|
table.insert(firstRowRewards, {
|
|
id = itemBundle.id,
|
|
count = itemBundle.count,
|
|
sortId1 = itemCfg.sortId1,
|
|
sortId2 = itemCfg.sortId2,
|
|
})
|
|
end
|
|
end
|
|
|
|
table.sort(firstRowRewards, Utils.genSortFunction(UIConst.COMMON_ITEM_SORT_KEYS))
|
|
|
|
return firstRowRewards
|
|
end
|
|
|
|
|
|
function DungeonUtils.genSecondPartRewardsInfo(dungeonId)
|
|
local secondRowRewards = {}
|
|
|
|
local dungeonMgr = GameInstance.dungeonManager
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
|
|
local rewardId = dungeonCfg.extraRewardId
|
|
|
|
local hasExtraReward = not string.isEmpty(rewardId)
|
|
local hunterModeRewardId = dungeonCfg.hunterModeRewardId
|
|
local hasHunterModeReward = not string.isEmpty(hunterModeRewardId)
|
|
if hasExtraReward then
|
|
|
|
local gained = dungeonMgr:IsDungeonExtraRewardGained(dungeonId)
|
|
local rewardCfg = Tables.rewardTable[rewardId]
|
|
for _, itemBundle in pairs(rewardCfg.itemBundles) do
|
|
local itemCfg = Tables.itemTable[itemBundle.id]
|
|
table.insert(secondRowRewards, {
|
|
id = itemBundle.id,
|
|
count = itemBundle.count,
|
|
gained = gained,
|
|
typeTag = DungeonConst.DUNGEON_REWARD_TAG_STATE.Extra,
|
|
sortId1 = itemCfg.sortId1,
|
|
sortId2 = itemCfg.sortId2,
|
|
})
|
|
end
|
|
table.sort(secondRowRewards, Utils.genSortFunction(UIConst.COMMON_ITEM_SORT_KEYS))
|
|
elseif hasHunterModeReward then
|
|
local rewardCfg = Tables.rewardTable[hunterModeRewardId]
|
|
|
|
|
|
for _, itemBundle in pairs(rewardCfg.itemBundles) do
|
|
local itemCfg = Tables.itemTable[itemBundle.id]
|
|
table.insert(secondRowRewards, {
|
|
id = itemCfg.id,
|
|
typeSortId = 0,
|
|
typeTag = DungeonConst.DUNGEON_REWARD_TAG_STATE.Regular,
|
|
sortId1 = itemCfg.sortId1,
|
|
sortId2 = itemCfg.sortId2,
|
|
})
|
|
end
|
|
|
|
for _, itemBundle in pairs(rewardCfg.probItemBundles) do
|
|
local itemCfg = Tables.itemTable[itemBundle.id]
|
|
table.insert(secondRowRewards, {
|
|
id = itemCfg.id,
|
|
typeSortId = -1,
|
|
typeTag = DungeonConst.DUNGEON_REWARD_TAG_STATE.Random,
|
|
sortId1 = itemCfg.sortId1,
|
|
sortId2 = itemCfg.sortId2,
|
|
})
|
|
end
|
|
|
|
local sortKeys = UIConst.COMMON_ITEM_SORT_KEYS
|
|
table.insert(sortKeys, 1, "typeSortId")
|
|
table.sort(secondRowRewards, Utils.genSortFunction(sortKeys))
|
|
end
|
|
|
|
return secondRowRewards
|
|
end
|
|
|
|
function DungeonUtils.getRewardsDetailFirstRowTitle(dungeonId)
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local langKey = DungeonConst.DUNGEON_FIRST_ROW_REWARDS_TITLE[dungeonCfg.dungeonCategory]
|
|
if langKey == nil then
|
|
return
|
|
end
|
|
return Language[langKey]
|
|
end
|
|
|
|
function DungeonUtils.getRewardsDetailSecondRowTitle(dungeonId)
|
|
local dungeonCfg = Tables.dungeonTable[dungeonId]
|
|
local extraRewardId = dungeonCfg.extraRewardId
|
|
local hunterModeId = dungeonCfg.hunterModeRewardId
|
|
if not string.isEmpty(extraRewardId) then
|
|
return Language.LUA_DUNGEON_REWARD_SHOW_EXTRAREWARD
|
|
elseif not string.isEmpty(hunterModeId) then
|
|
return Language.LUA_DUNGEON_REWARD_SHOW_HUNTERMODE
|
|
else
|
|
return "TBD"
|
|
end
|
|
end
|
|
|
|
function DungeonUtils.startSubGameLeaveTick(action)
|
|
local tickId = LuaUpdate:Add("LateTick", function(deltaTime)
|
|
local game = GameWorld.worldInfo.subGame
|
|
local leftTime = 0
|
|
if game ~= nil then
|
|
leftTime = game:GetRealLeaveTimestampForLua() - DateTimeUtils.GetCurrentTimestampBySeconds()
|
|
end
|
|
if leftTime >= 0 and action ~= nil then
|
|
action(leftTime)
|
|
end
|
|
end)
|
|
return tickId
|
|
end
|
|
|
|
|
|
|
|
|
|
function DungeonUtils.onClickDungeonInfoBtn()
|
|
local dungeonId = GameInstance.dungeonManager.curDungeonId
|
|
if DungeonUtils.isDungeonCharTutorial(dungeonId) then
|
|
|
|
local curStage = GameWorld.worldInfo.subGame.stage
|
|
local charTutorialCfg = Tables.dungeonCharTutorialTable[dungeonId]
|
|
local stageCfg = charTutorialCfg.tutorialStageData[CSIndex(curStage)]
|
|
|
|
GameAction.ManuallyStartGuideGroup(stageCfg.guideGroupId)
|
|
else
|
|
UIManager:AutoOpen(PanelId.DungeonInfoPopup, { dungeonId = dungeonId, needBindAction = true })
|
|
end
|
|
end
|
|
|
|
function DungeonUtils.checkVisibilityDungeonInfoBtn()
|
|
if not Utils.isInDungeon() then
|
|
return false
|
|
end
|
|
|
|
local curDungeonId = GameInstance.dungeonManager.curDungeonId
|
|
if not DungeonUtils.isDungeonCharTutorial(curDungeonId) then
|
|
return DungeonUtils.isDungeonHasFeatureInfo(curDungeonId)
|
|
end
|
|
|
|
|
|
local game = GameWorld.worldInfo.subGame
|
|
if not game then
|
|
return false
|
|
end
|
|
|
|
|
|
|
|
local stage = game.stage
|
|
local charTutorialCfg = Tables.dungeonCharTutorialTable[curDungeonId]
|
|
local tutorialStageCfg = charTutorialCfg.tutorialStageData[CSIndex(stage)]
|
|
return not string.isEmpty(tutorialStageCfg.guideGroupId)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonHasFeatureInfo(dungeonId)
|
|
if string.isEmpty(dungeonId) then
|
|
return false
|
|
end
|
|
|
|
local succ, dungeonCfg = Tables.dungeonTable:TryGetValue(dungeonId)
|
|
return succ and not string.isEmpty(dungeonCfg.featureDesc)
|
|
end
|
|
|
|
|
|
function DungeonUtils.checkCanPopupInfoPanel(dungeonId)
|
|
|
|
if DungeonUtils.isDungeonCharTutorial(dungeonId) then
|
|
return false
|
|
end
|
|
|
|
if not DungeonUtils.isDungeonHasFeatureInfo(dungeonId) then
|
|
return false
|
|
end
|
|
|
|
if GameInstance.dungeonManager:IsDungeonPassed(dungeonId) then
|
|
return false
|
|
end
|
|
|
|
local succ, dungeonCfg = Tables.dungeonTable:TryGetValue(dungeonId)
|
|
if succ and dungeonCfg.forceIgnoreFeaturePopup then
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
function DungeonUtils.dungeonTypeValidate(dungeonId, dungeonCategory)
|
|
local succ, dungeonCfg = Tables.dungeonTable:TryGetValue(dungeonId)
|
|
return succ and dungeonCfg.dungeonCategory == dungeonCategory
|
|
end
|
|
|
|
function DungeonUtils.isDungeonTrain(dungeonId)
|
|
return DungeonUtils.dungeonTypeValidate(dungeonId, DungeonConst.DUNGEON_CATEGORY.Train)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonCharTutorial(dungeonId)
|
|
return DungeonUtils.dungeonTypeValidate(dungeonId, DungeonConst.DUNGEON_CATEGORY.CharTutorial)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonChar(dungeonId)
|
|
return DungeonUtils.dungeonTypeValidate(dungeonId, DungeonConst.DUNGEON_CATEGORY.Char)
|
|
end
|
|
|
|
function DungeonUtils.isDungeonChallenge(dungeonId)
|
|
return DungeonUtils.dungeonTypeValidate(dungeonId, DungeonConst.DUNGEON_CATEGORY.Challenge)
|
|
end
|
|
|
|
|
|
|
|
|
|
_G.DungeonUtils = DungeonUtils
|
|
return DungeonUtils |