Skip to content

Commit fb76d7b

Browse files
committed
new tool: husbandry
1 parent 61e0e18 commit fb76d7b

File tree

3 files changed

+389
-0
lines changed

3 files changed

+389
-0
lines changed

changelog.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Template for new versions:
2828

2929
## New Tools
3030

31+
- `husbandry`: Automatically milk and shear animals at nearby farmer's workshops
32+
3133
## New Features
3234

3335
## Fixes

docs/husbandry.rst

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
husbandry
2+
=========
3+
4+
.. dfhack-tool::
5+
:summary: Automatically milk and shear animals.
6+
:tags: fort auto
7+
8+
This tool will automatically create milking and shearing orders at farmer's
9+
workshops. Unlike the ``automilk`` and ``autoshear`` options from the control
10+
panel, which create general work orders for milking and shearing jobs,
11+
``husbandry`` will directly create jobs for individual animals at specific
12+
workshops. This allows milking and shearing jobs to reliably be created at
13+
nearby workshops (e.g. inside the pasture that an animal is assigned to),
14+
minimizing the labor required to re-pasture animals after milking or shearing,
15+
in particular in the case of multiple pastures that are far apart.
16+
17+
18+
Usage
19+
-----
20+
21+
::
22+
23+
enable husbandry
24+
husbandry [status]
25+
husbandry now
26+
husbandry [set|unset] [shearing|milking|roaming|pasture]+
27+
28+
Flags can be set or unset using the command ``husbandry set`` or ``husbandry
29+
unset``. The ``shearing`` and ``milking`` flags (both enabled by default)
30+
control whether shearing or milking jobs are created at all.
31+
32+
Further, ``husbandry`` distinguishes between animals that are assigned to
33+
pastures and those that are "roaming".
34+
35+
If an animal is pastured and the pasture contains at least one workshop with the
36+
appropriate labour (i.e. milking or shearing) enabled, jobs will be created
37+
exclusively at those workshops. If the pasture does not contain a workshop with
38+
the appropriate labor enabled the behavior depends on the ``pasture`` flag
39+
(disabled by default): if set, no jobs will be created at workshops outside of
40+
pastures, otherwise jobs may be created at the closest workshop in your fort.
41+
42+
For animals that are roaming, jobs will only be created if the ``roaming`` flag
43+
is set, which is the default. In this case, jobs are created at the closest
44+
workshop with the appropriate labours enabled.
45+
46+
Examples
47+
--------
48+
49+
``enable husbandry``
50+
Start generating milking and shearing orders for animals.
51+
52+
``husbandry now``
53+
Run a single cycle, detecting animals that can be milked/sheared an creating
54+
jobs. Does not require the tool to be enabled.
55+
56+
``husbandry unset roaming``
57+
Disable the creation of jobs for roaming animals.
58+
59+
``husbandry set milking shearing pasture``
60+
Create milking and shearing jobs for pastured animals, but only at workshops
61+
inside their pastures.

