From a342a227374d16414a70ef1ca98332abf6b44ff3 Mon Sep 17 00:00:00 2001 From: Christian Doczkal <20443222+chdoc@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:41:47 +0200 Subject: [PATCH 1/4] new tool: immortal-cravings --- changelog.txt | 1 + docs/immortal-cravings.rst | 18 +++ immortal-cravings.lua | 231 +++++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 docs/immortal-cravings.rst create mode 100644 immortal-cravings.lua diff --git a/changelog.txt b/changelog.txt index d4b6641e5..6ad27e1a5 100644 --- a/changelog.txt +++ b/changelog.txt @@ -31,6 +31,7 @@ Template for new versions: - `idle-crafting`: allow dwarves to independently satisfy their need to craft objects - `gui/family-affairs`: (reinstated) inspect or meddle with pregnancies, marriages, or lover relationships - `notes`: manage map-specific notes +- `immortal-cravings`: allow immortals to satisfy their cravings for food and drink ## New Features - `caravan`: DFHack dialogs for trade screens (both ``Bring goods to depot`` and the ``Trade`` barter screen) can now filter by item origins (foreign vs. fort-made) and can filter bins by whether they have a mix of ethically acceptable and unacceptable items in them diff --git a/docs/immortal-cravings.rst b/docs/immortal-cravings.rst new file mode 100644 index 000000000..2fb851b9d --- /dev/null +++ b/docs/immortal-cravings.rst @@ -0,0 +1,18 @@ +immortal-cravings +================= + +.. dfhack-tool:: + :summary: Allow immortals to satisfy their cravings for food and drink. + :tags: fort gameplay + +When enabled, this script watches your fort for units that have no physiological +need to eat or drink but still have personality needs that can only be satisfied +by eating or drinking (e.g. necromancers). This enables those units to help +themselves to a drink or a meal when they crave one and are not otherwise +occupied. + +Usage +----- + +``enable immortal-cravings`` +``disable immortal-cravings`` diff --git a/immortal-cravings.lua b/immortal-cravings.lua new file mode 100644 index 000000000..c945b44f8 --- /dev/null +++ b/immortal-cravings.lua @@ -0,0 +1,231 @@ +--@enable = true +--@module = true + +local idle = reqscript('idle-crafting') +local repeatutil = require("repeat-util") +--- utility functions + +---3D city metric +---@param p1 df.coord +---@param p2 df.coord +---@return number +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 closest accessible item in an item vector +---@generic T : df.item +---@param pos df.coord +---@param item_vector T[] +---@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 x, y, z = dfhack.items.getPosition(item) + local pitem = xyz2pos(x, y, z) + local ditem = distance(pos, pitem) + if dfhack.maps.canWalkBetween(pos, pitem) and (not closest or ditem < dclosest) then + closest = item + dclosest = ditem + end + end + end + return closest +end + +---find a drink +---@param pos df.coord +---@return df.item_drinkst|nil +local function get_closest_drink(pos) + local is_good = function (drink) + local container = dfhack.items.getContainer(drink) + return container and df.item_barrelst:is_instance(container) + end + return findClosest(pos, df.global.world.items.other.DRINK, is_good) +end + +---find some prepared meal +---@return df.item_foodst? +local function get_closest_meal(pos) + ---@param meal df.item_foodst + local function is_good(meal) + return meal.flags.rotten == false + end + return findClosest(pos, df.global.world.items.other.FOOD, is_good) +end + +---create a Drink job for the given unit +---@param unit df.unit +local function goDrink(unit) + local drink = get_closest_drink(unit.pos) + if not drink then + -- print('no accessible drink found') + return + end + local job = idle.make_job() + job.job_type = df.job_type.DrinkItem + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(drink) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, drink, df.job_item_ref.T_role.Other, -1, -1) then + error('could not attach drink') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + print(dfhack.df2console('immortal-cravings: %s is getting a drink'):format(name)) +end + +---create Eat job for the given unit +---@param unit df.unit +local function goEat(unit) + local meal = get_closest_meal(unit.pos) + if not meal then + -- print('no accessible meals found') + return + end + local job = idle.make_job() + job.job_type = df.job_type.Eat + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(meal) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, meal, df.job_item_ref.T_role.Other, -1, -1) then + error('could not attach meal') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + print(dfhack.df2console('immortal-cravings: %s is getting something to eat'):format(name)) +end + +--- script logic + +local GLOBAL_KEY = 'immortal-cravings' + +enabled = enabled or false +function isEnabled() + return enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + enabled = persisted_data.enabled or false +end + +DrinkAlcohol = df.need_type['DrinkAlcohol'] +EatGoodMeal = df.need_type['EatGoodMeal'] + +---@type integer[] +watched = {} + +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)) + ---@type integer[] + local kept = {} + for _, unit_id in ipairs(watched) do + local unit = df.unit.find(unit_id) + if unit and not (unit.flags1.caged or unit.flags1.chained) then + if not idle.unitIsAvailable(unit) then + table.insert(kept, unit.id) + else + -- + for _, need in ipairs(unit.status.current_soul.personality.needs) do + if need.id == DrinkAlcohol and need.focus_level < threshold then + goDrink(unit) + goto next_unit + elseif need.id == EatGoodMeal and need.focus_level < threshold then + goEat(unit) + goto next_unit + end + end + end + else + -- print('immortal-cravings: unit gone or caged') + end + ::next_unit:: + end + watched = kept + if #watched == 0 then + -- print('immortal-cravings: no more watched units, cancelling unit loop') + repeatutil.cancel(GLOBAL_KEY .. '-unit') + end +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:') + watched = {} + for _, unit in ipairs(dfhack.units.getCitizens()) do + if unit.curse.add_tags1.NO_DRINK or unit.curse.add_tags1.NO_EAT then + 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 + then + table.insert(watched, unit.id) + print(' '..dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) + goto next_unit + end + end + end + ::next_unit:: + end + + if #watched > 0 then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-unit', 59, 'ticks', unit_loop) + end +end + +local function start() + if enabled then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-main', 4003, 'ticks', main_loop) + end +end + +local function stop() + repeatutil.cancel(GLOBAL_KEY..'-main') + repeatutil.cancel(GLOBAL_KEY..'-unit') +end + + + +-- script action + +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + enabled = false + return + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + load_state() + start() +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + enabled = true + start() + else + enabled = false + stop() + end + persist_state() +end From 48d19a9a1be283797fecb6809b650d63a02aac5f Mon Sep 17 00:00:00 2001 From: Christian Doczkal <20443222+chdoc@users.noreply.github.com> Date: Wed, 16 Oct 2024 19:54:06 +0200 Subject: [PATCH 2/4] implement suggestions from code review --- docs/immortal-cravings.rst | 1 - immortal-cravings.lua | 59 +++++++++++++++++++++----------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/docs/immortal-cravings.rst b/docs/immortal-cravings.rst index 2fb851b9d..2ef43c720 100644 --- a/docs/immortal-cravings.rst +++ b/docs/immortal-cravings.rst @@ -15,4 +15,3 @@ Usage ----- ``enable immortal-cravings`` -``disable immortal-cravings`` diff --git a/immortal-cravings.lua b/immortal-cravings.lua index c945b44f8..cb4223811 100644 --- a/immortal-cravings.lua +++ b/immortal-cravings.lua @@ -24,8 +24,7 @@ local function findClosest(pos, item_vector, is_good) 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 x, y, z = dfhack.items.getPosition(item) - local pitem = xyz2pos(x, y, z) + 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 @@ -38,11 +37,11 @@ end ---find a drink ---@param pos df.coord ----@return df.item_drinkst|nil +---@return df.item_drinkst? local function get_closest_drink(pos) local is_good = function (drink) local container = dfhack.items.getContainer(drink) - return container and df.item_barrelst:is_instance(container) + return container and container:isFoodStorage() end return findClosest(pos, df.global.world.items.other.DRINK, is_good) end @@ -52,7 +51,12 @@ end local function get_closest_meal(pos) ---@param meal df.item_foodst local function is_good(meal) - return meal.flags.rotten == false + if meal.flags.rotten then + return false + else + 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) end @@ -123,13 +127,13 @@ local function load_state() enabled = persisted_data.enabled or false end -DrinkAlcohol = df.need_type['DrinkAlcohol'] -EatGoodMeal = df.need_type['EatGoodMeal'] +DrinkAlcohol = df.need_type.DrinkAlcohol +EatGoodMeal = df.need_type.EatGoodMeal ---@type integer[] -watched = {} +watched = watched or {} -threshold = -9000 +local threshold = -9000 ---unit loop: check for idle watched units and create eat/drink jobs for them local function unit_loop() @@ -138,23 +142,25 @@ local function unit_loop() local kept = {} for _, unit_id in ipairs(watched) do local unit = df.unit.find(unit_id) - if unit and not (unit.flags1.caged or unit.flags1.chained) then - if not idle.unitIsAvailable(unit) then - table.insert(kept, unit.id) - else - -- - for _, need in ipairs(unit.status.current_soul.personality.needs) do - if need.id == DrinkAlcohol and need.focus_level < threshold then - goDrink(unit) - goto next_unit - elseif need.id == EatGoodMeal and need.focus_level < threshold then - goEat(unit) - goto next_unit - end + if + not unit or not dfhack.units.isActive(unit) or + unit.flags1.caged or unit.flags1.chained + then + goto next_unit + end + if not idle.unitIsAvailable(unit) then + table.insert(kept, unit.id) + else + -- unit is available for jobs; satisfy one of its needs + for _, need in ipairs(unit.status.current_soul.personality.needs) do + if need.id == DrinkAlcohol and need.focus_level < threshold then + goDrink(unit) + break + elseif need.id == EatGoodMeal and need.focus_level < threshold then + goEat(unit) + break end end - else - -- print('immortal-cravings: unit gone or caged') end ::next_unit:: end @@ -167,7 +173,7 @@ 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:') + -- print('immortal-cravings watching:') watched = {} for _, unit in ipairs(dfhack.units.getCitizens()) do if unit.curse.add_tags1.NO_DRINK or unit.curse.add_tags1.NO_EAT then @@ -176,7 +182,7 @@ local function main_loop() need.id == EatGoodMeal and need.focus_level < threshold then table.insert(watched, unit.id) - print(' '..dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) + -- print(' '..dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) goto next_unit end end @@ -208,6 +214,7 @@ end dfhack.onStateChange[GLOBAL_KEY] = function(sc) if sc == SC_MAP_UNLOADED then enabled = false + -- repeat-util will cancel the loops on unload return end From 4e9331ca80ac69581681cb4ab6f63384ae330f62 Mon Sep 17 00:00:00 2001 From: Myk Date: Tue, 12 Nov 2024 03:14:07 -0800 Subject: [PATCH 3/4] doc formatting --- docs/immortal-cravings.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/immortal-cravings.rst b/docs/immortal-cravings.rst index 2ef43c720..dcb3cb13d 100644 --- a/docs/immortal-cravings.rst +++ b/docs/immortal-cravings.rst @@ -14,4 +14,6 @@ occupied. Usage ----- -``enable immortal-cravings`` +:: + + enable immortal-cravings From edc7d40c009581e637c39265eb89d7ea149da7da Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Tue, 12 Nov 2024 03:18:24 -0800 Subject: [PATCH 4/4] add immortal-cravings to control panel registry --- internal/control-panel/registry.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index c71a53f07..548057b32 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -119,6 +119,7 @@ COMMANDS_BY_IDX = { {command='fastdwarf', group='gameplay', mode='enable'}, {command='hermit', group='gameplay', mode='enable'}, {command='hide-tutorials', group='gameplay', mode='system_enable'}, + {command='immortal-cravings', group='gameplay', mode='enable'}, {command='light-aquifers-only', group='gameplay', mode='run'}, {command='misery', group='gameplay', mode='enable'}, {command='orders-reevaluate', help_command='orders', group='gameplay', mode='repeat',