diff --git a/docs/changelog.txt b/docs/changelog.txt index 85b094f0d3..865d761f02 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -57,8 +57,11 @@ Template for new versions: ## New Tools ## New Features +- `autofarm`: farm plots can now be individually excluded from autofarm management via new controls on the farm plot information sheet, which also warn when a farm's crops are being managed by autofarm +- `autofarm`: thresholds can now be assigned to specific growth products of a plant (e.g. ``BUSH_QUARRY:LEAVES``), so crops that are grown for their growth items can be farmed to a target amount of that product ## Fixes +- `autofarm`: farms that span multiple biomes are now handled correctly: crops that can grow in any of the farm's biomes can be assigned to the farm, instead of only using the biome of the farm's center tile - `labormanager`: the modern engine's labor-mismatch probe no longer enables unmanaged labors on idle units or specialists, and no longer re-probes postings that can never be claimed (#6028) ## Misc Improvements diff --git a/docs/plugins/autofarm.rst b/docs/plugins/autofarm.rst index 0fe98f2b8b..0d070eeedc 100644 --- a/docs/plugins/autofarm.rst +++ b/docs/plugins/autofarm.rst @@ -9,6 +9,14 @@ Periodically scan your plant stocks and assign crops to your farm plots based on which plant stocks are low (as long as you have the appropriate seeds). The target threshold for each crop type is configurable. +Farms that span multiple biomes are handled correctly: any crop that can grow +in any of the biomes covered by the farm's tiles may be assigned to that farm. + +Individual farm plots can be excluded from autofarm management via the controls +that appear on the farm plot information sheet. That sheet also provides a +shortcut to `gui/autofarm`, a convenient UI for setting crop thresholds and +enabling or disabling autofarm. + Usage ----- @@ -26,6 +34,13 @@ Usage You can find the identifiers for the crop types in your world by running the following command: ``getplants -f`` +Some plants produce usable growth items (such as fruits, leaves, or bulbs) +when processed or gathered. These products normally count toward the plant's +threshold together with the plant items themselves, but you can also assign a +threshold to a specific growth product with the ``:`` syntax. +A plant is grown while either its pooled stock or any of its assigned growth +products is below the corresponding threshold. + Examples -------- @@ -33,3 +48,6 @@ Examples Set the default threshold to 30. ``autofarm threshold 150 MUSHROOM_HELMET_PLUMP GRASS_TAIL_PIG`` Set the threshold for Plump Helmets and Pig Tails to 150 +``autofarm threshold 30 BUSH_QUARRY:LEAVES`` + Keep at least 30 Quarry Bush Leaves in stock, growing Quarry Bushes until + you have enough of them to process diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 45290fbc3c..f8a75436bd 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -51,7 +51,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(autoclothing autoclothing.cpp LINK_LIBRARIES lua) dfhack_plugin(design design.cpp LINK_LIBRARIES lua) dfhack_plugin(autodump autodump.cpp) - dfhack_plugin(autofarm autofarm.cpp) + dfhack_plugin(autofarm autofarm.cpp LINK_LIBRARIES lua) #dfhack_plugin(autogems autogems.cpp LINK_LIBRARIES jsoncpp_static) add_subdirectory(autolabor) dfhack_plugin(autonestbox autonestbox.cpp LINK_LIBRARIES lua) diff --git a/plugins/autofarm.cpp b/plugins/autofarm.cpp index 65a08123d9..2881bfcc96 100644 --- a/plugins/autofarm.cpp +++ b/plugins/autofarm.cpp @@ -1,8 +1,12 @@ #include "Debug.h" +#include "LuaTools.h" #include "PluginManager.h" +#include "PluginLua.h" +#include "modules/Buildings.h" #include "modules/Items.h" #include "modules/Maps.h" +#include "modules/Materials.h" #include "modules/World.h" #include "df/biome_type.h" @@ -16,14 +20,17 @@ #include "df/item_plant_growthst.h" #include "df/item_seedsst.h" #include "df/items_other_id.h" +#include "df/material.h" +#include "df/material_flags.h" +#include "df/plant_growth.h" #include "df/plant_raw.h" #include "df/plant_raw_flags.h" #include "df/plotinfost.h" #include "df/unit.h" #include "df/world.h" +#include #include -#include using namespace DFHack; using namespace df::enums; @@ -51,7 +58,15 @@ class AutoFarm { std::map thresholds; int defaultThreshold = 50; + // thresholds for specific growth products of a plant, keyed by + // (plant index, growth id). growth items whose growth has no explicit + // threshold count toward the plant's pooled threshold instead + std::map, int> growth_thresholds; + std::map lastCounts; + // stockpile growth item counts, keyed by (plant index, growth id). + // populated for reporting; untracked growths also count toward lastCounts + std::map, int> lastGrowthCounts; public: void initialize() @@ -59,7 +74,10 @@ class AutoFarm { thresholds.clear(); defaultThreshold = 50; + growth_thresholds.clear(); + lastCounts.clear(); + lastGrowthCounts.clear(); } void setThreshold(int id, int val) @@ -72,6 +90,46 @@ class AutoFarm { return (thresholds.count(id) > 0) ? thresholds[id] : defaultThreshold; } + void removeThreshold(int id) + { + thresholds.erase(id); + } + + void setGrowthThreshold(int plant_id, const std::string& growth_id, int val) + { + growth_thresholds[std::make_pair(plant_id, growth_id)] = val; + } + + void removeGrowthThreshold(int plant_id, const std::string& growth_id) + { + growth_thresholds.erase(std::make_pair(plant_id, growth_id)); + } + + const std::map, int>& getGrowthThresholds() + { + return growth_thresholds; + } + + const std::map, int>& getLastGrowthCounts() + { + return lastGrowthCounts; + } + + int getDefault() + { + return defaultThreshold; + } + + const std::map& getThresholds() + { + return thresholds; + } + + const std::map& getLastCounts() + { + return lastCounts; + } + void setDefault(int val) { defaultThreshold = val; @@ -194,6 +252,110 @@ class AutoFarm { return raw ? raw->name : "NONE"; } + // whether the growth's material produces stockpilable growth items; + // only these are ever produced by processing jobs and gathering, so + // they are the only growths that can be meaningfully tracked + static bool is_stockpile_growth(df::plant_growth* growth) + { + MaterialInfo mat(growth->mat_type, growth->mat_index); + return mat.isValid() && + mat.material->flags.is_set(df::material_flags::STOCKPILE_PLANT_GROWTH); + } + + // the id of the stockpile growth at the given index, or "" if the + // growth doesn't exist or isn't stockpilable + static std::string stockpile_growth_id(df::plant_raw* plant, int32_t subtype) + { + if (subtype >= 0 && (size_t)subtype < plant->growths.size() && + is_stockpile_growth(plant->growths[subtype])) + return plant->growths[subtype]->id; + return ""; + } + + static bool has_stockpile_growth(df::plant_raw* plant, const std::string& growth_id) + { + for (auto g : plant->growths) + if (g->id == growth_id && is_stockpile_growth(g)) + return true; + return false; + } + + // gathers the set of biomes covered by the farm's tiles, since a farm may + // span multiple biomes and DF allows planting any crop that can grow in + // any of them (in the tiles of the respective biome) + std::set get_farm_biomes(df::building_farmplotst* farm) + { + std::set biomes; + for (int32_t x = farm->x1; x <= farm->x2; x++) + { + for (int32_t y = farm->y1; y <= farm->y2; y++) + { + if (!Buildings::containsTile(farm, df::coord2d(x, y))) + continue; + auto des = Maps::getTileDesignation(x, y, farm->z); + if (!des) + continue; + if (des->bits.subterranean) + { + // any subterranean tile can grow any crop that grows + // underground + biomes.insert(df::biome_type::SUBTERRANEAN_WATER); + biomes.insert(df::biome_type::SUBTERRANEAN_CHASM); + biomes.insert(df::biome_type::SUBTERRANEAN_LAVA); + } + else if (Maps::getRegionBiome(Maps::getTileBiomeRgn(df::coord(x, y, farm->z)))) + { + df::coord2d region(Maps::getTileBiomeRgn(df::coord(x, y, farm->z))); + biomes.insert(Maps::getBiomeType(region.x, region.y)); + } + } + } + return biomes; + } + +public: + bool is_farm_excluded(int32_t farm_id) + { + auto item = World::GetPersistentSiteData("autofarm/excluded_farm/" + std::to_string(farm_id)); + return item.isValid() && item.ival(0) != 0; + } + + void set_farm_excluded(int32_t farm_id, bool excluded) + { + const std::string key = "autofarm/excluded_farm/" + std::to_string(farm_id); + auto item = World::GetPersistentSiteData(key); + if (excluded) + { + if (!item.isValid()) + item = World::AddPersistentSiteData(key); + if (item.isValid()) + item.ival(0) = 1; + } + else if (item.isValid()) + { + World::DeletePersistentData(item); + } + } + + std::vector get_excluded_farms() + { + std::vector items; + std::vector ret; + World::GetPersistentSiteData(&items, "autofarm/excluded_farm/", true); + for (auto& i : items) + { + if (!i.isValid() || i.ival(0) == 0) + continue; + int32_t farm_id = atoi(i.key().substr(strlen("autofarm/excluded_farm/")).c_str()); + auto bld = df::building::find(farm_id); + // ignore entries left behind by destroyed farm plots + if (bld && bld->getType() == df::building_type::FarmPlot) + ret.push_back(farm_id); + } + return ret; + } + +private: void set_farm(color_ostream& out, int new_plant_id, df::building_farmplotst* farm, int season) { int old_plant_id = farm->plant_id[season]; @@ -206,54 +368,100 @@ class AutoFarm { } } - void set_farms(color_ostream& out, const std::set& plants, const std::vector& farms) +public: + void set_farms(color_ostream& out, const std::map>& plants, + const std::vector>>& farms) { - // this algorithm attempts to change as few farms as possible, while ensuring that - // the number of farms planting each eligible plant is "as equal as possible" + // this algorithm attempts to change as few farms as possible, while + // ensuring that the number of farms planting each plant is "as equal + // as possible". unlike previously, each farm may have a different set + // of plants that it can grow, since a farm can span multiple biomes int season = *df::global::cur_season; - if (farms.empty() || plants.empty()) + // the plants that each farm is eligible to grow, and the list of + // farms assigned to each plant + std::map> eligible; + std::map> assigned; + std::map counters; + std::vector unassigned; + + for (auto& [farm, biomes] : farms) { - // if no more plants were requested, fallow all farms - // if there were no farms, do nothing - for (auto farm : farms) + for (auto biome : biomes) { - set_farm(out, -1, farm, season); + auto it = plants.find(biome); + if (it != plants.end()) + eligible[farm].insert(it->second.begin(), it->second.end()); } - return; + for (auto plant : eligible[farm]) + assigned[plant]; // ensure every eligible plant can receive farms } - int min = farms.size() / plants.size(); // the number of farms that should plant each eligible plant, rounded down - int extra = farms.size() - min * plants.size(); // the remainder that cannot be evenly divided + // keep the current assignment for farms whose crop is still eligible + for (auto& [farm, biomes] : farms) + { + int cur = farm->plant_id[season]; + if (cur >= 0 && eligible[farm].count(cur)) + { + assigned[cur].push_back(farm); + counters[cur]++; + } + else + { + unassigned.push_back(farm); + } + } - std::map counters; - std::queue toChange; + // assign farms with the fewest options first so that constrained + // farms don't get crowded out by more flexible ones + std::sort(unassigned.begin(), unassigned.end(), + [&](df::building_farmplotst* a, df::building_farmplotst* b) + { + return eligible[a].size() < eligible[b].size(); + }); - for (auto farm : farms) + for (auto farm : unassigned) { - int o = farm->plant_id[season]; - if (plants.count(o) == 0 || counters[o] > min || (counters[o] == min && extra == 0)) - toChange.push(farm); // this farm is an excess instance for the plant it is currently planting - else + if (eligible[farm].empty()) { - if (counters[o] == min) - extra--; // allocate off one of the remainder farms - counters[o]++; + set_farm(out, -1, farm, season); + continue; } + int best = *std::min_element(eligible[farm].begin(), eligible[farm].end(), + [&](int a, int b) { return counters[a] < counters[b]; }); + set_farm(out, best, farm, season); + assigned[best].push_back(farm); + counters[best]++; } - for (auto n : plants) + // rebalance: move farms from overrepresented plants to + // underrepresented ones while their counts differ by more than one. + // each move strictly decreases the variance, so this terminates + for (bool moved = true; moved;) { - int c = counters[n]; - while (toChange.size() > 0 && (c < min || (c == min && extra > 0))) + moved = false; + for (auto& [src, src_farms] : assigned) { - // pick one of the excess farms and change it to plant this plant - df::building_farmplotst* farm = toChange.front(); - set_farm(out, n, farm, season); - toChange.pop(); - if (c++ == min) - extra--; + for (auto& [dst, dst_farms] : assigned) + { + if (counters[src] <= counters[dst] + 1) + continue; + auto it = std::find_if(src_farms.begin(), src_farms.end(), + [&](df::building_farmplotst* f) { return eligible[f].count(dst); }); + if (it == src_farms.end()) + continue; + auto farm = *it; + set_farm(out, dst, farm, season); + src_farms.erase(it); + dst_farms.push_back(farm); + counters[src]--; + counters[dst]++; + moved = true; + break; + } + if (moved) + break; } } } @@ -277,7 +485,9 @@ class AutoFarm { // have to scan both items[PLANT] and items[PLANT_GROWTH] because agricultural products can be either - auto count = [&, this](df::item* i) { + lastGrowthCounts.clear(); + + auto count_plant = [&, this](df::item* i) { auto mat = i->getMaterialIndex(); if ((i->flags.whole & bad_flags) == 0 && plantable_plants.count(mat) > 0) @@ -286,45 +496,73 @@ class AutoFarm { } }; + auto count_growth = [&, this](df::item* i) { + auto g = virtual_cast(i); + auto mat = i->getMaterialIndex(); + if (!g || (i->flags.whole & bad_flags) != 0 || + plantable_plants.count(mat) == 0) + return; + df::plant_raw* plant = world->raws.plants.all[mat]; + const std::string gid = stockpile_growth_id(plant, g->subtype); + const auto key = std::make_pair(mat, gid); + if (!gid.empty() && growth_thresholds.count(key)) + { + // tracked growths count only toward their own threshold + lastGrowthCounts[key] += i->getStackSize(); + } + else + { + if (!gid.empty()) + // unconfigured stockpile growths are tracked + // separately for reporting, but still count toward + // the plant's pooled threshold + lastGrowthCounts[key] += i->getStackSize(); + lastCounts[mat] += i->getStackSize(); + } + }; + for (auto i : world->items.other[df::items_other_id::PLANT]) - count(i); + count_plant(i); for (auto i : world->items.other[df::items_other_id::PLANT_GROWTH]) - count(i); + count_growth(i); std::map> plants; for (auto& plantable : plantable_plants) { df::plant_raw* plant = world->raws.plants.all[plantable.first]; - if (lastCounts[plant->index] < getThreshold(plant->index)) + // a plant is in demand when its pooled stock is under threshold + // or any explicitly tracked growth of it is under threshold + bool under = lastCounts[plant->index] < getThreshold(plant->index); + if (!under) + for (auto& gt : growth_thresholds) + { + if (gt.first.first == plant->index && + lastGrowthCounts[gt.first] < gt.second) + { + under = true; + break; + } + } + if (under) for (auto biome : plantable.second) { plants[biome].insert(plant->index); } } - std::map> farms; + std::vector>> farms; for (auto& bb : world->buildings.other[df::buildings_other_id::FARM_PLOT]) { auto farm = virtual_cast(bb); - if (farm->flags.bits.exists) + if (farm->flags.bits.exists && !is_farm_excluded(farm->id)) { - df::biome_type biome; - if (Maps::getTileDesignation(bb->centerx, bb->centery, bb->z)->bits.subterranean) - biome = biome_type::SUBTERRANEAN_WATER; - else { - df::coord2d region(Maps::getTileBiomeRgn(df::coord(bb->centerx, bb->centery, bb->z))); - biome = Maps::getBiomeType(region.x, region.y); - } - farms[biome].push_back(farm); + farms.emplace_back(farm, get_farm_biomes(farm)); } } - for (auto& ff : farms) - { - set_farms(out, plants[ff.first], ff.second); - } + set_farms(out, plants, farms); out << std::flush; } @@ -346,6 +584,14 @@ class AutoFarm { auto plant = world->raws.plants.all[th.first]; out << plant->id << " limit " << getThreshold(th.first) << " current 0" << '\n'; } + + for (auto& gt : growth_thresholds) + { + auto plant = world->raws.plants.all[gt.first.first]; + out << plant->id << ':' << gt.first.second << " limit " << gt.second + << " current " << lastGrowthCounts[gt.first] << '\n'; + } + out << "Default: " << defaultThreshold << '\n'; out << std::flush; @@ -386,15 +632,32 @@ class AutoFarm { const auto allPlants = world->raws.plants.all; const std::string id = i.val(); const int val = i.ival(0); - const auto plant = std::find_if(std::begin(allPlants), std::end(allPlants), [id](df::plant_raw* p) { return p->id == id; }); - if (plant != std::end(allPlants)) + const auto colon = id.find(':'); + const std::string plant_id = id.substr(0, colon); + const auto plant = std::find_if(std::begin(allPlants), std::end(allPlants), [plant_id](df::plant_raw* p) { return p->id == plant_id; }); + if (plant == std::end(allPlants)) + { + WARN(control, out).print("threshold for unknown plant {} in saved configuration ignored\n", id); + } + else if (colon == std::string::npos) { setThreshold((*plant)->index, val); INFO(control, out).print("threshold of {} for plant {} in saved configuration loaded\n", val, id); } else { - WARN(control, out).print("threshold for unknown plant {} in saved configuration ignored\n", id); + // "PLANT:GROWTH" entries hold thresholds for specific + // stockpile growth products of the plant + const std::string growth_id = id.substr(colon + 1); + if (has_stockpile_growth(*plant, growth_id)) + { + setGrowthThreshold((*plant)->index, growth_id, val); + INFO(control, out).print("threshold of {} for growth {} in saved configuration loaded\n", val, id); + } + else + { + WARN(control, out).print("threshold for unknown growth {} in saved configuration ignored\n", id); + } } } } @@ -419,6 +682,15 @@ class AutoFarm { cfgThreshold.val() = plantID; cfgThreshold.ival(0) = t.second; } + + for (const auto& t : growth_thresholds) + { + const std::string productID = world->raws.plants.all[t.first.first]->id + ":" + t.first.second; + const std::string keyName = "autofarm/threshold/" + productID; + PersistentDataItem cfgThreshold = World::AddPersistentSiteData(keyName); + cfgThreshold.val() = productID; + cfgThreshold.ival(0) = t.second; + } } }; @@ -490,19 +762,29 @@ static command_result setThresholds(color_ostream& out, std::vector std::string id = parameters[i]; std::transform(id.begin(), id.end(), id.begin(), ::toupper); + // a threshold may apply to a plant ("PLANT") or to a specific + // stockpile growth product of a plant ("PLANT:GROWTH") + const auto colon = id.find(':'); + const std::string plant_id = id.substr(0, colon); + const std::string growth_id = colon == std::string::npos ? "" : id.substr(colon + 1); + bool ok = false; for (auto plant : world->raws.plants.all) { - if (plant->flags.is_set(df::plant_raw_flags::SEED) && (plant->id == id)) - { + if (!plant->flags.is_set(df::plant_raw_flags::SEED) || (plant->id != plant_id)) + continue; + if (growth_id.empty()) autofarmInstance->setThreshold(plant->index, val); - ok = true; + else if (AutoFarm::has_stockpile_growth(plant, growth_id)) + autofarmInstance->setGrowthThreshold(plant->index, growth_id, val); + else break; - } + ok = true; + break; } if (!ok) { - out << "Cannot find plant with id " << id << '\n' << std::flush; + out << "Cannot find plant or growth with id " << id << '\n' << std::flush; return CR_WRONG_USAGE; } } @@ -545,3 +827,101 @@ static command_result autofarm(color_ostream& out, std::vector& par return CR_OK; } + +static void autofarm_setDefaultThreshold(color_ostream& out, int32_t val) +{ + autofarmInstance->setDefault(val); + autofarmInstance->save_state(out); +} + +static void autofarm_setThreshold(color_ostream& out, int32_t plant_index, int32_t val) +{ + autofarmInstance->setThreshold(plant_index, val); + autofarmInstance->save_state(out); +} + +static void autofarm_removeThreshold(color_ostream& out, int32_t plant_index) +{ + autofarmInstance->removeThreshold(plant_index); + autofarmInstance->save_state(out); +} + +static void autofarm_setGrowthThreshold(color_ostream& out, int32_t plant_index, std::string growth_id, int32_t val) +{ + autofarmInstance->setGrowthThreshold(plant_index, growth_id, val); + autofarmInstance->save_state(out); +} + +static void autofarm_removeGrowthThreshold(color_ostream& out, int32_t plant_index, std::string growth_id) +{ + autofarmInstance->removeGrowthThreshold(plant_index, growth_id); + autofarmInstance->save_state(out); +} + +static bool autofarm_isFarmExcluded(int32_t farm_id) +{ + return autofarmInstance->is_farm_excluded(farm_id); +} + +static void autofarm_setFarmExcluded(color_ostream& out, int32_t farm_id, bool excluded) +{ + autofarmInstance->set_farm_excluded(farm_id, excluded); +} + +// pushes a nested table of the form {plant_index = {growth_id = count}} for a +// map keyed by (plant_index, growth_id) +static void push_growth_map(lua_State* L, const std::map, int>& m) +{ + lua_newtable(L); + for (auto& [key, val] : m) + { + lua_pushinteger(L, key.first); + lua_gettable(L, -2); + if (lua_isnil(L, -1)) + { + lua_pop(L, 1); + lua_newtable(L); + lua_pushinteger(L, key.first); + lua_pushvalue(L, -2); + lua_settable(L, -4); + } + Lua::Push(L, key.second); + lua_pushinteger(L, val); + lua_settable(L, -3); + lua_pop(L, 1); + } +} + +static int autofarm_getData(lua_State* L) +{ + lua_newtable(L); + int ctable = lua_gettop(L); + Lua::SetField(L, autofarmInstance->getDefault(), ctable, "default_threshold"); + Lua::Push(L, autofarmInstance->getThresholds()); + lua_setfield(L, ctable, "thresholds"); + push_growth_map(L, autofarmInstance->getGrowthThresholds()); + lua_setfield(L, ctable, "growth_thresholds"); + Lua::Push(L, autofarmInstance->getLastCounts()); + lua_setfield(L, ctable, "last_counts"); + push_growth_map(L, autofarmInstance->getLastGrowthCounts()); + lua_setfield(L, ctable, "growth_counts"); + Lua::Push(L, autofarmInstance->get_excluded_farms()); + lua_setfield(L, ctable, "excluded_farms"); + return 1; +} + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(autofarm_setDefaultThreshold), + DFHACK_LUA_FUNCTION(autofarm_setThreshold), + DFHACK_LUA_FUNCTION(autofarm_removeThreshold), + DFHACK_LUA_FUNCTION(autofarm_setGrowthThreshold), + DFHACK_LUA_FUNCTION(autofarm_removeGrowthThreshold), + DFHACK_LUA_FUNCTION(autofarm_isFarmExcluded), + DFHACK_LUA_FUNCTION(autofarm_setFarmExcluded), + DFHACK_LUA_END +}; + +DFHACK_PLUGIN_LUA_COMMANDS { + DFHACK_LUA_COMMAND(autofarm_getData), + DFHACK_LUA_END +}; diff --git a/plugins/lua/autofarm.lua b/plugins/lua/autofarm.lua new file mode 100644 index 0000000000..1278aa99dd --- /dev/null +++ b/plugins/lua/autofarm.lua @@ -0,0 +1,184 @@ +local _ENV = mkmodule('plugins.autofarm') + +local gui = require('gui') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +local function get_farm() + local bld = dfhack.gui.getSelectedBuilding(true) + if bld and bld:getType() == df.building_type.FarmPlot then + return bld + end +end + +local function farm_is_managed(farm) + return farm and autofarm_isFarmExcluded and + not autofarm_isFarmExcluded(farm.id) +end + +-------------------------------- +-- FarmControlsOverlay +-- +-- toggle strip shown on the farm plot info sheet: enables/disables autofarm +-- globally, marks the inspected farm as managed or manually controlled, and +-- provides a link to the autofarm configuration GUI + +FarmControlsOverlay = defclass(FarmControlsOverlay, overlay.OverlayWidget) +FarmControlsOverlay.ATTRS{ + desc='Adds autofarm controls to the farm plot info sheet.', + default_pos={x=-43, y=13}, + version=3, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/FarmPlot', + frame={w=50, h=2}, + frame_background=gui.CLEAR_PEN, +} + +function FarmControlsOverlay:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id='autofarm_enabled', + frame={t=0, l=0}, + label='autofarm is', + key='CUSTOM_CTRL_A', + options={{value=true, label='enabled', pen=COLOR_GREEN}, + {value=false, label='disabled', pen=COLOR_RED}}, + on_change=function(val) setEnabled(val) end, + }, + widgets.ToggleHotkeyLabel{ + view_id='farm_managed', + frame={t=1, l=0}, + label='this farm is', + key='CUSTOM_CTRL_M', + enabled=function() return isEnabled() end, + options={{value=true, label='managed by autofarm', pen=COLOR_GREEN}, + {value=false, label='managed manually', pen=COLOR_YELLOW}}, + on_change=function(val) + local farm = get_farm() + if farm then autofarm_setFarmExcluded(farm.id, not val) end + end, + }, + widgets.TextButton{ + frame={t=0, r=0, w=18, h=1}, + label='configure', + key='CUSTOM_CTRL_S', + on_activate=function() dfhack.run_script('gui/autofarm') end, + }, + } +end + +function FarmControlsOverlay:render(dc) + self.subviews.autofarm_enabled:setOption(isEnabled()) + self.subviews.farm_managed:setOption(farm_is_managed(get_farm())) + FarmControlsOverlay.super.render(self, dc) +end + +-------------------------------- +-- FarmCoverOverlay +-- +-- opaque panel shown over the crop list of farms managed by autofarm. warns +-- the player that manual crop selection is overridden by autofarm and +-- provides links to the autofarm configuration GUI and to per-farm control + +local function get_crop_name(plant_id) + if not plant_id or plant_id < 0 then return 'fallow' end + local raw = df.plant_raw.find(plant_id) + return raw and raw.name or 'unknown' +end + +FarmCoverOverlay = defclass(FarmCoverOverlay, overlay.OverlayWidget) +FarmCoverOverlay.ATTRS{ + desc='Covers the crop list of farm plots managed by autofarm.', + default_pos={x=-40, y=16}, + version=4, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/FarmPlot', + frame={w=57, h=22}, + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + visible=function() + local farm = get_farm() + return isEnabled() and farm_is_managed(farm) and + farm:getBuildStage() == farm:getMaxBuildStage() + end, +} + +function FarmCoverOverlay:init() + self:addviews{ + widgets.Label{ + frame={t=1, l=2}, + text='This farm is managed by autofarm', + text_pen=COLOR_LIGHTGREEN, + }, + widgets.Label{ + view_id='now_planting', + frame={t=2, l=2}, + text='Now planting: ?', + }, + widgets.WrappedLabel{ + frame={t=3, l=2, r=2}, + text_to_wrap='Crops are chosen automatically based on your'.. + ' configured stock thresholds. Manual crop selection'.. + ' will be overridden.', + }, + widgets.WrappedLabel{ + view_id='fallow_note', + frame={t=6, l=2, r=2}, + text_pen=COLOR_YELLOW, + }, + widgets.TextButton{ + frame={b=4, l=2, r=2, h=1}, + label='configure autofarm', + key='CUSTOM_CTRL_S', + on_activate=function() dfhack.run_script('gui/autofarm') end, + }, + widgets.TextButton{ + frame={b=3, l=2, r=2, h=1}, + label='manage this farm manually', + key='CUSTOM_CTRL_M', + on_activate=function() + local farm = get_farm() + if farm then autofarm_setFarmExcluded(farm.id, true) end + end, + }, + } +end + +function FarmCoverOverlay:render(dc) + local farm = get_farm() + if farm then + self.subviews.now_planting:setText{ + 'Now planting: ', + {text=get_crop_name(farm.plant_id[df.global.cur_season]), + pen=COLOR_LIGHTCYAN}, + } + local all_fallow = true + for i=0,3 do + if farm.plant_id[i] >= 0 then + all_fallow = false + break + end + end + self.subviews.fallow_note:setText(all_fallow and + 'All seasons are fallow: no plantable seeds available, or all'.. + ' crops are above their thresholds.' or '') + end + FarmCoverOverlay.super.render(self, dc) +end + +-- swallow mouse input over the covered area so that the underlying crop +-- list cannot be manipulated while autofarm is in control +function FarmCoverOverlay:onInput(keys) + if FarmCoverOverlay.super.onInput(self, keys) then return true end + if (keys._MOUSE_L or keys._MOUSE_R) and self:getMousePos() then + return true + end + return false +end + +OVERLAY_WIDGETS = { + farm_controls=FarmControlsOverlay, + farm_cover=FarmCoverOverlay, +} + +return _ENV diff --git a/scripts b/scripts index 4c4f5bf510..ea2ba5d95c 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit 4c4f5bf510e22fb96857328638e93ded3f608d0e +Subproject commit ea2ba5d95c2376cc798c58c444455573945d374c diff --git a/test/plugins/autofarm.lua b/test/plugins/autofarm.lua index 0573d7379d..5dfb0a6348 100644 --- a/test/plugins/autofarm.lua +++ b/test/plugins/autofarm.lua @@ -38,3 +38,67 @@ function test.threshold_rejects_bad_plant() expect.true_(output:find('Cannot find plant', 1, true) ~= nil or status ~= 0) end + +local function find_plant(id) + for _,plant in ipairs(df.global.world.raws.plants.all) do + if plant.id == id then return plant end + end +end + +function test.threshold_growth() + local plugin = require('plugins.autofarm') + -- quarry bushes have a stockpile LEAVES growth in vanilla + local output, status = dfhack.run_command_silent('autofarm', + 'threshold', '30', + 'BUSH_QUARRY:LEAVES') + expect.eq(0, status) + local plant = find_plant('BUSH_QUARRY') + local data = plugin.autofarm_getData() + expect.eq(30, data.growth_thresholds[plant.index].LEAVES) + plugin.autofarm_removeGrowthThreshold(plant.index, 'LEAVES') + plugin.autofarm_removeThreshold(plant.index) +end + +function test.threshold_rejects_bad_growth() + local output, status = dfhack.run_command_silent('autofarm', + 'threshold', '5', + 'BUSH_QUARRY:NOPE') + expect.true_(output:find('Cannot find plant or growth', 1, true) ~= nil + or status ~= 0) + -- growths that are not stockpile-marked are also rejected + output, status = dfhack.run_command_silent('autofarm', + 'threshold', '5', + 'BUCKWHEAT:FLOWERS') + expect.true_(output:find('Cannot find plant or growth', 1, true) ~= nil + or status ~= 0) +end + +function test.growth_threshold_funcs() + local plugin = require('plugins.autofarm') + local plant = find_plant('BUSH_QUARRY') + plugin.autofarm_setGrowthThreshold(plant.index, 'LEAVES', 42) + local data = plugin.autofarm_getData() + expect.eq(42, data.growth_thresholds[plant.index].LEAVES) + plugin.autofarm_removeGrowthThreshold(plant.index, 'LEAVES') + data = plugin.autofarm_getData() + expect.nil_(data.growth_thresholds[plant.index]) +end + +function test.farm_exclusion() + local plugin = require('plugins.autofarm') + expect.false_(plugin.autofarm_isFarmExcluded(-1)) + plugin.autofarm_setFarmExcluded(-1, true) + expect.true_(plugin.autofarm_isFarmExcluded(-1)) + plugin.autofarm_setFarmExcluded(-1, false) + expect.false_(plugin.autofarm_isFarmExcluded(-1)) +end + +function test.get_data() + local data = require('plugins.autofarm').autofarm_getData() + expect.eq('number', type(data.default_threshold)) + expect.eq('table', type(data.thresholds)) + expect.eq('table', type(data.growth_thresholds)) + expect.eq('table', type(data.last_counts)) + expect.eq('table', type(data.growth_counts)) + expect.eq('table', type(data.excluded_farms)) +end