Skip to content

[immortal-cravings] prioritize high-value meals #1475

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -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, properly split of portions, and don't go eating or drinking on a full stomach

## Misc Improvements

Expand Down
124 changes: 95 additions & 29 deletions immortal-cravings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,26 +23,40 @@ 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

---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?
---@return T?
function findBest(item_vector, metric, smallest)
local best = nil
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
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 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
Expand All @@ -41,24 +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 some prepared meal
---find available meal with highest per-portion value
---@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
return false
local function portion_value(meal)
local accessible = dfhack.maps.canWalkBetween(pos,xyz2pos(dfhack.items.getPosition(meal)))
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
return findClosest(pos, df.global.world.items.other.FOOD, is_good)

return findBest(df.global.world.items.other.FOOD, portion_value)
end

---create a Drink job for the given unit
Expand Down Expand Up @@ -86,11 +118,22 @@ 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
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
Expand All @@ -105,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'
Expand Down Expand Up @@ -137,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
Expand All @@ -148,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
Expand All @@ -166,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
Expand All @@ -178,18 +241,21 @@ 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
if not is_active_caste_flag(unit, 'NO_DRINK') and not is_active_caste_flag(unit, 'NO_EAT') then
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
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)))
debug(' '..dfhack.df2console(dfhack.units.getReadableName(unit)))
goto next_unit
end
end
Expand Down
Loading