Bay 12 Games Forum

Please login or register.

Login with username, password and session length
Advanced search  

Author Topic: "change-build-menu" Non-Rubble version of "Libs/Change Build List"  (Read 8210 times)

milo christiansen

  • Bay Watcher
  • Something generic here
    • View Profile

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":
Code: [Select]
-- 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.

Code: [Select]
-- 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
« Last Edit: November 22, 2016, 02:07:16 pm by milo christiansen »
Logged
Rubble 8 - The most powerful modding suite in existence!
After all, coke is for furnaces, not for snorting.
You're not true dwarven royalty unless you own the complete 'Signature Collection' baby-bone bedroom set from NOKEAS

Meph

  • Bay Watcher
    • View Profile
    • worldbicyclist
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #1 on: November 18, 2016, 01:49:44 pm »

Can you limit this to a certain entity? Remove carpenter from elves, for example, but not from dwarves?
Logged
::: ☼Meph Tileset☼☼Map Tileset☼- 32x graphic sets with TWBT :::
::: ☼MASTERWORK DF☼ - A comprehensive mod pack now on Patreon - 250.000+ downloads and counting :::
::: WorldBicyclist.com - Follow my bike tours around the world - 148 countries visited :::

milo christiansen

  • Bay Watcher
  • Something generic here
    • View Profile
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #2 on: November 18, 2016, 01:56:53 pm »

Sure, but you will need to drop into Lua code for that.

The script actually has a function that checks to see if a particular (custom) workshop is enabled for the current entity, but deciding for hardcoded workshops would need something else. (basically you would need to switch based on "df.global.ui.civ_id" or some such)

I could write a generic wrapper that calls a given DFHack command only for certain entities. It wouldn't be very hard and could have seismic implications for multi-race modding...
Logged
Rubble 8 - The most powerful modding suite in existence!
After all, coke is for furnaces, not for snorting.
You're not true dwarven royalty unless you own the complete 'Signature Collection' baby-bone bedroom set from NOKEAS

Meph

  • Bay Watcher
    • View Profile
    • worldbicyclist
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #3 on: November 18, 2016, 02:52:32 pm »

Please, if thats possible, it would be really nice to have. It helps to make races more unique and should give total-conversions some more fun to do. :)
Logged
::: ☼Meph Tileset☼☼Map Tileset☼- 32x graphic sets with TWBT :::
::: ☼MASTERWORK DF☼ - A comprehensive mod pack now on Patreon - 250.000+ downloads and counting :::
::: WorldBicyclist.com - Follow my bike tours around the world - 148 countries visited :::

Dirst

  • Bay Watcher
  • [EASILY_DISTRA
    • View Profile
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #4 on: November 18, 2016, 03:02:12 pm »

Please, if thats possible, it would be really nice to have. It helps to make races more unique and should give total-conversions some more fun to do. :)
Oh come on, we already have Eventful so that you could run a script whenever a task finishes.  Anyone who completes a Carpenter job in an Elven "fort" gets struck down by a syndrome.  Of course, the game's AI doesn't understand this, so order some beds and watch it send an endless stream of elves to their deaths.  Hard to imagine a more noble use of DFHack :)

But seriously, a wrapper script would be nice to have.  It would allow for thematically appropriate variants of a modded workshop.
Logged
Just got back, updating:
(0.42 & 0.43) The Earth Strikes Back! v2.15 - Pay attention...  It's a mine!  It's-a not yours!
(0.42 & 0.43) Appearance Tweaks v1.03 - Tease those hippies about their pointy ears.
(0.42 & 0.43) Accessibility Utility v1.04 - Console tools to navigate the map

milo christiansen

  • Bay Watcher
  • Something generic here
    • View Profile
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #5 on: November 18, 2016, 03:32:50 pm »

A simple "run another command only if current entity is X" command would be trivial to write, and so I'll have one up next tuesday.

I am online every tuesday and friday, so tuesday will be the earliest I can mange (if I had DF on this computer I could write it now, but I won't post something I haven't tested at least a little).
Logged
Rubble 8 - The most powerful modding suite in existence!
After all, coke is for furnaces, not for snorting.
You're not true dwarven royalty unless you own the complete 'Signature Collection' baby-bone bedroom set from NOKEAS

milo christiansen

  • Bay Watcher
  • Something generic here
    • View Profile
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #6 on: November 22, 2016, 02:08:32 pm »

OK, I just added "if-entity" to the first post. This is a separate script, so you can use it with any commands you want, not just change-build-menu.

EDIT:

At some future release of DFHack these scripts will be available as "modtools/if-entity" and "modtools/change-build-menu". The versions posted here are for use before that point, but in the near future they will come pre-installed!
« Last Edit: November 22, 2016, 03:11:10 pm by milo christiansen »
Logged
Rubble 8 - The most powerful modding suite in existence!
After all, coke is for furnaces, not for snorting.
You're not true dwarven royalty unless you own the complete 'Signature Collection' baby-bone bedroom set from NOKEAS

Amostubal

  • Bay Watcher
    • View Profile
Re: "change-build-menu" Non-Rubble version of "Libs/Change Build List"
« Reply #7 on: November 26, 2016, 11:25:55 pm »

Awesome sauce milo!  I just got back and seen the work you did congrats.
Logged
Legendary Dwarf Fortress
Legendary Discord Group
"...peering into the darkness behind the curtains, evokes visions of pixies being chased by dragons while eating cupcakes made of coral iced with liquid fire while their hearts burn out with unknown plant substances..." - a quote from the diaries of Amostubal