diff --git a/Readme.html b/Readme.html index e606d7137..ba89a442e 100644 --- a/Readme.html +++ b/Readme.html @@ -462,6 +462,7 @@ access DF memory and allow for easier development of new tools.
Allows the fortress bookkeeper to queue jobs through the manager.
+Usage:
++++
+- stockflow enable
+- Enable the plugin.
+- stockflow disable
+- Disable the plugin.
+- stockflow list
+- List any work order settings for your stockpiles.
+- stockflow status
+- Display whether the plugin is enabled.
+
Whenever the bookkeeper updates stockpile records, new work orders will +be placed on the manager's queue for each such selection, reduced by the +number of identical orders already in the queue.
+Manage control of repeat jobs.
diff --git a/Readme.rst b/Readme.rst index cd9101aab..dbef32f26 100644 --- a/Readme.rst +++ b/Readme.rst @@ -1317,6 +1317,29 @@ Duplicate the selected job in a workshop: * In 'q' mode, when a job is highlighted within a workshop or furnace building, instantly duplicates the job. +stockflow +--------- +Allows the fortress bookkeeper to queue jobs through the manager. + +Usage: + + ``stockflow enable`` + Enable the plugin. + ``stockflow disable`` + Disable the plugin. + ``stockflow list`` + List any work order settings for your stockpiles. + ``stockflow status`` + Display whether the plugin is enabled. + +While enabled, the 'q' menu of each stockpile will have two new options: + j: Select a job to order, from an interface like the manager's screen. + J: Cycle between several options for how many such jobs to order. + +Whenever the bookkeeper updates stockpile records, new work orders will +be placed on the manager's queue for each such selection, reduced by the +number of identical orders already in the queue. + workflow -------- Manage control of repeat jobs. diff --git a/dfhack.init-example b/dfhack.init-example index 22d2c2972..8525896d4 100644 --- a/dfhack.init-example +++ b/dfhack.init-example @@ -150,6 +150,9 @@ tweak military-training # enable autoSyndrome autoSyndrome enable +# allow the fortress bookkeeper to queue jobs through the manager +stockflow enable + ########### # Scripts # ########### diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 638fed3fd..29c39f1bb 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -112,6 +112,7 @@ if (BUILD_SUPPORTED) DFHACK_PLUGIN(rename rename.cpp LINK_LIBRARIES lua PROTOBUFS rename) DFHACK_PLUGIN(jobutils jobutils.cpp) DFHACK_PLUGIN(workflow workflow.cpp LINK_LIBRARIES lua) + DFHACK_PLUGIN(stockflow stockflow.cpp LINK_LIBRARIES lua) DFHACK_PLUGIN(showmood showmood.cpp) DFHACK_PLUGIN(fixveins fixveins.cpp) DFHACK_PLUGIN(fixpositions fixpositions.cpp) diff --git a/plugins/lua/stockflow.lua b/plugins/lua/stockflow.lua new file mode 100644 index 000000000..510298a06 --- /dev/null +++ b/plugins/lua/stockflow.lua @@ -0,0 +1,1099 @@ +local _ENV = mkmodule('plugins.stockflow') + +gui = require "gui" +utils = require "utils" + +reaction_list = {} +saved_orders = {} +jobs_to_create = {} + +triggers = { + {filled = false, divisor = 1, name = "Per empty space"}, + {filled = true, divisor = 1, name = "Per stored item"}, + {filled = false, divisor = 2, name = "Per two empty spaces"}, + {filled = true, divisor = 2, name = "Per two stored items"}, + {filled = false, divisor = 3, name = "Per three empty spaces"}, + {filled = true, divisor = 3, name = "Per three stored items"}, + {filled = false, divisor = 4, name = "Per four empty spaces"}, + {filled = true, divisor = 4, name = "Per four stored items"}, + {name = "Never"}, +} + +-- Populate the reaction and stockpile order lists. +-- To be called whenever a world is loaded. +function initialize_world() + reaction_list = collect_reactions() + saved_orders = collect_orders() + jobs_to_create = {} +end + +-- Clear all caches. +-- Called when a world is loaded, or when the plugin is disabled. +function clear_caches() + reaction_list = {} + saved_orders = {} + jobs_to_create = {} +end + +function trigger_name(cache) + local trigger = triggers[cache.entry.ints[3]] + return trigger and trigger.name or "Never" +end + +function list_orders() + local listed = false + for _, spec in pairs(saved_orders) do + local num = spec.stockpile.stockpile_number + local name = spec.entry.value + local trigger = trigger_name(spec) + print("Stockpile #"..num, name, trigger) + listed = true + end + + if not listed then + print("No manager jobs have been set for your stockpiles.") + print("Use j in a stockpile menu to create one...") + end +end + +-- Save the stockpile jobs for later creation. +-- Called when the bookkeeper starts updating stockpile records. +function start_bookkeeping() + result = {} + for reaction_id, quantity in pairs(check_stockpiles()) do + local amount = order_quantity(reaction_list[reaction_id].order, quantity) + if amount > 0 then + result[reaction_id] = amount + end + end + + jobs_to_create = result +end + +-- Insert any saved jobs. +-- Called when the bookkeeper finishes updating stockpile records. +function finish_bookkeeping() + for reaction, amount in pairs(jobs_to_create) do + create_orders(reaction_list[reaction].order, amount) + end + + jobs_to_create = {} +end + +function stockpile_settings(sp) + local order = saved_orders[sp.id] + if not order then + return "No job selected", "" + end + + return order.entry.value, trigger_name(order) +end + +-- Toggle the trigger condition for a stockpile. +function toggle_trigger(sp) + local saved = saved_orders[sp.id] + if saved then + saved.entry.ints[3] = (saved.entry.ints[3] % #triggers) + 1 + saved.entry:save() + end +end + +function collect_orders() + local result = {} + local entries = dfhack.persistent.get_all("stockflow/entry", true) + if entries then + local stockpiles = df.global.world.buildings.other.STOCKPILE + for _, entry in ipairs(entries) do + local spid = entry.ints[1] + local stockpile = utils.binsearch(stockpiles, spid, "id") + if stockpile then + -- Todo: What if entry.value ~= reaction_list[order_number].name? + result[spid] = { + stockpile = stockpile, + entry = entry, + } + end + end + end + + return result +end + +-- Choose an order that the stockpile should add to the manager queue. +function select_order(stockpile) + screen:reset(stockpile) + screen:show() +end + +function reaction_entry(job_type, values, name) + local order = df.manager_order:new() + -- These defaults differ from the newly created order's. + order:assign{ + job_type = job_type, + unk_2 = -1, + item_subtype = -1, + mat_type = -1, + mat_index = -1, + } + + if values then + -- Override default attributes. + order:assign(values) + end + + return { + name = name or df.job_type.attrs[job_type].caption, + order = order, + } +end + +function resource_reactions(reactions, job_type, mat_info, keys, items, options) + local values = {} + for key, value in pairs(mat_info.management) do + values[key] = value + end + + for _, itemid in ipairs(keys) do + local itemdef = items[itemid] + local start = options.verb or mat_info.verb or "Make" + if options.adjective then + start = start.." "..itemdef.adjective + end + + if (not options.permissible) or options.permissible(itemdef) then + local item_name = " "..itemdef[options.name_field or "name"] + if options.capitalize then + item_name = string.gsub(item_name, " .", string.upper) + end + + values.item_subtype = itemid + table.insert(reactions, reaction_entry(job_type, values, start.." "..mat_info.adjective..item_name)) + end + end +end + +function material_reactions(reactions, itemtypes, mat_info) + -- Expects a list of {job_type, verb, item_name} tuples. + for _, row in ipairs(itemtypes) do + local line = row[2].." "..mat_info.adjective + if row[3] then + line = line.." "..row[3] + end + + table.insert(reactions, reaction_entry(row[1], mat_info.management, line)) + end +end + +function clothing_reactions(reactions, mat_info, filter) + local resources = df.global.world.entities.all[df.global.ui.civ_id].resources + local itemdefs = df.global.world.raws.itemdefs + resource_reactions(reactions, 101, mat_info, resources.armor_type, itemdefs.armor, {permissible = filter}) + resource_reactions(reactions, 103, mat_info, resources.pants_type, itemdefs.pants, {permissible = filter}) + resource_reactions(reactions, 117, mat_info, resources.gloves_type, itemdefs.gloves, {permissible = filter}) + resource_reactions(reactions, 102, mat_info, resources.helm_type, itemdefs.helms, {permissible = filter}) + resource_reactions(reactions, 118, mat_info, resources.shoes_type, itemdefs.shoes, {permissible = filter}) +end + +-- Find the reaction types that should be listed in the management interface. +function collect_reactions() + -- The sequence here tries to match the native manager screen. + -- It should also be possible to collect the sequence from somewhere native, + -- but I currently can only find it while the job selection screen is active. + -- Even that list doesn't seem to include their names. + local result = {} + + local materials = { + rock = { + adjective = "rock", + management = {mat_type = 0}, + }, + } + + for _, name in ipairs{"wood", "cloth", "leather", "silk", "yarn", "bone", "shell", "tooth", "horn", "pearl"} do + materials[name] = { + adjective = name, + management = {material_category = {[name] = true}}, + } + end + + materials.wood.adjective = "wooden" + materials.tooth.adjective = "ivory/tooth" + materials.leather.clothing_flag = "LEATHER" + + local jobs = {12, 22, 219, 31, 32} + for _, job_id in ipairs(jobs) do + table.insert(result, reaction_entry(job_id)) + end + + -- Cutting, encrusting, and metal extraction. + local rock_types = df.global.world.raws.inorganics + for rock_id = #rock_types-1, 0, -1 do + local material = rock_types[rock_id].material + local rock_name = material.state_adj.Solid + if material.flags.IS_STONE or material.flags.IS_GEM then + table.insert(result, reaction_entry(85, {mat_type = 0, mat_index = rock_id}, "Cut "..rock_name)) + + table.insert(result, reaction_entry(87, { + mat_type = 0, + mat_index = rock_id, + item_category = {finished_goods = true}, + }, "Encrust Finished Goods With "..rock_name)) + + table.insert(result, reaction_entry(87, { + mat_type = 0, + mat_index = rock_id, + item_category = {furniture = true}, + }, "Encrust Furniture With "..rock_name)) + + table.insert(result, reaction_entry(87, { + mat_type = 0, + mat_index = rock_id, + item_category = {ammo = true}, + }, "Encrust Ammo With "..rock_name)) + end + + if #rock_types[rock_id].metal_ore.mat_index > 0 then + table.insert(result, reaction_entry(90, {mat_type = 0, mat_index = rock_id}, "Smelt "..rock_name.." Ore")) + end + + if #rock_types[rock_id].thread_metal.mat_index > 0 then + table.insert(result, reaction_entry(92, {mat_type = 0, mat_index = rock_id})) + end + end + + -- Glass cutting and encrusting, with different job numbers. + -- We could search the entire table, but glass is less subject to raws. + local glass_types = df.global.world.raws.mat_table.builtin + local glasses = {} + for glass_id = 3, 5 do + local material = glass_types[glass_id] + local glass_name = material.state_adj.Solid + if material.flags.IS_GLASS then + -- For future use. + table.insert(glasses, { + adjective = glass_name, + management = {mat_type = glass_id}, + }) + + table.insert(result, reaction_entry(86, {mat_type = glass_id}, "Cut "..glass_name)) + + table.insert(result, reaction_entry(88, { + mat_type = glass_id, + item_category = {finished_goods = true}, + }, "Encrust Finished Goods With "..glass_name)) + + table.insert(result, reaction_entry(88, { + mat_type = glass_id, + item_category = {furniture = true}, + }, "Encrust Furniture With "..glass_name)) + + table.insert(result, reaction_entry(88, { + mat_type = glass_id, + item_category = {ammo = true}, + }, "Encrust Ammo With "..glass_name)) + end + end + + -- Dyeing + table.insert(result, reaction_entry(192)) + table.insert(result, reaction_entry(193)) + + -- Sew Image + cloth_mats = {materials.cloth, materials.silk, materials.yarn, materials.leather} + for _, material in ipairs(cloth_mats) do + material_reactions(result, {{194, "Sew", "Image"}}, material) + end + + for _, spec in ipairs{materials.bone, materials.shell, materials.tooth, materials.horn, materials.pearl} do + material_reactions(result, {{132, "Decorate With"}}, spec) + end + + -- Make Totem + table.insert(result, reaction_entry(130)) + -- Butcher an Animal + table.insert(result, reaction_entry(105)) + -- Mill Plants + table.insert(result, reaction_entry(107)) + -- Make Potash From Lye + table.insert(result, reaction_entry(189)) + -- Make Potash From Ash + table.insert(result, reaction_entry(191)) + + -- Kitchen + table.insert(result, reaction_entry(115, {mat_type = 2}, "Prepare Easy Meal")) + table.insert(result, reaction_entry(115, {mat_type = 3}, "Prepare Fine Meal")) + table.insert(result, reaction_entry(115, {mat_type = 4}, "Prepare Lavish Meal")) + + -- Brew Drink + table.insert(result, reaction_entry(150)) + + -- Weaving + table.insert(result, reaction_entry(116, {material_category = {plant = true}}, "Weave Thread into Cloth")) + table.insert(result, reaction_entry(116, {material_category = {silk = true}}, "Weave Thread into Silk")) + table.insert(result, reaction_entry(116, {material_category = {yarn = true}}, "Weave Yarn into Cloth")) + + -- Extracts, farmer's workshop, and wood burning + local jobs = {151, 152, 153, 106, 110, 109, 214, 215, 188, 111, 112, 113, 114, 186, 187} + for _, job_id in ipairs(jobs) do + table.insert(result, reaction_entry(job_id)) + end + + -- Reactions defined in the raws. + -- Not all reactions are allowed to the civilization. + -- That includes "Make sharp rock" by default. + local entity = df.global.world.entities.all[df.global.ui.civ_id] + for _, reaction_id in ipairs(entity.entity_raw.workshops.permitted_reaction_id) do + local reaction = df.global.world.raws.reactions[reaction_id] + local name = string.gsub(reaction.name, "^.", string.upper) + table.insert(result, reaction_entry(211, {reaction_name = reaction.code}, name)) + end + + -- Metal forging + local itemdefs = df.global.world.raws.itemdefs + for rock_id = 0, #rock_types - 1 do + local material = rock_types[rock_id].material + local rock_name = material.state_adj.Solid + local mat_flags = { + adjective = rock_name, + management = {mat_type = 0, mat_index = rock_id}, + verb = "Forge", + } + + if material.flags.IS_METAL then + table.insert(result, reaction_entry(104, mat_flags.management, "Stud With "..rock_name)) + + if material.flags.ITEMS_WEAPON then + -- Todo: Are these really the right flags to check? + resource_reactions(result, 97, mat_flags, entity.resources.weapon_type, itemdefs.weapons, { + permissible = (function(itemdef) return itemdef.skill_ranged == -1 end), + }) + + -- Is this entirely disconnected from the entity? + material_reactions(result, {{135, "Forge", "Ballista Arrow Head"}}, mat_flags) + + resource_reactions(result, 142, mat_flags, entity.resources.trapcomp_type, itemdefs.trapcomps, { + adjective = true, + }) + + resource_reactions(result, 136, mat_flags, entity.resources.siegeammo_type, itemdefs.siege_ammo, { + verb = "Assemble", + }) + end + + if material.flags.ITEMS_WEAPON_RANGED then + resource_reactions(result, 97, mat_flags, entity.resources.weapon_type, itemdefs.weapons, { + permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end), + }) + end + + if material.flags.ITEMS_DIGGER then + -- Todo: Ranged or training digging weapons? + resource_reactions(result, 97, mat_flags, entity.resources.digger_type, itemdefs.weapons, { + }) + end + + if material.flags.ITEMS_AMMO then + resource_reactions(result, 131, mat_flags, entity.resources.ammo_type, itemdefs.ammo, { + name_field = "name_plural", + }) + end + + if material.flags.ITEMS_ANVIL then + material_reactions(result, {{98, "Forge", "Anvil"}}, mat_flags) + end + + if material.flags.ITEMS_ARMOR then + local metalclothing = (function(itemdef) return itemdef.props.flags.METAL end) + clothing_reactions(result, mat_flags, metalclothing) + resource_reactions(result, 119, mat_flags, entity.resources.shield_type, itemdefs.shields, { + }) + end + + if material.flags.ITEMS_SOFT then + local metalclothing = (function(itemdef) return itemdef.props.flags.SOFT and not itemdef.props.flags.METAL end) + clothing_reactions(result, mat_flags, metalclothing) + end + + if material.flags.ITEMS_HARD then + resource_reactions(result, 218, mat_flags, entity.resources.tool_type, itemdefs.tools, { + permissible = (function(itemdef) return itemdef.flags.HARD_MAT end), + capitalize = true, + }) + end + + if material.flags.ITEMS_METAL then + resource_reactions(result, 218, mat_flags, entity.resources.tool_type, itemdefs.tools, { + permissible = (function(itemdef) return itemdef.flags.METAL_MAT end), + capitalize = true, + }) + end + + if material.flags.ITEMS_HARD then + material_reactions(result, { + {69, "Construct", "Door"}, + {70, "Construct", "Floodgate"}, + {200, "Construct", "Hatch Cover"}, + {201, "Construct", "Grate"}, + {72, "Construct", "Throne"}, + {73, "Construct", "Sarcophagus"}, + {74, "Construct", "Table"}, + {205, "Construct", "Splint"}, + {206, "Construct", "Crutch"}, + {77, "Construct", "Armor Stand"}, + {78, "Construct", "Weapon Rack"}, + {79, "Construct", "Cabinet"}, + {123, "Forge", "Goblet"}, + {124, "Forge", "Instrument"}, + {125, "Forge", "Toy"}, + {80, "Construct", "Statue"}, + {81, "Construct", "Blocks"}, + {126, "Forge", "Animal Trap"}, + {127, "Forge", "Barrel"}, + {128, "Forge", "Bucket"}, + {76, "Construct", "Bin"}, + {195, "Forge", "Pipe Section"}, + {120, "Forge", "Cage"}, + {84, "Mint", "Coins"}, + {75, "Construct", "Chest"}, + {122, "Forge", "Flask"}, + {121, "Forge", "Chain"}, + {83, "Make", "Crafts"}, + }, mat_flags) + end + + if material.flags.ITEMS_SOFT then + material_reactions(result, { + {133, "Make", "Backpack"}, + {134, "Make", "Quiver"}, + {99, "Construct", "Catapult Parts"}, + {100, "Construct", "Ballista Parts"}, + }, mat_flags) + end + end + end + + -- Traction Bench + table.insert(result, reaction_entry(207)) + + -- Non-metal weapons + resource_reactions(result, 97, materials.wood, entity.resources.weapon_type, itemdefs.weapons, { + permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end), + }) + + resource_reactions(result, 97, materials.wood, entity.resources.training_weapon_type, itemdefs.weapons, { + }) + + resource_reactions(result, 97, materials.bone, entity.resources.weapon_type, itemdefs.weapons, { + permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end), + }) + + resource_reactions(result, 97, materials.rock, entity.resources.weapon_type, itemdefs.weapons, { + permissible = (function(itemdef) return itemdef.flags.CAN_STONE end), + }) + + -- Wooden items + -- Closely related to the ITEMS_HARD list. + material_reactions(result, { + {69, "Construct", "Door"}, + {70, "Construct", "Floodgate"}, + {200, "Construct", "Hatch Cover"}, + {201, "Construct", "Grate"}, + {72, "Construct", "Chair"}, + {73, "Construct", "Casket"}, + {74, "Construct", "Table"}, + {77, "Construct", "Armor Stand"}, + {78, "Construct", "Weapon Rack"}, + {79, "Construct", "Cabinet"}, + {123, "Make", "Cup"}, + {124, "Make", "Instrument"}, + }, materials.wood) + + resource_reactions(result, 218, materials.wood, entity.resources.tool_type, itemdefs.tools, { + -- permissible = (function(itemdef) return itemdef.flags.WOOD_MAT end), + capitalize = true, + }) + + material_reactions(result, { + {125, "Make", "Toy"}, + {81, "Construct", "Blocks"}, + {205, "Construct", "Splint"}, + {206, "Construct", "Crutch"}, + {126, "Make", "Animal Trap"}, + {127, "Make", "Barrel"}, + {128, "Make", "Bucket"}, + {76, "Construct", "Bin"}, + {120, "Make", "Cage"}, + {195, "Make", "Pipe Section"}, + }, materials.wood) + + resource_reactions(result, 142, materials.wood, entity.resources.trapcomp_type, itemdefs.trapcomps, { + permissible = (function(itemdef) return itemdef.flags.WOOD end), + adjective = true, + }) + + -- Rock items + material_reactions(result, { + {69, "Construct", "Door"}, + {70, "Construct", "Floodgate"}, + {200, "Construct", "Hatch Cover"}, + {201, "Construct", "Grate"}, + {72, "Construct", "Throne"}, + {73, "Construct", "Coffin"}, + {74, "Construct", "Table"}, + {77, "Construct", "Armor Stand"}, + {78, "Construct", "Weapon Rack"}, + {79, "Construct", "Cabinet"}, + {123, "Make", "Mug"}, + {124, "Make", "Instrument"}, + }, materials.rock) + + resource_reactions(result, 218, materials.rock, entity.resources.tool_type, itemdefs.tools, { + permissible = (function(itemdef) return itemdef.flags.HARD_MAT end), + capitalize = true, + }) + + material_reactions(result, { + {125, "Make", "Toy"}, + {203, "Construct", "Quern"}, + {204, "Construct", "Millstone"}, + {212, "Construct", "Slab"}, + {80, "Construct", "Statue"}, + {81, "Construct", "Blocks"}, + }, materials.rock) + + -- Glass items + for _, mat_info in ipairs(glasses) do + material_reactions(result, { + {69, "Construct", "Portal"}, + {70, "Construct", "Floodgate"}, + {200, "Construct", "Hatch Cover"}, + {201, "Construct", "Grate"}, + {72, "Construct", "Throne"}, + {73, "Construct", "Coffin"}, + {74, "Construct", "Table"}, + {77, "Construct", "Armor Stand"}, + {78, "Construct", "Weapon Rack"}, + {79, "Construct", "Cabinet"}, + {123, "Make", "Goblet"}, + {124, "Make", "Instrument"}, + }, mat_info) + + resource_reactions(result, 218, mat_info, entity.resources.tool_type, itemdefs.tools, { + permissible = (function(itemdef) return itemdef.flags.HARD_MAT end), + capitalize = true, + }) + + material_reactions(result, { + {125, "Make", "Toy"}, + {80, "Construct", "Statue"}, + {81, "Construct", "Blocks"}, + {120, "Make", "Terrarium"}, + {195, "Make", "Tube"}, + }, mat_info) + + resource_reactions(result, 142, mat_info, entity.resources.trapcomp_type, itemdefs.trapcomps, { + adjective = true, + }) + end + + -- Bed, specified as wooden. + table.insert(result, reaction_entry(71, materials.wood.management)) + + -- Windows + for _, mat_info in ipairs(glasses) do + material_reactions(result, { + {129, "Make", "Window"}, + }, mat_info) + end + + -- Rock Mechanisms + table.insert(result, reaction_entry(141, materials.rock.management)) + + resource_reactions(result, 136, materials.wood, entity.resources.siegeammo_type, itemdefs.siege_ammo, { + verb = "Assemble", + }) + + for _, mat_info in ipairs(glasses) do + material_reactions(result, { + {82, "Make Raw", nil}, + }, mat_info) + end + + material_reactions(result, { + {133, "Make", "Backpack"}, + {134, "Make", "Quiver"}, + }, materials.leather) + + for _, material in ipairs(cloth_mats) do + clothing_reactions(result, material, (function(itemdef) return itemdef.props.flags[material.clothing_flag or "SOFT"] end)) + end + + -- Boxes, Bags, and Ropes + boxmats = { + {mats = {materials.wood}, box = "Chest"}, + {mats = {materials.rock}, box = "Coffer"}, + {mats = glasses, box = "Box", flask = "Vial"}, + {mats = {materials.cloth}, box = "Bag", chain = "Rope"}, + {mats = {materials.leather}, box = "Bag", flask = "Waterskin"}, + {mats = {materials.silk, materials.yarn}, box = "Bag", chain = "Rope"}, + } + for _, boxmat in ipairs(boxmats) do + for _, mat in ipairs(boxmat.mats) do + material_reactions(result, {{75, "Construct", boxmat.box}}, mat) + if boxmat.chain then + material_reactions(result, {{121, "Make", boxmat.chain}}, mat) + end + if boxmat.flask then + material_reactions(result, {{122, "Make", boxmat.flask}}, mat) + end + end + end + + for _, mat in ipairs{ + materials.wood, + materials.rock, + materials.cloth, + materials.leather, + materials.shell, + materials.bone, + materials.silk, + materials.tooth, + materials.horn, + materials.pearl, + materials.yarn, + } do + material_reactions(result, {{83, "Make", "Crafts"}}, mat) + end + + -- Siege engine parts + table.insert(result, reaction_entry(99, materials.wood.management)) + table.insert(result, reaction_entry(100, materials.wood.management)) + + for _, mat in ipairs{materials.wood, materials.bone} do + resource_reactions(result, 131, mat, entity.resources.ammo_type, itemdefs.ammo, { + name_field = "name_plural", + }) + end + + -- BARRED and SCALED as flag names don't quite seem to fit, here. + clothing_reactions(result, materials.bone, (function(itemdef) return itemdef.props.flags[3] end)) + clothing_reactions(result, materials.shell, (function(itemdef) return itemdef.props.flags[4] end)) + + for _, mat in ipairs{materials.wood, materials.leather} do + resource_reactions(result, 119, mat, entity.resources.shield_type, itemdefs.shields, {}) + end + + -- Melt a Metal Object + table.insert(result, reaction_entry(91)) + + return result +end + +screen = gui.FramedScreen { + frame_title = "Select Stockpile Order", +} + +PageSize = 16 +function screen:onRenderBody(dc) + -- Emulates the built-in manager screen. + dc:seek(1, 1):string("Type in parts of the name to narrow your search. ", COLOR_WHITE) + dc:string(gui.getKeyDisplay("LEAVESCREEN"), COLOR_LIGHTGREEN) + dc:string(" to abort.", COLOR_WHITE) + dc:seek(1, PageSize + 5):string(self.search_string, COLOR_LIGHTCYAN) + for _, item in ipairs(self.displayed) do + dc:seek(item.x, item.y):string(item.name, item.color) + end +end + +function screen:onInput(keys) + if keys.LEAVESCREEN then + self:dismiss() + elseif keys.SELECT then + self:dismiss() + local selected = self.reactions[self.position].index + store_order(self.stockpile, selected) + elseif keys.STANDARDSCROLL_UP then + self.position = self.position - 1 + elseif keys.STANDARDSCROLL_DOWN then + self.position = self.position + 1 + elseif keys.STANDARDSCROLL_LEFT then + self.position = self.position - PageSize + elseif keys.STANDARDSCROLL_RIGHT then + self.position = self.position + PageSize + elseif keys.STANDARDSCROLL_PAGEUP then + -- Moves to the first item displayed on the new page, for some reason. + self.position = self.position - PageSize*2 - ((self.position-1) % (PageSize*2)) + elseif keys.STANDARDSCROLL_PAGEDOWN then + -- Moves to the first item displayed on the new page, for some reason. + self.position = self.position + PageSize*2 - ((self.position-1) % (PageSize*2)) + elseif keys.STRING_A000 then + -- This seems like an odd way to check for Backspace. + self.search_string = string.sub(self.search_string, 1, -2) + elseif keys._STRING and keys._STRING >= 32 then + -- This interface only accepts letters and spaces. + local char = string.char(keys._STRING) + if char == " " or string.find(char, "^%a") then + self.search_string = self.search_string .. string.upper(char) + end + end + + self:refilter() +end + +function screen:reset(stockpile) + self.stockpile = stockpile + self.search_string = "" + self.position = 1 + self:refilter() +end + +function matchall(haystack, needles) + for _, needle in ipairs(needles) do + if not string.find(haystack, needle) then + return false + end + end + + return true +end + +function splitstring(full, pattern) + local last = string.len(full) + local result = {} + local n = 1 + while n <= last do + local start, stop = string.find(full, pattern, n) + if not start then + result[#result+1] = string.sub(full, n) + break + elseif start > n then + result[#result+1] = string.sub(full, n, start - 1) + end + + if stop < n then + -- The pattern matches an empty string. + -- Avoid an infinite loop. + break + end + + n = stop + 1 + end + + return result +end + +function screen:refilter() + local filtered = {} + local needles = splitstring(self.search_string, " ") + for key, value in ipairs(reaction_list) do + if matchall(string.upper(value.name), needles) then + filtered[#filtered+1] = { + index = key, + name = value.name + } + end + end + + if self.position < 1 then + self.position = #filtered + elseif self.position > #filtered then + self.position = 1 + end + + local start = 1 + while self.position >= start + PageSize*2 do + start = start + PageSize*2 + end + + local displayed = {} + for n = 0, PageSize*2 - 1 do + local item = filtered[start + n] + if not item then + break + end + + local x = 1 + local y = 4 + n + if n >= PageSize then + x = 39 + y = y - PageSize + end + + local color = COLOR_CYAN + if start + n == self.position then + color = COLOR_LIGHTCYAN + end + + displayed[n + 1] = { + x = x, + y = y, + name = item.name, + color = color, + } + end + + self.reactions = filtered + self.displayed = displayed +end + +function store_order(stockpile, order_number) + local name = reaction_list[order_number].name + -- print("Setting stockpile #"..stockpile.stockpile_number.." to "..name.." (#"..order_number..")") + local saved = saved_orders[stockpile.id] + if saved then + saved.entry.value = name + saved.entry.ints[2] = order_number + saved.entry:save() + else + saved_orders[stockpile.id] = { + stockpile = stockpile, + entry = dfhack.persistent.save{ + key = "stockflow/entry/"..stockpile.id, + value = name, + ints = { + stockpile.id, + order_number, + 1, + }, + }, + } + end +end + +-- Compare the job specification of two orders. +function orders_match(a, b) + local fields = { + "job_type", + "item_subtype", + "reaction_name", + "mat_type", + "mat_index", + } + + for _, fieldname in ipairs(fields) do + if a[fieldname] ~= b[fieldname] then + return false + end + end + + local subtables = { + "item_category", + "material_category", + } + + for _, fieldname in ipairs(subtables) do + local aa = a[fieldname] + local bb = b[fieldname] + for key, value in ipairs(aa) do + if bb[key] ~= value then + return false + end + end + end + + return true +end + +-- Reduce the quantity by the number of matching orders in the queue. +function order_quantity(order, quantity) + local amount = quantity + for _, managed in ipairs(df.global.world.manager_orders) do + if orders_match(order, managed) then + amount = amount - managed.amount_left + if amount < 0 then + return 0 + end + end + end + + if amount > 30 then + -- Respect the quantity limit. + -- With this many in the queue, we can wait for the next cycle. + return 30 + end + + return amount +end + +-- Place a new copy of the order onto the manager's queue. +function create_orders(order, amount) + local new_order = order:new() + new_order.amount_left = amount + new_order.amount_total = amount + -- Todo: Create in a validated state if the fortress is small enough? + new_order.is_validated = 0 + df.global.world.manager_orders:insert('#', new_order) +end + +function findItemsAtTile(x, y, z) + -- There should be a faster and easier way to do this... + local found = {} + for _, item in ipairs(df.global.world.items.all) do + -- local ix, iy, iz = dfhack.items.getPosition(item) + if item.pos.x == x and item.pos.y == y and + item.pos.z == z and item.flags.on_ground then + found[#found+1] = item + end + end + + return found +end + +function countContents(container, settings) + local total = 0 + local blocking = false + for _, item in ipairs(dfhack.items.getContainedItems(container)) do + if item.flags.container then + -- Recursively count the total of items contained. + -- Not likely to go more than two levels deep. + local subtotal, subblocked = countContents(item, settings) + if subtotal > 0 then + -- Ignore the inner container itself; + -- generally, only the contained items matter. + total = total + subtotal + elseif subblocked then + blocking = true + elseif matches_stockpile(item, settings) then + -- The container may or may not be empty, + -- but is stockpiled as a container itself. + total = total + 1 + else + blocking = true + end + elseif matches_stockpile(item, settings) then + total = total + 1 + else + blocking = true + end + end + + return total, blocking +end + +function check_stockpiles(verbose) + local result = {} + for _, spec in pairs(saved_orders) do + local trigger = triggers[spec.entry.ints[3]] + if trigger and trigger.divisor then + local reaction = spec.entry.ints[2] + local filled, empty = check_pile(spec.stockpile, verbose) + local amount = trigger.filled and filled or empty + amount = (amount - (amount % trigger.divisor)) / trigger.divisor + result[reaction] = (result[reaction] or 0) + amount + end + end + + return result +end + +function check_pile(sp, verbose) + local numspaces = 0 + local filled = 0 + local empty = 0 + for y = sp.y1, sp.y2 do + for x = sp.x1, sp.x2 do + -- Sadly, the obvious check currently fails when y == sp.y2 + if dfhack.buildings.containsTile(sp, x, y) or + (y == sp.y2 and dfhack.buildings.findAtTile(x, y, sp.z) == sp) then + numspaces = numspaces + 1 + local designation, occupancy = dfhack.maps.getTileFlags(x, y, sp.z) + if not designation.liquid_type then + if not occupancy.item then + empty = empty + 1 + else + local item_count, blocked = count_pile_items(sp, x, y) + if item_count > 0 then + filled = filled + item_count + elseif not blocked then + empty = empty + 1 + end + end + end + end + end + end + + if verbose then + print("Stockpile #"..sp.stockpile_number, + string.format("%3d spaces", numspaces), + string.format("%4d items", filled), + string.format("%4d empty spaces", empty)) + end + + return filled, empty +end + +function count_pile_items(sp, x, y) + local item_count = 0 + local blocked = false + for _, item in ipairs(findItemsAtTile(x, y, sp.z)) do + if item:isAssignedToThisStockpile(sp.id) then + -- This is a bin or barrel associated with the stockpile. + -- If it's empty, it doesn't count as blocking the stockpile space. + -- Oddly, when empty, item.flags.container might be false. + local subtotal, subblocked = countContents(item, sp.settings) + item_count = item_count + subtotal + if subblocked then + blocked = true + end + elseif matches_stockpile(item, sp.settings) then + item_count = item_count + 1 + else + blocked = true + end + end + + return item_count, blocked +end + +function matches_stockpile(item, settings) + -- Check whether the item matches the stockpile. + -- FIXME: This is starting to look like a whole lot of work. + if df.item_barst:is_instance(item) then + return settings.flags.bars_blocks + elseif df.item_blocksst:is_instance(item) then + return settings.flags.bars_blocks + elseif df.item_smallgemst:is_instance(item) then + return settings.flags.gems + elseif df.item_boulderst:is_instance(item) then + return settings.flags.stone + elseif df.item_woodst:is_instance(item) then + return settings.flags.wood + elseif df.item_seedsst:is_instance(item) then + return settings.flags.food + elseif df.item_meatst:is_instance(item) then + return settings.flags.food + elseif df.item_plantst:is_instance(item) then + return settings.flags.food + elseif df.item_leavesst:is_instance(item) then + return settings.flags.food + elseif df.item_cheesest:is_instance(item) then + return settings.flags.food + elseif df.item_globst:is_instance(item) then + return settings.flags.food + elseif df.item_fishst:is_instance(item) then + return settings.flags.food + elseif df.item_fish_rawst:is_instance(item) then + return settings.flags.food + elseif df.item_foodst:is_instance(item) then + return settings.flags.food + elseif df.item_drinkst:is_instance(item) then + return settings.flags.food + elseif df.item_eggst:is_instance(item) then + return settings.flags.food + elseif df.item_skin_tannedst:is_instance(item) then + return settings.flags.leather + elseif df.item_remainsst:is_instance(item) then + return settings.flags.refuse + elseif df.item_verminst:is_instance(item) then + return settings.flags.animals + elseif df.item_petst:is_instance(item) then + return settings.flags.animals + elseif df.item_threadst:is_instance(item) then + return settings.flags.cloth + end + + return true +end + +return _ENV diff --git a/plugins/stockflow.cpp b/plugins/stockflow.cpp new file mode 100644 index 000000000..26bc3f9e8 --- /dev/null +++ b/plugins/stockflow.cpp @@ -0,0 +1,374 @@ +/* + * Stockflow plugin. + * For best effect, place "stockflow enable" in your dfhack.init configuration. + */ + +#include "uicommon.h" +#include "LuaTools.h" + +#include "df/building_stockpilest.h" +#include "df/job.h" +#include "df/viewscreen_dwarfmodest.h" + +#include "modules/Gui.h" +#include "modules/Maps.h" +#include "modules/World.h" + +using namespace DFHack; +using namespace std; + +using df::global::world; +using df::global::ui; +using df::building_stockpilest; + +DFHACK_PLUGIN("stockflow"); + +bool enabled = false; +const char *tagline = "Allows the fortress bookkeeper to queue jobs through the manager."; +const char *usage = ( + " stockflow enable\n" + " Enable the plugin.\n" + " stockflow disable\n" + " Disable the plugin.\n" + " stockflow list\n" + " List any work order settings for your stockpiles.\n" + " stockflow status\n" + " Display whether the plugin is enabled.\n" + "\n" + "While enabled, the 'q' menu of each stockpile will have two new options:\n" + " j: Select a job to order, from an interface like the manager's screen.\n" + " J: Cycle between several options for how many such jobs to order.\n" + "\n" + "Whenever the bookkeeper updates stockpile records, new work orders will\n" + "be placed on the manager's queue for each such selection, reduced by the\n" + "number of identical orders already in the queue.\n" +); + +/* + * Stockpile Access + */ +static building_stockpilest *get_selected_stockpile() { + if (!Gui::dwarfmode_hotkey(Core::getTopViewscreen()) || + ui->main.mode != ui_sidebar_mode::QueryBuilding) + { + return nullptr; + } + + return virtual_cast