From a8b2f21cd78a417cc2227a69d7eb529bdc2a3302 Mon Sep 17 00:00:00 2001 From: Christian Doczkal <20443222+chdoc@users.noreply.github.com> Date: Fri, 4 Jul 2025 13:08:18 +0200 Subject: [PATCH 1/2] prioritize high-value meals and don't go eating or drinking on a full stomach --- changelog.txt | 1 + immortal-cravings.lua | 49 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/changelog.txt b/changelog.txt index a937e2fb2..142f3f545 100644 --- a/changelog.txt +++ b/changelog.txt @@ -36,6 +36,7 @@ Template for new versions: - `gui/journal`: fix typo which caused the table of contents to always be regenerated even when not needed - `gui/mod-manager`: gracefully handle mods with missing or broken ``info.txt`` files - `uniform-unstick`: resolve overlap with new buttons in 51.13 +- `immortal-cravings`: prioritize high-value meals and don't go eating or drinking on a full stomach ## Misc Improvements diff --git a/immortal-cravings.lua b/immortal-cravings.lua index 2162045fd..de251f3fc 100644 --- a/immortal-cravings.lua +++ b/immortal-cravings.lua @@ -13,6 +13,27 @@ function distance(p1, p2) return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + math.abs(p1.z - p2.z) end +---find best item in an item vector (according to some metric) +---@generic T : df.item +---@param item_vector T[] +---@param metric fun(item: T): number +---@param is_good? fun(item: T): boolean +---@return T? +function findBest(item_vector, metric, is_good) + local best = nil + local mbest = -1 + for _,item in ipairs(item_vector) do + if not item.flags.in_job and (not is_good or is_good(item)) then + mitem = metric(item) + if not best or mitem > mbest then + best = item + mbest = mitem + end + end + end + return best +end + ---find closest accessible item in an item vector ---@generic T : df.item ---@param pos df.coord @@ -46,19 +67,28 @@ local function get_closest_drink(pos) return findClosest(pos, df.global.world.items.other.DRINK, is_good) end ----find some prepared meal +---find highest-value accessible meal ---@return df.item_foodst? -local function get_closest_meal(pos) +local function get_best_meal(pos) + ---@param meal df.item_foodst local function is_good(meal) - if meal.flags.rotten then + local accessible = dfhack.maps.canWalkBetween(pos,xyz2pos(dfhack.items.getPosition(meal))) + if meal.flags.rotten or not accessible then return false else + -- check that meal is either on the ground or in food storage (and not in a backpack) local container = dfhack.items.getContainer(meal) return not container or container:isFoodStorage() end end - return findClosest(pos, df.global.world.items.other.FOOD, is_good) + + ---@param meal df.item_foodst + local function portion_value(meal) + return dfhack.items.getValue(meal) / meal.stack_size + end + + return findBest(df.global.world.items.other.FOOD, portion_value, is_good) end ---create a Drink job for the given unit @@ -86,7 +116,7 @@ end ---create Eat job for the given unit ---@param unit df.unit local function goEat(unit) - local meal = get_closest_meal(unit.pos) + local meal = get_best_meal(unit.pos) if not meal then -- print('no accessible meals found') return @@ -181,12 +211,15 @@ local function main_loop() -- print('immortal-cravings watching:') watched = {} for _, unit in ipairs(dfhack.units.getCitizens()) do - if not is_active_caste_flag(unit, 'NO_DRINK') and not is_active_caste_flag(unit, 'NO_EAT') then + if + not (is_active_caste_flag(unit, 'NO_DRINK') or is_active_caste_flag(unit, 'NO_EAT')) or + unit.counters2.stomach_content > 0 + then goto next_unit end for _, need in ipairs(unit.status.current_soul.personality.needs) do - if need.id == DrinkAlcohol and need.focus_level < threshold or - need.id == EatGoodMeal and need.focus_level < threshold + if need.id == DrinkAlcohol and need.focus_level < threshold or + need.id == EatGoodMeal and need.focus_level < threshold then table.insert(watched, unit.id) -- print(' '..dfhack.df2console(dfhack.units.getReadableName(unit))) From 8490a13ad4ac16682655b52a6c6acff8758b36f7 Mon Sep 17 00:00:00 2001 From: Christian Doczkal <20443222+chdoc@users.noreply.github.com> Date: Sat, 2 Aug 2025 18:11:38 +0200 Subject: [PATCH 2/2] simplify code and properly split off portions --- changelog.txt | 2 +- immortal-cravings.lua | 117 +++++++++++++++++++++++++++--------------- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/changelog.txt b/changelog.txt index 142f3f545..355043437 100644 --- a/changelog.txt +++ b/changelog.txt @@ -36,7 +36,7 @@ Template for new versions: - `gui/journal`: fix typo which caused the table of contents to always be regenerated even when not needed - `gui/mod-manager`: gracefully handle mods with missing or broken ``info.txt`` files - `uniform-unstick`: resolve overlap with new buttons in 51.13 -- `immortal-cravings`: prioritize high-value meals and don't go eating or drinking on a full stomach +- `immortal-cravings`: prioritize high-value meals, properly split of portions, and don't go eating or drinking on a full stomach ## Misc Improvements diff --git a/immortal-cravings.lua b/immortal-cravings.lua index de251f3fc..21ae333ec 100644 --- a/immortal-cravings.lua +++ b/immortal-cravings.lua @@ -3,8 +3,18 @@ local idle = reqscript('idle-crafting') local repeatutil = require("repeat-util") + --- utility functions +local verbose = false +---conditional printing of debug messages +---@param message string +local function debug(message) + if verbose then + print(message) + end +end + ---3D city metric ---@param p1 df.coord ---@param p2 df.coord @@ -13,22 +23,20 @@ function distance(p1, p2) return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + math.abs(p1.z - p2.z) end ----find best item in an item vector (according to some metric) +---maybe a candidate for utils.lua? +---find best available item in an item vector (according to some metric) ---@generic T : df.item ---@param item_vector T[] ----@param metric fun(item: T): number ----@param is_good? fun(item: T): boolean +---@param metric fun(item: T): number? ---@return T? -function findBest(item_vector, metric, is_good) +function findBest(item_vector, metric, smallest) local best = nil - local mbest = -1 - for _,item in ipairs(item_vector) do - if not item.flags.in_job and (not is_good or is_good(item)) then - mitem = metric(item) - if not best or mitem > mbest then - best = item - mbest = mitem - end + local mbest = nil + for _, item in ipairs(item_vector) do + mitem = metric(item) + if mitem and (not best or (smallest and mitem < mbest or mitem > mbest)) then + best = item + mbest = mitem end end return best @@ -41,19 +49,14 @@ end ---@param is_good? fun(item: T): boolean ---@return T? local function findClosest(pos, item_vector, is_good) - local closest = nil - local dclosest = -1 - for _,item in ipairs(item_vector) do - if not item.flags.in_job and (not is_good or is_good(item)) then + local function metric(item) + if not is_good or is_good(item) then local pitem = xyz2pos(dfhack.items.getPosition(item)) - local ditem = distance(pos, pitem) - if dfhack.maps.canWalkBetween(pos, pitem) and (not closest or ditem < dclosest) then - closest = item - dclosest = ditem - end + return dfhack.maps.canWalkBetween(pos, pitem) and distance(pos, pitem) or nil end + return nil end - return closest + return findBest(item_vector, metric, true) end ---find a drink @@ -62,33 +65,32 @@ end local function get_closest_drink(pos) local is_good = function (drink) local container = dfhack.items.getContainer(drink) - return container and container:isFoodStorage() + return not drink.flags.in_job and container and container:isFoodStorage() end return findClosest(pos, df.global.world.items.other.DRINK, is_good) end ----find highest-value accessible meal +---find available meal with highest per-portion value ---@return df.item_foodst? local function get_best_meal(pos) ---@param meal df.item_foodst - local function is_good(meal) + local function portion_value(meal) local accessible = dfhack.maps.canWalkBetween(pos,xyz2pos(dfhack.items.getPosition(meal))) - if meal.flags.rotten or not accessible then - return false + if meal.flags.in_job or meal.flags.rotten or not accessible then + return nil else -- check that meal is either on the ground or in food storage (and not in a backpack) local container = dfhack.items.getContainer(meal) - return not container or container:isFoodStorage() + if not container or container:isFoodStorage() then + return dfhack.items.getValue(meal) / meal.stack_size + else + return nil + end end end - ---@param meal df.item_foodst - local function portion_value(meal) - return dfhack.items.getValue(meal) / meal.stack_size - end - - return findBest(df.global.world.items.other.FOOD, portion_value, is_good) + return findBest(df.global.world.items.other.FOOD, portion_value) end ---create a Drink job for the given unit @@ -116,11 +118,22 @@ end ---create Eat job for the given unit ---@param unit df.unit local function goEat(unit) - local meal = get_best_meal(unit.pos) - if not meal then + local meal_stack = get_best_meal(unit.pos) + if not meal_stack then -- print('no accessible meals found') return end + + ---@type df.item|df.item_foodst + local meal + if meal_stack.stack_size > 1 then + meal = meal_stack:splitStack(1, true) + meal:categorize(true) + else + meal = meal_stack + end + dfhack.items.setOwner(meal, unit) + local job = idle.make_job() job.job_type = df.job_type.Eat job.flags.special = true @@ -135,6 +148,25 @@ local function goEat(unit) print(dfhack.df2console('immortal-cravings: %s is getting something to eat'):format(name)) end +---unit is ready to take jobs (will interrupt social activities) +---@param unit df.unit +---@return boolean +function unitIsAvailable(unit) + if unit.job.current_job then + return false + elseif #unit.individual_drills > 0 then + return false + elseif unit.flags1.caged or unit.flags1.chained then + return false + elseif unit.military.squad_id ~= -1 then + local squad = df.squad.find(unit.military.squad_id) + -- this lookup should never fail + ---@diagnostic disable-next-line: need-check-nil + return #squad.orders == 0 and squad.activity == -1 + end + return true +end + --- script logic local GLOBAL_KEY = 'immortal-cravings' @@ -167,7 +199,7 @@ local threshold = -9000 ---unit loop: check for idle watched units and create eat/drink jobs for them local function unit_loop() - -- print(('immortal-cravings: running unit loop (%d watched units)'):format(#watched)) + debug(('immortal-cravings: running unit loop (%d watched units)'):format(#watched)) ---@type integer[] local kept = {} for _, unit_id in ipairs(watched) do @@ -178,7 +210,8 @@ local function unit_loop() then goto next_unit end - if not idle.unitIsAvailable(unit) then + if not unitIsAvailable(unit) then + debug("immortal-cravings: skipping busy"..dfhack.units.getReadableName(unit)) table.insert(kept, unit.id) else -- unit is available for jobs; satisfy one of its needs @@ -196,7 +229,7 @@ local function unit_loop() end watched = kept if #watched == 0 then - -- print('immortal-cravings: no more watched units, cancelling unit loop') + debug('immortal-cravings: no more watched units, cancelling unit loop') repeatutil.cancel(GLOBAL_KEY .. '-unit') end end @@ -208,9 +241,9 @@ end ---main loop: look for citizens with personality needs for food/drink but w/o physiological need local function main_loop() - -- print('immortal-cravings watching:') + debug('immortal-cravings watching:') watched = {} - for _, unit in ipairs(dfhack.units.getCitizens()) do + for _, unit in ipairs(dfhack.units.getCitizens(false, false)) do if not (is_active_caste_flag(unit, 'NO_DRINK') or is_active_caste_flag(unit, 'NO_EAT')) or unit.counters2.stomach_content > 0 @@ -222,7 +255,7 @@ local function main_loop() need.id == EatGoodMeal and need.focus_level < threshold then table.insert(watched, unit.id) - -- print(' '..dfhack.df2console(dfhack.units.getReadableName(unit))) + debug(' '..dfhack.df2console(dfhack.units.getReadableName(unit))) goto next_unit end end