husbandry.lua

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
2+
--@enable = true
3+
--@module = true
4+
5+
local utils = require 'utils'
6+
local repeatutil = require("repeat-util")
7+
local ic = reqscript('idle-crafting')
8+
9+
local verbose = true
10+
---conditional printing of debug messages
11+
---@param message string
12+
local function debug(message)
13+
if verbose then
14+
print(message)
15+
end
16+
end
17+
18+
-- From workorder.lua
19+
---------------------------8<-----------------------------
20+
21+
local function isValidAnimal(unit)
22+
-- this should also check for the absence of misc trait 55 (as of 50.09), but we don't
23+
-- currently have an enum definition for that value yet
24+
return dfhack.units.isOwnCiv(unit)
25+
and dfhack.units.isAlive(unit)
26+
and dfhack.units.isAdult(unit)
27+
and dfhack.units.isActive(unit)
28+
and dfhack.units.isFortControlled(unit)
29+
and dfhack.units.isTame(unit)
30+
and not dfhack.units.isMarkedForSlaughter(unit)
31+
and not dfhack.units.getMiscTrait(unit, df.misc_trait_type.Migrant, false)
32+
end
33+
34+
-- true/false or nil if no shearable_tissue_layer with length > 0.
35+
local function canShearCreature(unit)
36+
local stls = df.global.world.raws.creatures
37+
.all[unit.race]
38+
.caste[unit.caste]
39+
.shearable_tissue_layer
40+
41+
local any
42+
for _, stl in ipairs(stls) do
43+
if stl.length > 0 then
44+
for _, bpi in ipairs(stl.bp_modifiers_idx) do
45+
any = { unit.appearance.bp_modifiers[bpi], stl.length }
46+
if unit.appearance.bp_modifiers[bpi] >= stl.length then
47+
return true, any
48+
end
49+
end
50+
end
51+
end
52+
53+
if any then return false, any end
54+
-- otherwise: nil
55+
end
56+
57+
---------------------------8<-----------------------------
58+
59+
local function canMilkCreature(u)
60+
if dfhack.units.isMilkable(u) and not dfhack.units.isPet(u) then
61+
local mt_milk = dfhack.units.getMiscTrait(u, df.misc_trait_type.MilkCounter, false)
62+
if not mt_milk then return true else return false end
63+
else
64+
return nil
65+
end
66+
end
67+
68+
---@param p1 df.coord
69+
---@param p2 df.coord
70+
---@return number
71+
function distance(p1, p2)
72+
return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + 2 * math.abs(p1.z - p2.z)
73+
end
74+
75+
---find appropriate workshop to milk or shear an animal
76+
---@param unit df.unit
77+
---@param collection table<integer,df.building_workshopst>
78+
---@return df.building_workshopst?
79+
local function getAppropriateWorkshop(unit, collection)
80+
local zone_ref = dfhack.units.getGeneralRef(unit, df.general_ref_type.BUILDING_CIVZONE_ASSIGNED)
81+
local zone = zone_ref and zone_ref:getBuilding() or nil
82+
83+
-- if animal is assigned to a zone containing workshops, only use those
84+
if zone then
85+
local contains_workshop = false
86+
local best = nil
87+
local worst_load = 10
88+
for _, workshop in pairs(collection[zone.z] or {}) do
89+
if dfhack.buildings.containsTile(zone, workshop.centerx, workshop.centery) then
90+
contains_workshop = true
91+
local workshop_pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z)
92+
if dfhack.maps.canWalkBetween(unit.pos, workshop_pos) and #workshop.jobs < worst_load then
93+
worst_load = #workshop.jobs
94+
best = workshop
95+
end
96+
end
97+
end
98+
if contains_workshop or state.pasture then
99+
return best
100+
end
101+
elseif not state.roaming then
102+
return nil -- not treating roaming animals
103+
end
104+
-- otherwise, use the closest workshop to the animal
105+
local closest = nil
106+
local dist = nil
107+
for _, level in pairs(collection) do
108+
for _, workshop in pairs(level) do
109+
local workshop_pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z)
110+
if dfhack.maps.canWalkBetween(unit.pos, workshop_pos) then
111+
local d = distance(unit.pos, workshop_pos)
112+
if not closest or d < dist then
113+
closest = workshop
114+
dist = d
115+
end
116+
end
117+
end
118+
end
119+
return #closest.jobs < 10 and closest or nil
120+
end
121+
122+
local function shearCreature(unit, workshop)
123+
local job = ic.make_job()
124+
job.job_type = df.job_type.ShearCreature
125+
dfhack.job.addGeneralRef(job, df.general_ref_type.UNIT_SHEAREE, unit.id)
126+
ic.assignToWorkshop(job, workshop)
127+
end
128+
129+
local function milkCreature(unit, workshop)
130+
local job = ic.make_job()
131+
job.job_type = df.job_type.MilkCreature
132+
dfhack.job.addGeneralRef(job, df.general_ref_type.UNIT_MILKEE, unit.id)
133+
ic.assignToWorkshop(job, workshop)
134+
end
135+
136+
137+
-- configuration management
138+
139+
GLOBAL_KEY = 'husbandry'
140+
141+
local function get_default_state()
142+
return {
143+
enabled = false,
144+
milking = true,
145+
shearing = true,
146+
roaming = true;
147+
pasture = false
148+
}
149+
end
150+
151+
state = state or get_default_state()
152+
153+
function isEnabled()
154+
return state.enabled
155+
end
156+
157+
function persist_state()
158+
dfhack.persistent.saveSiteData(GLOBAL_KEY, {
159+
enabled=state.enabled,
160+
milking=state.milking,
161+
shearing=state.shearing,
162+
roaming=state.roaming,
163+
pasture=state.pasture,
164+
})
165+
end
166+
167+
--- Load the saved state of the script
168+
local function load_state()
169+
-- load persistent data
170+
local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, get_default_state())
171+
state.enabled = persisted_data.enabled
172+
state.milking = persisted_data.milking
173+
state.shearing = persisted_data.shearing
174+
state.roaming = persisted_data.roaming
175+
state.pasture = persisted_data.pasture
176+
return state
177+
end
178+
179+
-- main script action
180+
181+
local function action()
182+
debug('husbandry: running loop')
183+
184+
-- organize workshops by allowed labors and z-level
185+
---@type table<integer,df.building_workshopst[]>
186+
local farmer_shearing = {}
187+
---@type table<integer,df.building_workshopst[]>
188+
local farmer_milking = {}
189+
for _, workshop in ipairs(df.global.world.buildings.other.WORKSHOP_FARMER) do
190+
if not workshop.profile.blocked_labors[df.unit_labor.SHEARER] then
191+
table.insert(ensure_key(farmer_shearing, workshop.z), workshop)
192+
end
193+
if not workshop.profile.blocked_labors[df.unit_labor.MILK] then
194+
table.insert(ensure_key(farmer_milking, workshop.z), workshop)
195+
end
196+
end
197+
198+
-- gather units that are already being milked or sheared
199+
---@type table<integer,boolean>
200+
local unit_milking = {}
201+
---@type table<integer,boolean>
202+
local unit_shearing = {}
203+
204+
-- go over all workshops to to catch player-initiated jobs
205+
for _, workshop in ipairs(df.global.world.buildings.other.WORKSHOP_FARMER) do
206+
for _, job in ipairs(workshop.jobs) do
207+
if state.milking and job.job_type == df.job_type.MilkCreature then
208+
local milkee = dfhack.job.getGeneralRef(job, df.general_ref_type.UNIT_MILKEE)
209+
if milkee then
210+
unit_milking[milkee.unit_id] = true
211+
end
212+
elseif state.shearing and job.job_type == df.job_type.ShearCreature then
213+
local shearee = dfhack.job.getGeneralRef(job, df.general_ref_type.UNIT_SHEAREE)
214+
if shearee then
215+
unit_shearing[shearee.unit_id] = true
216+
end
217+
end
218+
end
219+
end
220+
221+
-- look for units that can be milked/sheared and for which there is no active job
222+
for _, unit in ipairs(df.global.world.units.active) do
223+
if not isValidAnimal(unit) then goto skip end
224+
225+
if state.shearing and canShearCreature(unit) and not unit_shearing[unit.id] then
226+
local workshop = getAppropriateWorkshop(unit, farmer_shearing)
227+
if workshop then
228+
shearCreature(unit, workshop)
229+
end
230+
end
231+
232+
if state.milking and canMilkCreature(unit) and not unit_milking[unit.id] then
233+
local workshop = getAppropriateWorkshop(unit, farmer_milking)
234+
if workshop then
235+
milkCreature(unit, workshop)
236+
end
237+
end
238+
239+
::skip::
240+
end
241+
end
242+
243+
-- enable management
244+
245+
local function start()
246+
if state.enabled then
247+
repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY, 1000, 'ticks', action)
248+
end
249+
end
250+
251+
local function stop()
252+
repeatutil.cancel(GLOBAL_KEY)
253+
end
254+
255+
dfhack.onStateChange[GLOBAL_KEY] = function(sc)
256+
if sc == SC_MAP_UNLOADED then
257+
state.enabled = false
258+
return
259+
end
260+
261+
if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then
262+
return
263+
end
264+
265+
load_state()
266+
start()
267+
end
268+
269+
if dfhack_flags.module then
270+
return
271+
end
272+
273+
if dfhack_flags.enable then
274+
if dfhack_flags.enable_state then
275+
enabled = true
276+
start()
277+
else
278+
enabled = false
279+
stop()
280+
end
281+
persist_state()
282+
return
283+
end
284+
285+
-- command-line interface
286+
287+
local argparse = require('argparse')
288+
local positionals = argparse.processArgsGetopt({ ... }, {})
289+
290+
local state_vars = utils.invert({ "milking", "shearing", "roaming", "pasture" })
291+
292+
local function setFlags(positionals, value)
293+
for i = 2, #positionals do
294+
local flag = positionals[i]
295+
if state_vars[flag] then
296+
debug(("setting %s = %s"):format(flag, value))
297+
state[flag] = value
298+
end
299+
end
300+
end
301+
302+
load_state()
303+
if not positionals[1] or positionals[1] == 'status' then
304+
print(("husbandry is %s"):format(state.enabled and "enabled" or "not enabled"))
305+
print(("currently %smilking%s%sshearing animals"):format(
306+
state.milking and "" or "not ",
307+
state.milking == state.shearing and " and " or " but ",
308+
state.shearing and "" or "not "))
309+
print(("%s roaming animals"):format(state.roaming and "including" or "ignoring"))
310+
if state.pasture then
311+
print("not milking/shearing animals inside pastures without workshops")
312+
end
313+
elseif positionals[1] == "set" then
314+
if positionals[2] == "default" then
315+
state = get_default_state()
316+
else
317+
setFlags(positionals, true)
318+
end
319+
elseif positionals[1] == "unset" then
320+
setFlags(positionals, false)
321+
elseif positionals[1] == "now" then
322+
action()
323+
else
324+
qerror("unrecognized option")
325+
end
326+
persist_state()

0 commit comments

Comments
 (0)