Quite a while ago I wrote some scripts that allow modders to remove or add buildings to the build menu tabs. The only problem with these scripts was that they leaned fairly heavily on the Rubble script loader, certain thing it did were vastly simplified due to guarantees the script loader made.
Now that I have all the bugs worked out of the scripts I feel it is time to share a "deRubbleized" version with the world. The non-Rubble version I am presenting here is a DFHack command script with support for "enable" and "dfhack.reqscript". Not only can you change the build menu from Lua scripts (via "dfhack.reqscript"), but the basic functionality is also available from the DFHack command line or "raw/dfhack.init".
The usage statement is nice and detailed, giving documentation for both interfaces. Also the code has lots of comments, so feel free to read that too
Anyway, here it is, "change-build-menu.lua":
-- Edit the build mode sidebar menus.
--
-- Based on my script for the Rubble addon "Libs/Change Build List".
--[[
Copyright 2016 Milo Christiansen
This software is provided 'as-is', without any express or implied warranty. In
no event will the authors be held liable for any damages arising from the use of
this software.
Permission is granted to anyone to use this software for any purpose, including
commercial applications, and to alter it and redistribute it freely, subject to
the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim
that you wrote the original software. If you use this software in a product, an
acknowledgment in the product documentation would be appreciated but is not
required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
]]
--@ module = true
--@ enable = true
-- From here until I say otherwise the APIs are generic building manipulation functions from "Libs/Buildings"
local utils = require "utils"
local wshop_type_to_id = {
[df.workshop_type.Carpenters] = "CARPENTERS",
[df.workshop_type.Farmers] = "FARMERS",
[df.workshop_type.Masons] = "MASONS",
[df.workshop_type.Craftsdwarfs] = "CRAFTSDWARFS",
[df.workshop_type.Jewelers] = "JEWELERS",
[df.workshop_type.MetalsmithsForge] = "METALSMITHSFORGE",
[df.workshop_type.MagmaForge] = "MAGMAFORGE",
[df.workshop_type.Bowyers] = "BOWYERS",
[df.workshop_type.Mechanics] = "MECHANICS",
[df.workshop_type.Siege] = "SIEGE",
[df.workshop_type.Butchers] = "BUTCHERS",
[df.workshop_type.Leatherworks] = "LEATHERWORKS",
[df.workshop_type.Tanners] = "TANNERS",
[df.workshop_type.Clothiers] = "CLOTHIERS",
[df.workshop_type.Fishery] = "FISHERY",
[df.workshop_type.Still] = "STILL",
[df.workshop_type.Loom] = "LOOM",
[df.workshop_type.Quern] = "QUERN",
[df.workshop_type.Kennels] = "KENNELS",
[df.workshop_type.Ashery] = "ASHERY",
[df.workshop_type.Kitchen] = "KITCHEN",
[df.workshop_type.Dyers] = "DYERS",
[df.workshop_type.Tool] = "TOOL",
[df.workshop_type.Millstone] = "MILLSTONE",
}
local wshop_id_to_type = utils.invert(wshop_type_to_id)
local furnace_type_to_id = {
[df.furnace_type.WoodFurnace] = "WOOD_FURNACE",
[df.furnace_type.Smelter] = "SMELTER",
[df.furnace_type.GlassFurnace] = "GLASS_FURNACE",
[df.furnace_type.MagmaSmelter] = "MAGMA_SMELTER",
[df.furnace_type.MagmaGlassFurnace] = "MAGMA_GLASS_FURNACE",
[df.furnace_type.MagmaKiln] = "MAGMA_KILN",
[df.furnace_type.Kiln] = "KILN",
}
local furnace_id_to_type = utils.invert(furnace_type_to_id)
-- GetWShopID returns a workshop or furnace's string ID based on it's numeric ID triplet.
-- This string ID *should* match what is expected by eventful for hardcoded buildings.
function GetWShopID(btype, bsubtype, bcustom)
if btype == df.building_type.Workshop then
if wshop_type_to_id[bsubtype] ~= nil then
return wshop_type_to_id[bsubtype]
else
return df.building_def_workshopst.find(bcustom).code
end
elseif btype == df.building_type.Furnace then
if furnace_type_to_id[bsubtype] ~= nil then
return furnace_type_to_id[bsubtype]
else
return df.building_def_furnacest.find(bcustom).code
end
end
end
-- GetWShopIDs returns a workshop or furnace's ID numbers as a table.
-- The passed in ID should be the building's string identifier, it makes
-- no difference if it is a custom building or a hardcoded one.
-- The return table is structured like so: `{type, subtype, custom}`
function GetWShopType(id)
if wshop_id_to_type[id] ~= nil then
-- Hardcoded workshop
return {
type = df.building_type.Workshop,
subtype = wshop_id_to_type[id],
custom = -1,
}
elseif furnace_id_to_type[id] ~= nil then
-- Hardcoded furnace
return {
type = df.building_type.Furnace,
subtype = furnace_id_to_type[id],
custom = -1,
}
else
-- User defined workshop or furnace.
for i, def in ipairs(df.global.world.raws.buildings.all) do
if def.code == id then
local typ = df.building_type.Furnace
local styp = df.furnace_type.Custom
if getmetatable(def) == "building_def_workshopst" then
typ = df.building_type.Workshop
styp = df.workshop_type.Custom
end
return {
type = typ,
subtype = styp,
custom = i,
}
end
end
end
return nil
end
-- OK, Now to "Libs/Change Build List" proper...
--[[
-- Examples:
-- Remove the carpenters workshop.
ChangeBuilding("CARPENTERS", "WORKSHOPS", false)
-- Make it impossible to build walls (not recommended!).
ChangeBuildingAdv(df.building_type.Construction, df.construction_type.Wall, -1, "CONSTRUCTIONS", false)
-- Add the mechanic's workshops to the machines category.
ChangeBuilding("MECHANICS", "MACHINES", true, "CUSTOM_E")
]]
local category_name_to_id = {
["MAIN_PAGE"] = 0,
["SIEGE_ENGINES"] = 1,
["TRAPS"] = 2,
["WORKSHOPS"] = 3,
["FURNACES"] = 4,
["CONSTRUCTIONS"] = 5,
["MACHINES"] = 6,
["CONSTRUCTIONS_TRACK"] = 7,
}
--[[
{
category = 0, -- The menu category id (from category_name_to_id)
add = true, -- Are we adding a workshop or removing one?
id = {
-- The building IDs.
type = 0,
subtype = 0,
custom = 0,
},
}
]]
stuffToChange = stuffToChange or {}
-- Returns true if DF would normally allow you to build a workshop or furnace.
-- Use this if you want to change a building, but only if it is permitted in the current entity.
function IsEntityPermitted(id)
local wshop = GetWShopType(id)
-- It's hard coded, so yes, of course it's permitted, why did you even ask?
if wshop.custom == -1 then
return true
end
local entsrc = df.historical_entity.find(df.global.ui.civ_id)
if entsrc == nil then
return false
end
local entity = entsrc.entity_raw
for _, bid in ipairs(entity.workshops.permitted_building_id) do
if wshop.custom == bid then
return true
end
end
return false
end
function RevertBuildingChanges(id, category)
local wshop = GetWShopType(id)
if wshop == nil then
qerror("RevertBuildingChanges: Invalid workshop ID: "..id)
end
RevertBuildingChangesAdv(wshop.type, wshop.subtype, wshop.custom, category)
end
function RevertBuildingChangesAdv(typ, subtyp, custom, category)
local cat
if tonumber(category) ~= nil then
cat = tonumber(category)
else
cat = category_name_to_id[category]
if cat == nil then
qerror("ChangeBuilding: Invalid category ID: "..category)
end
end
for i = #stuffToChange, 1, -1 do
local change = stuffToChange[i]
if change.category == cat then
if typ == change.id.type and subtyp == change.id.subtype and custom == change.id.custom then
table.remove(stuffToChange, i)
end
end
end
end
function ChangeBuilding(id, category, add, key)
local cat
if tonumber(category) ~= nil then
cat = tonumber(category)
else
cat = category_name_to_id[category]
if cat == nil then
qerror("ChangeBuilding: Invalid category ID: "..category)
end
end
local wshop = GetWShopType(id)
if wshop == nil then
qerror("ChangeBuilding: Invalid workshop ID: "..id)
end
if tonumber(key) == nil then
key = df.interface_key[key]
end
if key == nil then
key = 0
end
ChangeBuildingAdv(wshop.type, wshop.subtype, wshop.custom, category, add, key)
end
function ChangeBuildingAdv(typ, subtyp, custom, category, add, key)
local cat
if tonumber(category) ~= nil then
cat = tonumber(category)
else
cat = category_name_to_id[category]
if cat == nil then
qerror("ChangeBuilding: Invalid category ID: "..category)
end
end
if tonumber(key) == nil then
key = df.interface_key[key]
end
if key == nil then
key = 0
end
table.insert(stuffToChange, {
category = cat,
add = add,
key = key,
id = {
type = typ,
subtype = subtyp,
custom = custom,
},
})
end
-- Return early if module mode. Note that the ticker will not start in module mode!
-- To start the ticker you need to enable the script first ("enable change-build-menu").
if moduleMode then return end
function usage()
dfhack.print [==[
Change the build sidebar menus.
This script provides a flexible and comprehensive system for adding and removing
items from the build sidebar menus. You can add or remove workshops/furnaces by
text ID, or you can add/remove ANY building via a numeric building ID triplet.
Changes made with this script do not survive a save/load. You will need to redo
your changes each time the world loads.
Just to be clear: You CANNOT use this script AT ALL if there is no world
loaded!
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
Command Usage:
change-build-menu start|enable
enable change-build-menu
Start the ticker. This needs to be done before any changes will take
effect. Note that you can make changes before or after starting the
ticker, both options should work equally well.
change-build-menu stop|disable
disable change-build-menu
Stop the ticker. Does not clear stored changes. The ticker will
automatically stop when the current world is unloaded.
change-build-menu add <ID> <CATEGORY> [<KEY>]
Add the workshop or furnace with the ID <ID> to <CATEGORY>.
<KEY> is an optional DF hotkey ID.
<CATEGORY> may be one of:
MAIN_PAGE
SIEGE_ENGINES
TRAPS
WORKSHOPS
FURNACES
CONSTRUCTIONS
MACHINES
CONSTRUCTIONS_TRACK
Valid <ID> values for hardcoded buildings are as follows:
CARPENTERS
FARMERS
MASONS
CRAFTSDWARFS
JEWELERS
METALSMITHSFORGE
MAGMAFORGE
BOWYERS
MECHANICS
SIEGE
BUTCHERS
LEATHERWORKS
TANNERS
CLOTHIERS
FISHERY
STILL
LOOM
QUERN
KENNELS
ASHERY
KITCHEN
DYERS
TOOL
MILLSTONE
WOOD_FURNACE
SMELTER
GLASS_FURNACE
MAGMA_SMELTER
MAGMA_GLASS_FURNACE
MAGMA_KILN
KILN
change-build-menu remove <ID> <CATEGORY>
Remove the workshop or furnace with the ID <ID> from <CATEGORY>.
<CATEGORY> and <ID> may have the same values as for the "add" option.
change-build-menu revert <ID> <CATEGORY>
Revert an earlier remove or add operation. It is NOT safe to "remove"
and "add"ed building or vice versa, use this option to reverse any
changes you no longer want/need.
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
Module Usage:
To use this script as a module put the following somewhere in your own script:
local buildmenu = reqscript "change-build-menu"
Then you can call the functions documented here like so:
-- Example: Remove the carpenters workshop.
buildmenu.ChangeBuilding("CARPENTERS", "WORKSHOPS", false)
Note that to allow any of your changes to take effect you need to start the
ticker. See the "Command Usage" section.
Global Functions:
function GetWShopID(btype, bsubtype, bcustom)
GetWShopID returns a workshop or furnace's string ID based on it's
numeric ID triplet. This string ID *should* match what is expected
by eventful for hardcoded buildings.
function GetWShopType(id)
GetWShopIDs returns a workshop or furnace's ID numbers as a table.
The passed in ID should be the building's string identifier, it makes
no difference if it is a custom building or a hardcoded one.
The return table is structured like so: `{type, subtype, custom}`
function IsEntityPermitted(id)
IsEntityPermitted returns true if DF would normally allow you to build
a workshop or furnace. Use this if you want to change a building, but
only if it is permitted in the current entity. You do not need to
specify an entity, the current fortress race is used.
ChangeBuilding(id, category, [add, [key]])
ChangeBuildingAdv(typ, subtyp, custom, category, [add, [key]])
These two functions apply changes to the build sidebar menus. If "add"
is true then the building is added to the specified category, else it
is removed. When adding you may specify "key", a string DF hotkey ID.
The first version of this function takes a workshop or furnace ID as a
string, the second takes a numeric ID triplet (which can specify any
building, not just workshops or furnaces).
function RevertBuildingChanges(id, category)
function RevertBuildingChangesAdv(typ, subtyp, custom, category)
These two functions revert changes made by "ChangeBuilding" and
"ChangeBuildingAdv". Like those two functions there are two versions,
a simple one that takes a string ID and one that takes a numeric ID
triplet.
]==]
end
if not dfhack.isWorldLoaded() then
dfhack.color(RED)
print("change-build-menu: No World Loaded!\n")
dfhack.color(-1)
usage()
return
end
args = {...}
if dfhack_flags and dfhack_flags.enable then
table.insert(args, dfhack_flags.enable_state and 'enable' or 'disable')
end
tickerOn = tickerOn or false
tickerStart = false
if #args >= 1 then
if args[1] == 'start' or args[1] == 'enable' then
if not tickerOn then tickerStart = true end
elseif args[1] == 'stop' or args[1] == 'disable' then
tickerOn = false
elseif args[1] == 'add' then
ChangeBuilding(args[2], args[3], true, args[4])
return
elseif args[1] == 'remove' then
ChangeBuilding(args[2], args[3], false)
return
elseif args[1] == 'revert' then
RevertBuildingChanges(args[2], args[3])
return
else
usage()
return
end
else
usage()
return
end
-- These two store the values we *think* are in effect, they are used to detect changes.
sidebarLastCat = sidebarLastCat or -1
sidebarIsBuild = sidebarIsBuild or false
local function checkSidebar()
-- Needs to be "frames" so it ticks over while paused.
if tickerOn then
dfhack.timeout(1, "frames", checkSidebar)
end
local sidebar = df.global.ui_sidebar_menus.building
if not sidebarIsBuild and df.global.ui.main.mode ~= df.ui_sidebar_mode.Build then
-- Not in build mode.
return
elseif sidebarIsBuild and df.global.ui.main.mode ~= df.ui_sidebar_mode.Build then
-- Just exited build mode
sidebarIsBuild = false
sidebarLastCat = -1
return
elseif sidebarIsBuild and sidebar.category_id == sidebarLastCat then
-- In build mode, but category has not changed since last frame.
return
end
-- Either we just entered build mode or the category has changed.
sidebarIsBuild = true
sidebarLastCat = sidebar.category_id
-- Changes made here do not persist, they need to be made every time the side bar is shown.
-- Will just deleting stuff cause a memory leak? (probably, but how can it be avoided?)
local stufftoremove = {}
local stufftoadd = {}
for i, btn in ipairs(sidebar.choices_all) do
if getmetatable(btn) == "interface_button_construction_building_selectorst" then
for _, change in ipairs(stuffToChange) do
if not change.add and sidebar.category_id == change.category and
btn.building_type == change.id.type and btn.building_subtype == change.id.subtype and
btn.custom_type == change.id.custom then
table.insert(stufftoremove, i)
end
end
end
end
for _, change in ipairs(stuffToChange) do
if sidebar.category_id == change.category and change.add then
table.insert(stufftoadd, change)
end
end
-- Do the actual adding and removing.
-- We need to iterate the list backwards to keep from invalidating the stored indexes.
for x = #stufftoremove, 1, -1 do
-- AFAIK item indexes always match (except for one extra item at the end of "choices_all").
local i = stufftoremove[x]
sidebar.choices_visible:erase(i)
sidebar.choices_all:erase(i)
end
for _, change in ipairs(stufftoadd) do
local button = df.interface_button_construction_building_selectorst:new()
button.hotkey_id = change.key
button.building_type = change.id.type
button.building_subtype = change.id.subtype
button.custom_type = change.id.custom
local last = #sidebar.choices_visible
sidebar.choices_visible:insert(last, button)
sidebar.choices_all:insert(last, button)
end
end
-- The logic for the ticker is much more complicated here than the Rubble version.
-- The Rubble version can lean on the script loader, this has to do all the work itself.
-- Greatly complicating things is that Rubble will only evaluate a module once, whereas a
-- command script may be evaluated many times.
dfhack.onStateChange.ChangeBuildList = function(event)
if event == SC_WORLD_LOADED and not tickerOn and tickerStart then
-- Set ticker flags...
tickerOn = true
tickerStart = false
-- Then start the ticker itself.
checkSidebar()
end
if event == SC_WORLD_UNLOADED and tickerOn then
-- This will kill the ticker next frame.
tickerOn = false
-- The Rubble version doesn't need to worry about this stuff, but we do here...
sidebarLastCat = -1
sidebarIsBuild = false
stuffToChange = nil
end
end
dfhack.onStateChange.ChangeBuildList(SC_WORLD_LOADED)
There are no external requirements, just drop it into "raw/scripts" and have fun!
To make this script easier to use with raws containing multiple playable races I also threw together "if-entity.lua", a simple script that allowsyou to make a command conditional on the current entity matching a given ID.
-- Run a command if the current entity matches a given ID.
--[[
Consider this public domain (CC0).
- Milo Christiansen
]]
local usage = [=[
Run a command if the current entity matches a given ID.
To use this script effectively it needs to be called from "raw/onload.init".
Calling this from the main dfhack.init file will do nothing, as no world has
been loaded yet.
Arguments:
-id
Specify the entity ID to match
-cmd [ commandStrs ]
Specify the command to be run if the current entity matches the entity
given via -id
All arguments are required.
Example:
-- Print a message if you load an elf fort, but not a dwarf, human, etc
-- fort.
if-entity -id "FOREST" -cmd [ lua "print('Dirty hippies.')" ]
]=]
local utils = require 'utils'
validArgs = validArgs or utils.invert({
'help',
'id',
'cmd',
})
local args = utils.processArgs({...}, validArgs)
if not args.id or not args.cmd or args.help then
dfhack.print(usage)
return
end
local entsrc = df.historical_entity.find(df.global.ui.civ_id)
if entsrc == nil then
dfhack.printerr("Could not find current entity. No world loaded?")
return
end
if entsrc.entity_raw.code == args.id then
dfhack.run_command(table.unpack(args.cmd))
end