Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new tool: immortal-cravings #1301

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you move this up into the new "Future" section?


## 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
Expand Down
18 changes: 18 additions & 0 deletions docs/immortal-cravings.rst
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you also add this tool to the control panel registry (internal/control-panel/registry.lua)?

Original file line number Diff line number Diff line change
@@ -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``
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
``enable immortal-cravings``
``disable immortal-cravings``
::
enable immortal-cravings

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't usually individually document the disable command

231 changes: 231 additions & 0 deletions immortal-cravings.lua
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a good candidate to move to utils. no need to do it yet, but if/when another Lua tool requires this, we should remember that this is here

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can combine this into

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
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also pots. don't forget about pots!

I think what you want is return container and container:isFoodStorage()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that I tested this change: It appears that isFoodStorage also returns true for backpacks. I just saw one of my necromancers chasing a squad member halfway across the fort, trying to steal his meal from his black pack. 🤣

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: no, backpacks are not food storage. The change you suggested was for drinks, but the same check should also be done when looking for meals. ^^

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return not meal.flags.rotten feels more idiomatic

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']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
DrinkAlcohol = df.need_type['DrinkAlcohol']
EatGoodMeal = df.need_type['EatGoodMeal']
DrinkAlcohol = df.need_type.DrinkAlcohol
EatGoodMeal = df.need_type.EatGoodMeal


---@type integer[]
watched = {}

threshold = -9000
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be local or "protected" globals (foo = foo or default_foo). otherwise, they'll get reset whenever the player runs (probably mistakenly) immortal-cravings

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threshold should definitely be local since it's read only. watched could be either


---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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opportunity to reduce code nesting here

        if not unit or unit.flags1.caged or units.flags1.chained then
            goto next_unit
        end

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, unit should be skipped if dead or not active (e.g. left map on a raid)

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these gotos could be replaced with breaks

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:')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this prints in this function are probably too spammy. the prints for when actions are actually taken is enough

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add a comment here explaining that repeat-util will cancel the scheduled callbacks for you

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