diff --git a/docs/plugins/channel-safely.rst b/docs/plugins/channel-safely.rst new file mode 100644 index 000000000..6010f4925 --- /dev/null +++ b/docs/plugins/channel-safely.rst @@ -0,0 +1,62 @@ +channel-safely +============== + +.. dfhack-tool:: + :summary: Auto-manage channel designations to keep dwarves safe + :tags: fort auto + +Multi-level channel projects can be dangerous, and managing the safety of your +dwarves throughout the completion of such projects can be difficult and time +consuming. This plugin keeps your dwarves safe (while channeling) so you don't +have to. Now you can focus on designing your dwarven cities with the deep chasms +they were meant to have. + +Usage +----- + +:: + enable channel-safely + channel-safely set + channel-safely enable|disable + channel-safely run once + +When enabled the map will be scanned for channel designations which will be grouped +together based on adjacency and z-level. These groups will then be analyzed for safety +and designations deemed unsafe will be put into :wiki:`Marker Mode `. +Each time a channel designation is completed its group status is checked, and if the group +is complete pending groups below are made active again. + +Examples +-------- + +``channel-safely`` + The plugin reports its configured status. + +``channel-safely run once`` + Runs the safety procedures once. You can use this if you prefer initiating scans manually. + +``channel-safely disable require-vision`` + Allows the plugin to read all tiles, including the ones your dwarves know nothing about. + +``channel-safely enable monitor-active`` + Enables monitoring active channel digging jobs. Meaning that if another unit it present + or the tile below becomes open space the job will be paused or canceled (respectively). + +``channel-safely set ignore-threshold 3`` + Configures the plugin to ignore designations equal to or above priority 3 designations. + +Features +-------- +:monitor-active: Toggle whether to monitor the conditions of active digs. (default: disabled) +:require-vision: Toggle whether the dwarves need vision of a tile before channeling to it can be deemed unsafe. (default: enabled) +:insta-dig: Toggle whether to use insta-digging on unreachable designations. (default: disabled) + +Settings +-------- +:refresh-freq: The rate at which full refreshes are performed. + This can be expensive if you're undertaking many mega projects. (default:600, twice a day) +:monitor-freq: The rate at which active jobs are monitored. + todo: this can have a massive impact? (default:10) +:ignore-threshold: Sets the priority threshold below which designations are processed. You can set to 1 or 0 to + effectively disable the scanning. (default: 7) +:fall-threshold: Sets the fall threshold beyond which is considered unsafe. (default: 1) diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 9038d5d79..c3cd88479 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -100,6 +100,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(changeitem changeitem.cpp) dfhack_plugin(changelayer changelayer.cpp) dfhack_plugin(changevein changevein.cpp) + add_subdirectory(channel-safely) dfhack_plugin(cleanconst cleanconst.cpp) dfhack_plugin(cleaners cleaners.cpp) dfhack_plugin(cleanowned cleanowned.cpp) diff --git a/plugins/channel-safely/CMakeLists.txt b/plugins/channel-safely/CMakeLists.txt new file mode 100644 index 000000000..d660d2262 --- /dev/null +++ b/plugins/channel-safely/CMakeLists.txt @@ -0,0 +1,10 @@ +project(channel-safely) + +include_directories(include) +SET(SOURCES + channel-jobs.cpp + channel-groups.cpp + channel-manager.cpp + channel-safely-plugin.cpp) + +dfhack_plugin(${PROJECT_NAME} ${SOURCES} LINK_LIBRARIES lua) diff --git a/plugins/channel-safely/channel-groups.cpp b/plugins/channel-safely/channel-groups.cpp new file mode 100644 index 000000000..7ee779df8 --- /dev/null +++ b/plugins/channel-safely/channel-groups.cpp @@ -0,0 +1,253 @@ +#include +#include +#include +#include + +#include + +// scans the map for channel designations +void ChannelGroups::scan_map() { + static std::default_random_engine RNG(0); + static std::bernoulli_distribution optimizing(0.3333); + DEBUG(groups).print(" scan_map()\n"); + // foreach block + for (int32_t z = mapz - 1; z >= 0; --z) { + for (int32_t by = 0; by < mapy; ++by) { + for (int32_t bx = 0; bx < mapx; ++bx) { + // the block + if (df::map_block* block = Maps::getBlock(bx, by, z)) { + // skip this block? + if (!block->flags.bits.designated && optimizing(RNG)) { + // todo: add remainder of block width onto bx + TRACE(groups).print(" skipping this block, it has no designations\n"); + continue; + } + // foreach tile + for (int16_t lx = 0; lx < 16; ++lx) { + for (int16_t ly = 0; ly < 16; ++ly) { + // the tile, check if it has a channel designation + if (is_dig_designation(block->designation[lx][ly])) { + for (df::block_square_event* event: block->block_events) { + if (auto evT = virtual_cast(event)) { + // we want to let the user keep some designations free of being managed + TRACE(groups).print(" tile designation priority: %d\n", evT->priority[lx][ly]); + if (evT->priority[lx][ly] < 1000 * config.ignore_threshold) { + df::coord map_pos((bx * 16) + lx, (by * 16) + ly, z); + TRACE(groups).print(" adding (" COORD ")\n", COORDARGS(map_pos)); + add(map_pos); + } + } + } + } + } + } + } + } + } + } + INFO(groups).print("scan_map() exits\n"); +} + +// scans a single tile for channel designations +void ChannelGroups::scan_one(const df::coord &map_pos) { + df::map_block* block = Maps::getTileBlock(map_pos); + int16_t lx = map_pos.x % 16; + int16_t ly = map_pos.y % 16; + if (is_dig_designation(block->designation[lx][ly])) { + for (df::block_square_event* event: block->block_events) { + if (auto evT = virtual_cast(event)) { + // we want to let the user keep some designations free of being managed + if (evT->priority[lx][ly] < 1000 * config.ignore_threshold) { + TRACE(groups).print(" adding (" COORD ")\n", COORDARGS(map_pos)); + add(map_pos); + } + } + } + } +} + +// adds map_pos to a group if an adjacent one exists, or creates one if none exist... if multiple exist they're merged into the first found +void ChannelGroups::add(const df::coord &map_pos) { + // if we've already added this, we don't need to do it again + if (groups_map.count(map_pos)) { + return; + } + /* We need to add map_pos to an existing group if possible... + * So what we do is we look at neighbours to see if they belong to one or more existing groups + * If there is more than one group, we'll be merging them + */ + df::coord neighbors[8]; + get_neighbours(map_pos, neighbors); + Group* group = nullptr; + int group_index = -1; + + DEBUG(groups).print(" add(" COORD ")\n", COORDARGS(map_pos)); + // and so we begin iterating the neighbours + for (auto &neighbour: neighbors) { + // go to the next neighbour if this one doesn't have a group + if (!groups_map.count(neighbour)) { + TRACE(groups).print(" -> neighbour is not designated\n"); + continue; + } + // get the group, since at least one exists... then merge any additional into that one + if (!group){ + TRACE(groups).print(" -> group* has no valid state yet\n"); + group_index = groups_map.find(neighbour)->second; + group = &groups.at(group_index); + } else { + TRACE(groups).print(" -> group* has an existing state\n"); + + // we don't do anything if the found group is the same as the existing group + auto index2 = groups_map.find(neighbour)->second; + if (group_index != index2) { + // we already have group "prime" if you will, so we're going to merge the new find into prime + Group &group2 = groups.at(index2); + // merge + TRACE(groups).print(" -> merging two groups. group 1 size: %zu. group 2 size: %zu\n", group->size(), + group2.size()); + for (auto pos2: group2) { + group->emplace(pos2); + groups_map[pos2] = group_index; + } + group2.clear(); + free_spots.emplace(index2); + TRACE(groups).print(" merged size: %zu\n", group->size()); + } + } + } + // if we haven't found at least one group by now we need to create/get one + if (!group) { + TRACE(groups).print(" -> no merging took place\n"); + // first we check if we can re-use a group that's been freed + if (!free_spots.empty()) { + TRACE(groups).print(" -> use recycled old group\n"); + // first element in a set is always the lowest value, so we re-use from the front of the vector + group_index = *free_spots.begin(); + group = &groups[group_index]; + free_spots.erase(free_spots.begin()); + } else { + TRACE(groups).print(" -> brand new group\n"); + // we create a brand-new group to use + group_index = groups.size(); + groups.push_back(Group()); + group = &groups[group_index]; + } + } + // puts the "add" in "ChannelGroups::add" + group->emplace(map_pos); + DEBUG(groups).print(" = group[%d] of (" COORD ") is size: %zu\n", group_index, COORDARGS(map_pos), group->size()); +// ERR(groups).print("\n\n\nDEBUG MAPPINGS:\n"); +// debug_map(); +// DEBUG(groups).flush(); + + // we may have performed a merge, so we update all the `coord -> group index` mappings + for (auto &wpos: *group) { + groups_map[wpos] = group_index; + } + DEBUG(groups).print(" <- add() exits, there are %zu mappings\n", groups_map.size()); +// ERR(groups).print("\n\n\nDEBUG MAPPINGS:\n"); +// debug_map(); +// DEBUG(groups).flush(); +} + +// builds groupings of adjacent channel designations +void ChannelGroups::build() { + clear(); + // iterate over each job, finding channel jobs + jobs.load_channel_jobs(); + // transpose channel jobs to + for (auto &pos : jobs) { + add(pos); + } + scan_map(); +} + +// clears out the containers for unloading maps or disabling the plugin +void ChannelGroups::clear() { + debug_map(); + WARN(groups).print(" <- clearing groups\n"); + free_spots.clear(); + groups_map.clear(); + for(size_t i = 0; i < groups.size(); ++i) { + groups[i].clear(); + free_spots.emplace(i); + } +} + +// erases map_pos from its group, and deletes mappings IFF the group is empty +void ChannelGroups::remove(const df::coord &map_pos) { + // we don't need to do anything if the position isn't in a group (granted, that should never be the case) + INFO(groups).print(" remove()\n"); + if (groups_map.count(map_pos)) { + INFO(groups).print(" -> found group\n"); + // get the group, and map_pos' block* + int group_index = groups_map.find(map_pos)->second; + Group &group = groups[group_index]; + // erase map_pos from the group + INFO(groups).print(" -> erase(" COORD ")\n", COORDARGS(map_pos)); + group.erase(map_pos); + groups_map.erase(map_pos); + // clean up if the group is empty + if (group.empty()) { + WARN(groups).print(" -> group is empty\n"); + // erase `coord -> group group_index` mappings + for (auto iter = groups_map.begin(); iter != groups_map.end();) { + if (group_index == iter->second) { + iter = groups_map.erase(iter); + continue; + } + ++iter; + } + // flag the `groups` group_index as available + free_spots.insert(group_index); + } + } + INFO(groups).print(" remove() exits\n"); +} + +// finds a group corresponding to a map position if one exists +Groups::const_iterator ChannelGroups::find(const df::coord &map_pos) const { + const auto iter = groups_map.find(map_pos); + if (iter != groups_map.end()) { + return groups.begin() + iter->second; + } + return groups.end(); +} + +// returns an iterator to the first element stored +Groups::const_iterator ChannelGroups::begin() const { + return groups.begin(); +} + +// returns an iterator to after the last element stored +Groups::const_iterator ChannelGroups::end() const { + return groups.end(); +} + +// returns a count of 0 or 1 depending on whether map_pos is mapped to a group +size_t ChannelGroups::count(const df::coord &map_pos) const { + return groups_map.count(map_pos); +} + +// prints debug info about the groups stored, and their members +void ChannelGroups::debug_groups() { +// int idx = 0; +// TRACE(groups).print(" debugging group data\n"); +// for (auto &group : groups) { +// TRACE(groups).print(" group %d (size: %zu)\n", idx, group.size()); +// for (auto &pos : group) { +// TRACE(groups).print(" (%d,%d,%d)\n", pos.x, pos.y, pos.z); +// } +// idx++; +// } +} + +// prints debug info group mappings +void ChannelGroups::debug_map() { +// INFO(groups).print("Group Mappings: %zu\n", groups_map.size()); +// for (auto &pair : groups_map) { +// DEBUG(groups).print(" map[" COORD "] = %d\n",COORDARGS(pair.first), pair.second); +// } +} + + diff --git a/plugins/channel-safely/channel-jobs.cpp b/plugins/channel-safely/channel-jobs.cpp new file mode 100644 index 000000000..7a1c2f4be --- /dev/null +++ b/plugins/channel-safely/channel-jobs.cpp @@ -0,0 +1,46 @@ +#include +#include +#include +#include + +// iterates the DF job list and adds channel jobs to the `jobs` container +void ChannelJobs::load_channel_jobs() { + jobs.clear(); + df::job_list_link* node = df::global::world->jobs.list.next; + while (node) { + df::job* job = node->item; + node = node->next; + if (is_dig_job(job)) { + jobs.emplace(job->pos); + } + } +} + +// clears the container +void ChannelJobs::clear() { + jobs.clear(); +} + +// finds and erases a job corresponding to a map position, then returns the iterator following the element removed +std::set::iterator ChannelJobs::erase(const df::coord &map_pos) { + auto iter = jobs.find(map_pos); + if (iter != jobs.end()) { + return jobs.erase(iter); + } + return iter; +} + +// finds a job corresponding to a map position if one exists +std::set::const_iterator ChannelJobs::find(const df::coord &map_pos) const { + return jobs.find(map_pos); +} + +// returns an iterator to the first element stored +std::set::const_iterator ChannelJobs::begin() const { + return jobs.begin(); +} + +// returns an iterator to after the last element stored +std::set::const_iterator ChannelJobs::end() const { + return jobs.end(); +} diff --git a/plugins/channel-safely/channel-manager.cpp b/plugins/channel-safely/channel-manager.cpp new file mode 100644 index 000000000..79bc6a7aa --- /dev/null +++ b/plugins/channel-safely/channel-manager.cpp @@ -0,0 +1,99 @@ +#include +#include + +#include + +/** +blocks[48][96][135]: +blocks[48][96][135].default_liquid.hidden: false +blocks[48][96][135].designation[10][0].hidden: false + * */ + +// sets mark flags as necessary, for all designations +void ChannelManager::manage_all() { + INFO(manager).print("manage_all()\n"); + // make sure we've got a fort map to analyze + if (World::isFortressMode() && Maps::IsValid()) { + // iterate the groups we built/updated + for (const auto &group: groups) { + manage_group(group, true, has_any_groups_above(groups, group)); + } + } +} + +void ChannelManager::manage_group(const df::coord &map_pos, bool set_marker_mode, bool marker_mode) { + INFO(manager).print("manage_group(" COORD ")\n ", COORDARGS(map_pos)); + if (!groups.count(map_pos)) { + groups.scan_one(map_pos); + } + auto iter = groups.find(map_pos); + if (iter != groups.end()) { + manage_group(*iter, set_marker_mode, marker_mode); + } + INFO(manager).print("manage_group() is done\n"); +} + +void ChannelManager::manage_group(const Group &group, bool set_marker_mode, bool marker_mode) { + INFO(manager).print("manage_group()\n"); + if (!set_marker_mode) { + if (has_any_groups_above(groups, group)) { + marker_mode = true; + } else { + marker_mode = false; + } + } + for (auto &designation: group) { + manage_one(group, designation, true, marker_mode); + } + INFO(manager).print("manage_group() is done\n"); +} + +bool ChannelManager::manage_one(const Group &group, const df::coord &map_pos, bool set_marker_mode, bool marker_mode) { + if (Maps::isValidTilePos(map_pos)) { + INFO(manager).print("manage_one(" COORD ")\n", COORDARGS(map_pos)); + df::map_block* block = Maps::getTileBlock(map_pos); + // we calculate the position inside the block* + df::coord local(map_pos); + local.x = local.x % 16; + local.y = local.y % 16; + df::tile_occupancy &tile_occupancy = block->occupancy[Coord(local)]; + // ensure that we aren't on the top-most layers + if (map_pos.z < mapz - 3) { + // do we already know whether to set marker mode? + if (set_marker_mode) { + DEBUG(manager).print(" -> marker_mode\n"); + tile_occupancy.bits.dig_marked = marker_mode; + jobs.erase(map_pos); + return true; + } else { + // next search for the designation priority + for (df::block_square_event* event: block->block_events) { + if (auto evT = virtual_cast(event)) { + // we want to let the user keep some designations free of being managed + if (evT->priority[Coord(local)] < 1000 * config.ignore_threshold) { + DEBUG(manager).print(" if(has_groups_above())\n"); + // check that the group has no incomplete groups directly above it + if (has_group_above(groups, map_pos)) { + DEBUG(manager).print(" has_groups_above: setting marker mode\n"); + tile_occupancy.bits.dig_marked = true; + jobs.erase(map_pos); + WARN(manager).print(" <- manage_one() exits normally\n"); + return true; + } + } + } + } + } + } else { + // if we are though, it should be totally safe to dig + tile_occupancy.bits.dig_marked = false; + } + WARN(manager).print(" <- manage_one() exits normally\n"); + } + return false; +} + +void ChannelManager::mark_done(const df::coord &map_pos) { + groups.remove(map_pos); + jobs.erase(map_pos); +} diff --git a/plugins/channel-safely/channel-safely-plugin.cpp b/plugins/channel-safely/channel-safely-plugin.cpp new file mode 100644 index 000000000..e6cdf231b --- /dev/null +++ b/plugins/channel-safely/channel-safely-plugin.cpp @@ -0,0 +1,509 @@ +/* Prevent channeling down into known open space. +Author: Josh Cooper +Created: Aug. 4 2020 +Updated: Nov. 1 2022 + + Enable plugin: + -> build groups + -> manage designations + + Unpause event: + -> build groups + -> manage designations + + Manage Designation(s): + -> for each group in groups: + -> for each designation in this group: + -> + + + Job started event: + -> validate job type (channel) + -> check pathing: + -> Can: add job/worker to tracking + -> Can: set tile to restricted + -> Cannot: remove worker + -> Cannot: insta-dig & delete job + -> Cannot: set designation to Marker Mode (no insta-digging) + + OnUpdate: + -> check worker location: + -> CanFall: check if a fall would be safe: + -> Safe: do nothing + -> Unsafe: remove worker + -> Unsafe: insta-dig & delete job (presumes the job is only accessible from directly on the tile) + -> Unsafe: set designation to Marker Mode (no insta-digging) + -> check tile occupancy: + -> HasUnit: check if a fall would be safe: + -> Safe: do nothing, let them fall + -> Unsafe: remove worker for 1 tick (test if this "pauses" or cancels the job) + -> Unsafe: Add feature to teleport unit? + + Job completed event: + -> validate job type (channel) + -> verify completion: + -> IsOpenSpace: mark done + -> IsOpenSpace: manage tile below + -> NotOpenSpace: check for designation + -> HasDesignation: do nothing + -> NoDesignation: mark done (erases from group) + -> NoDesignation: manage tile below +*/ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Debugging +namespace DFHack { + DBG_DECLARE(channelsafely, monitor, DebugCategory::LINFO); + DBG_DECLARE(channelsafely, manager, DebugCategory::LINFO); + DBG_DECLARE(channelsafely, groups, DebugCategory::LINFO); + DBG_DECLARE(channelsafely, jobs, DebugCategory::LINFO); +} + +DFHACK_PLUGIN("channel-safely"); +DFHACK_PLUGIN_IS_ENABLED(enabled); +REQUIRE_GLOBAL(world); + +namespace EM = EventManager; +using namespace DFHack; +using namespace EM::EventType; + +int32_t mapx, mapy, mapz; +Configuration config; +PersistentDataItem pconfig; +const std::string CONFIG_KEY = std::string(plugin_name) + "/config"; +//std::unordered_set active_jobs; + +#include + +enum ConfigurationData { + MONITOR, + VISION, + INSTADIG, + IGNORE_THRESH, + FALL_THRESH, + REFRESH_RATE, + MONITOR_RATE +}; + +inline void saveConfig() { + if (pconfig.isValid()) { + pconfig.ival(MONITOR) = config.monitor_active; + pconfig.ival(VISION) = config.require_vision; + pconfig.ival(INSTADIG) = config.insta_dig; + pconfig.ival(REFRESH_RATE) = config.refresh_freq; + pconfig.ival(MONITOR_RATE) = config.monitor_freq; + pconfig.ival(IGNORE_THRESH) = config.ignore_threshold; + pconfig.ival(FALL_THRESH) = config.fall_threshold; + } +} + +// executes dig designations for the specified tile coordinates +inline bool dig_now(color_ostream &out, const df::coord &map_pos) { + auto L = Lua::Core::State; + Lua::StackUnwinder top(L); + + if (!lua_checkstack(L, 2) || + !Lua::PushModulePublic(out, L, "plugins.dig-now", "dig_now_tile")) + return false; + + Lua::Push(L, map_pos); + + if (!Lua::SafeCall(out, L, 1, 1)) + return false; + + return lua_toboolean(L, -1); + +} + +namespace CSP { + std::unordered_map active_workers; + std::unordered_map last_safe; + std::unordered_set dignow_queue; + + void UnpauseEvent(){ + INFO(monitor).print("UnpauseEvent()\n"); + ChannelManager::Get().build_groups(); + INFO(monitor).print("after building groups\n"); + ChannelManager::Get().debug(); + ChannelManager::Get().manage_all(); + INFO(monitor).print("UnpauseEvent() exits\n"); + ChannelManager::Get().debug(); + } + + void JobStartedEvent(color_ostream &out, void* p) { + if (config.monitor_active) { + if (enabled && World::isFortressMode() && Maps::IsValid()) { + INFO(monitor).print("JobStartedEvent()\n"); + auto job = (df::job*) p; + // validate job type + if (is_dig_job(job)) { + DEBUG(monitor).print(" valid channel job:\n"); + df::unit* worker = Job::getWorker(job); + // there is a valid worker (living citizen) on the job? right.. + if (worker && Units::isAlive(worker) && Units::isCitizen(worker)) { + DEBUG(monitor).print(" valid worker:\n"); + df::coord local(job->pos); + local.x = local.x % 16; + local.y = local.y % 16; + // check pathing exists to job + if (Maps::canWalkBetween(worker->pos, job->pos)) { + DEBUG(monitor).print(" can path from (" COORD ") to (" COORD ")\n", + COORDARGS(worker->pos), COORDARGS(job->pos)); + // track workers on jobs + active_workers.emplace(job->id, Units::findIndexById(Job::getWorker(job)->id)); + // set tile to restricted + TRACE(monitor).print(" setting job tile to restricted\n"); + Maps::getTileDesignation(job->pos)->bits.traffic = df::tile_traffic::Restricted; + } else { + DEBUG(monitor).print(" no path exists to job:\n"); + // if we can't get there, then we should remove the worker and cancel the job (restore tile designation) + Job::removeWorker(job); + cancel_job(job); + if (!config.insta_dig) { + TRACE(monitor).print(" setting marker mode for (" COORD ")\n", COORDARGS(job->pos)); + // set to marker mode + auto occupancy = Maps::getTileOccupancy(job->pos); + if (!occupancy) { + WARN(monitor).print(" Could not acquire tile occupancy*\n"); + return; + } + occupancy->bits.dig_marked = true; + // prevent algorithm from re-enabling designation + df::map_block* block = Maps::getTileBlock(job->pos); + if (!block) { + WARN(monitor).print(" Could not acquire block*\n"); + return; + } + for (auto &be: block->block_events) { ; + if (auto bsedp = virtual_cast(be)) { + TRACE(monitor).print(" re-setting priority\n"); + bsedp->priority[Coord(local)] = config.ignore_threshold * 1000 + 1; + } + } + } else { + TRACE(monitor).print(" deleting job, and queuing insta-dig)\n"); + // queue digging the job instantly + dignow_queue.emplace(job->pos); + } + } + } + } + INFO(monitor).print(" <- JobStartedEvent() exits normally\n"); + } + } + } + + void JobCompletedEvent(color_ostream &out, void* job_ptr) { + if (config.monitor_active) { + INFO(monitor).print("JobCompletedEvent()\n"); + if (enabled && World::isFortressMode() && Maps::IsValid()) { + auto job = (df::job*) job_ptr; + // we only care if the job is a channeling one + if (is_dig_job(job)) { + // untrack job/worker + active_workers.erase(job->id); + // check job outcome + df::coord local(job->pos); + auto block = Maps::getTileBlock(local); + local.x = local.x % 16; + local.y = local.y % 16; + // verify completion + if (isOpenTerrain(block->tiletype[local.x][local.y]) + || block->designation[local.x][local.y].bits.dig != df::enums::tile_dig_designation::Channel) { + // the job can be considered done + df::coord below(job->pos); + below.z--; + WARN(monitor).print(" -> Marking tile done and managing the group below.\n"); + // mark done and manage below + Maps::getTileDesignation(job->pos)->bits.traffic = df::tile_traffic::Normal; + ChannelManager::Get().mark_done(job->pos); + ChannelManager::Get().manage_group(below); + ChannelManager::Get().debug(); + Job::removeJob(job); + } + } + } + INFO(monitor).print("JobCompletedEvent() exits\n"); + } + } + + void OnUpdate(color_ostream &out) { + if (enabled && World::isFortressMode() && Maps::IsValid() && !World::ReadPauseState()) { + static int32_t last_monitor_tick = df::global::world->frame_counter; + static int32_t last_refresh_tick = df::global::world->frame_counter; + int32_t tick = df::global::world->frame_counter; + if (tick - last_refresh_tick >= config.refresh_freq) { + last_refresh_tick = tick; + TRACE(monitor).print("OnUpdate()\n"); + UnpauseEvent(); + } + if (config.monitor_active && tick - last_monitor_tick >= config.monitor_freq) { + last_monitor_tick = tick; + TRACE(monitor).print("OnUpdate()\n"); + for (df::job_list_link* link = &df::global::world->jobs.list; link != nullptr; link = link->next) { + df::job* job = link->item; + if (job) { + auto iter = active_workers.find(job->id); + TRACE(monitor).print(" -> check for job in tracking\n"); + if (iter != active_workers.end()) { + df::unit* unit = df::global::world->units.active[iter->second]; + TRACE(monitor).print(" -> compare positions of worker and job\n"); + // check if fall is possible + if (unit->pos == job->pos) { + // can fall, is safe? + TRACE(monitor).print(" equal -> check if safe fall\n"); + if (!is_safe_fall(job->pos)) { + // unsafe + Job::removeWorker(job); + if (config.insta_dig) { + TRACE(monitor).print(" -> insta-dig\n"); + // delete the job + Job::removeJob(job); + // queue digging the job instantly + dignow_queue.emplace(job->pos); + // worker is currently in the air + Units::teleport(unit, last_safe[unit->id]); + last_safe.erase(unit->id); + } else { + TRACE(monitor).print(" -> set marker mode\n"); + // set to marker mode + Maps::getTileOccupancy(job->pos)->bits.dig_marked = true; + // prevent algorithm from re-enabling designation + for (auto &be: Maps::getBlock(job->pos)->block_events) { ; + if (auto bsedp = virtual_cast( + be)) { + df::coord local(job->pos); + local.x = local.x % 16; + local.y = local.y % 16; + bsedp->priority[Coord(local)] = config.ignore_threshold * 1000 + 1; + break; + } + } + } + } + } else { + TRACE(monitor).print(" -> save safe position\n"); + // worker is perfectly safe right now + last_safe[unit->id] = unit->pos; + } + } + } + } + TRACE(monitor).print(" -> evaluate dignow queue\n"); + for (const df::coord &pos: dignow_queue) { + if (!has_unit(Maps::getTileOccupancy(pos))) { + dig_now(out, pos); + } else { + // todo: teleport? + //Units::teleport() + } + } + TRACE(monitor).print("OnUpdate() exits\n"); + } + } + } +} + +command_result channel_safely(color_ostream &out, std::vector ¶meters); + +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { + commands.push_back(PluginCommand("channel-safely", + "Automatically manage channel designations.", + channel_safely, + false)); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(manager).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LERROR); + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &out) { + EM::unregisterAll(plugin_self); + return CR_OK; +} + +DFhackCExport command_result plugin_load_data (color_ostream &out) { + pconfig = World::GetPersistentData(CONFIG_KEY); + + if (!pconfig.isValid()) { + pconfig = World::AddPersistentData(CONFIG_KEY); + saveConfig(); + } else { + config.monitor_active = pconfig.ival(MONITOR); + config.require_vision = pconfig.ival(VISION); + config.insta_dig = pconfig.ival(INSTADIG); + config.refresh_freq = pconfig.ival(REFRESH_RATE); + config.monitor_freq = pconfig.ival(MONITOR_RATE); + config.ignore_threshold = pconfig.ival(IGNORE_THRESH); + config.fall_threshold = pconfig.ival(FALL_THRESH); + } + return DFHack::CR_OK; +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (enable && !enabled) { + // register events to check jobs / update tracking + EM::EventHandler jobStartHandler(CSP::JobStartedEvent, 0); + EM::EventHandler jobCompletionHandler(CSP::JobCompletedEvent, 0); + EM::registerListener(EventType::JOB_STARTED, jobStartHandler, plugin_self); + EM::registerListener(EventType::JOB_COMPLETED, jobCompletionHandler, plugin_self); + // manage designations to start off (first time building groups [very important]) + out.print("channel-safely: enabled!\n"); + CSP::UnpauseEvent(); + } else if (!enable) { + // don't need the groups if the plugin isn't going to be enabled + EM::unregisterAll(plugin_self); + out.print("channel-safely: disabled!\n"); + } + enabled = enable; + return CR_OK; +} + +DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { + if (enabled && World::isFortressMode() && Maps::IsValid()) { + switch (event) { + case SC_MAP_LOADED: + // cache the map size + Maps::getSize(mapx, mapy, mapz); + case SC_UNPAUSED: + // manage all designations on unpause + CSP::UnpauseEvent(); + default: + break; + } + } + return CR_OK; +} + +DFhackCExport command_result plugin_onupdate(color_ostream &out, state_change_event event) { + CSP::OnUpdate(out); + return DFHack::CR_OK; +} + +command_result channel_safely(color_ostream &out, std::vector ¶meters) { + if (!parameters.empty()) { + if (parameters.size() >= 2 && parameters.size() <= 3) { + if (parameters[0] == "run" && parameters[1] == "once") { + CSP::UnpauseEvent(); + return DFHack::CR_OK; + } + bool state = false; + bool set = false; + if (parameters[0] == "enable") { + state = true; + } else if (parameters[0] == "disable") { + state = false; + } else if (parameters[0] == "set") { + set = true; + } else { + return DFHack::CR_WRONG_USAGE; + } + try { + if (parameters[1] == "debug") { + auto level = std::abs(std::stol(parameters[2])); + config.debug = true; + switch (level) { + case 1: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LDEBUG); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LINFO); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LINFO); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LINFO); + break; + case 2: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LINFO); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LDEBUG); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LDEBUG); + break; + case 3: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LINFO); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LDEBUG); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LTRACE); + break; + case 4: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LINFO); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LTRACE); + break; + case 5: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LDEBUG); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LTRACE); + break; + case 6: + DBG_NAME(manager).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LTRACE); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LTRACE); + break; + case 0: + default: + DBG_NAME(monitor).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(manager).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(groups).allowed(DFHack::DebugCategory::LERROR); + DBG_NAME(jobs).allowed(DFHack::DebugCategory::LERROR); + } + } else if(parameters[1] == "monitor-active"){ + config.monitor_active = state; + } else if (parameters[1] == "require-vision") { + config.require_vision = state; + } else if (parameters[1] == "insta-dig") { + config.insta_dig = state; + } else if (parameters[1] == "refresh-freq" && set && parameters.size() == 3) { + config.refresh_freq = std::abs(std::stol(parameters[2])); + } else if (parameters[1] == "monitor-freq" && set && parameters.size() == 3) { + config.monitor_freq = std::abs(std::stol(parameters[2])); + } else if (parameters[1] == "ignore-threshold" && set && parameters.size() == 3) { + config.ignore_threshold = std::abs(std::stol(parameters[2])); + } else if (parameters[1] == "fall-threshold" && set && parameters.size() == 3) { + uint8_t t = std::abs(std::stol(parameters[2])); + if (t > 0) { + config.fall_threshold = t; + } else { + out.printerr("fall-threshold must have a value greater than 0 or the plugin does a lot of nothing.\n"); + return DFHack::CR_FAILURE; + } + } else { + return DFHack::CR_WRONG_USAGE; + } + } catch (const std::exception &e) { + out.printerr("%s\n", e.what()); + return DFHack::CR_FAILURE; + } + } + } else { + out.print("Channel-Safely is %s\n", enabled ? "ENABLED." : "DISABLED."); + out.print("monitor-active: %s\n", config.monitor_active ? "on." : "off."); + out.print("require-vision: %s\n", config.require_vision ? "on." : "off."); + out.print("insta-dig: %s\n", config.insta_dig ? "on." : "off."); + out.print("refresh-freq: %" PRIi32 "\n", config.refresh_freq); + out.print("monitor-freq: %" PRIi32 "\n", config.monitor_freq); + out.print("ignore-threshold: %" PRIu8 "\n", config.ignore_threshold); + out.print("fall-threshold: %" PRIu8 "\n", config.fall_threshold); + } + saveConfig(); + return DFHack::CR_OK; +} + + diff --git a/plugins/channel-safely/include/channel-groups.h b/plugins/channel-safely/include/channel-groups.h new file mode 100644 index 000000000..d39780df3 --- /dev/null +++ b/plugins/channel-safely/include/channel-groups.h @@ -0,0 +1,49 @@ +#pragma once +#include "plugin.h" +#include "channel-jobs.h" + +#include +#include + +#include +#include +#include + +using namespace DFHack; + +using Group = std::set; +using Groups = std::vector; + +/* Used to build groups of adjacent channel designations/jobs + * groups_map: maps coordinates to a group index in `groups` + * groups: list of Groups + * Group: used to track designations which are connected through adjacency to one another (a group cannot span Z) + * Note: a designation plan may become unsafe if the jobs aren't completed in a specific order; + * the easiest way to programmatically ensure safety is to.. + * lock overlapping groups directly adjacent across Z until the above groups are complete, or no longer overlap + * groups may no longer overlap if the adjacent designations are completed, but requires a rebuild of groups + * jobs: list of coordinates with channel jobs associated to them + */ +class ChannelGroups { +private: + using GroupsMap = std::map; + GroupsMap groups_map; + Groups groups; + ChannelJobs &jobs; + std::set free_spots; +protected: + void scan_map(); + void add(const df::coord &map_pos); +public: + explicit ChannelGroups(ChannelJobs &jobs) : jobs(jobs) { groups.reserve(200); } + void scan_one(const df::coord &map_pos); + void build(); + void clear(); + void remove(const df::coord &map_pos); + Groups::const_iterator find(const df::coord &map_pos) const; + Groups::const_iterator begin() const; + Groups::const_iterator end() const; + size_t count(const df::coord &map_pos) const; + void debug_groups(); + void debug_map(); +}; diff --git a/plugins/channel-safely/include/channel-jobs.h b/plugins/channel-safely/include/channel-jobs.h new file mode 100644 index 000000000..d0aaead7c --- /dev/null +++ b/plugins/channel-safely/include/channel-jobs.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include + +using namespace DFHack; + +/* Used to read/store/iterate channel digging jobs + * jobs: list of coordinates with channel jobs associated to them + * load_channel_jobs: iterates world->jobs.list to find channel jobs and adds them into the `jobs` map + * clear: empties the container + * erase: finds a job corresponding to a coord, removes the mapping in jobs, and calls Job::removeJob, then returns an iterator following the element removed + * find: returns an iterator to a job if one exists for a map coordinate + * begin: returns jobs.begin() + * end: returns jobs.end() + */ +class ChannelJobs { +private: + friend class ChannelGroup; + using Jobs = std::set; // job* will exist until it is complete, and likely beyond + Jobs jobs; +public: + void load_channel_jobs(); + void clear(); + Jobs::iterator erase(const df::coord &map_pos); + Jobs::const_iterator find(const df::coord &map_pos) const; + Jobs::const_iterator begin() const; + Jobs::const_iterator end() const; +}; diff --git a/plugins/channel-safely/include/channel-manager.h b/plugins/channel-safely/include/channel-manager.h new file mode 100644 index 000000000..2e33c5c46 --- /dev/null +++ b/plugins/channel-safely/include/channel-manager.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include +#include "channel-groups.h" +#include "plugin.h" + +using namespace DFHack; + +// Uses GroupData to detect an unsafe work environment +class ChannelManager { +private: + ChannelJobs jobs; + ChannelManager()= default; +protected: +public: + ChannelGroups groups = ChannelGroups(jobs); + + static ChannelManager& Get(){ + static ChannelManager instance; + return instance; + } + + void build_groups() { groups.build(); debug(); } + void manage_all(); + void manage_group(const df::coord &map_pos, bool set_marker_mode = false, bool marker_mode = false); + void manage_group(const Group &group, bool set_marker_mode = false, bool marker_mode = false); + bool manage_one(const Group &group, const df::coord &map_pos, bool set_marker_mode = false, bool marker_mode = false); + void mark_done(const df::coord &map_pos); + void debug() { + if (config.debug) { + groups.debug_groups(); + groups.debug_map(); + //std::terminate(); + } + } +}; diff --git a/plugins/channel-safely/include/inlines.h b/plugins/channel-safely/include/inlines.h new file mode 100644 index 000000000..72042327a --- /dev/null +++ b/plugins/channel-safely/include/inlines.h @@ -0,0 +1,149 @@ +#pragma once +#include "plugin.h" +#include "channel-manager.h" + +#include +#include +#include + +#include +#include + +#define Coord(id) id.x][id.y +#define COORD "%" PRIi16 " %" PRIi16 " %" PRIi16 +#define COORDARGS(id) id.x, id.y, id.z + +namespace CSP { + extern std::unordered_set dignow_queue; +} + +inline bool is_dig_job(const df::job* job) { + return job->job_type == df::job_type::Dig || job->job_type == df::job_type::DigChannel; +} + +inline bool is_dig_designation(const df::tile_designation &designation) { + return designation.bits.dig != df::tile_dig_designation::No; +} + +inline bool has_unit(const df::tile_occupancy* occupancy) { + return occupancy->bits.unit || occupancy->bits.unit_grounded; +} + +inline bool is_safe_fall(const df::coord &map_pos) { + df::coord below(map_pos); + for (uint8_t zi = 0; zi < config.fall_threshold; ++zi) { + below.z--; + if (config.require_vision && Maps::getTileDesignation(below)->bits.hidden) { + return true; //we require vision, and we can't see below.. so we gotta assume it's safe + } + df::tiletype type = *Maps::getTileType(below); + if (!isOpenTerrain(type)) { + return true; + } + } + return false; +} + +inline bool is_safe_to_dig_down(const df::coord &map_pos) { + df::coord pos(map_pos); + + for (uint8_t zi = 0; zi <= config.fall_threshold; ++zi) { + // assume safe if we can't see and need vision + if (config.require_vision && Maps::getTileDesignation(pos)->bits.hidden) { + return true; + } + df::tiletype type = *Maps::getTileType(pos); + if (zi == 0 && isOpenTerrain(type)) { + // the starting tile is open space, that's obviously not safe + return false; + } else if (!isOpenTerrain(type)) { + // a tile after the first one is not open space + return true; + } + pos.z--; + } + return false; +} + +inline bool is_group_occupied(const ChannelGroups &groups, const Group &group) { + // return true if any tile in the group is occupied by a unit + return std::any_of(group.begin(), group.end(), [](const Group::key_type &pos){ + return has_unit(Maps::getTileOccupancy(pos)); + }); +} + +inline bool has_group_above(const ChannelGroups &groups, const df::coord &map_pos) { + df::coord above(map_pos); + above.z++; + if (groups.count(above)) { + return true; + } + return false; +} + +inline bool has_any_groups_above(const ChannelGroups &groups, const Group &group) { + // for each designation in the group + for (auto &pos : group) { + df::coord above(pos); + above.z++; + if (groups.count(above)) { + return true; + } + } + // if there are no incomplete groups above this group, then this group is ready + return false; +} + +inline void cancel_job(df::job* job) { + if (job != nullptr) { + df::coord &pos = job->pos; + df::map_block* job_block = Maps::getTileBlock(pos); + uint16_t x, y; + x = pos.x % 16; + y = pos.y % 16; + df::tile_designation &designation = job_block->designation[x][y]; + switch (job->job_type) { + case job_type::Dig: + designation.bits.dig = df::tile_dig_designation::Default; + break; + case job_type::CarveUpwardStaircase: + designation.bits.dig = df::tile_dig_designation::UpStair; + break; + case job_type::CarveDownwardStaircase: + designation.bits.dig = df::tile_dig_designation::DownStair; + break; + case job_type::CarveUpDownStaircase: + designation.bits.dig = df::tile_dig_designation::UpDownStair; + break; + case job_type::CarveRamp: + designation.bits.dig = df::tile_dig_designation::Ramp; + break; + case job_type::DigChannel: + designation.bits.dig = df::tile_dig_designation::Channel; + break; + default: + designation.bits.dig = df::tile_dig_designation::No; + break; + } + Job::removeJob(job); + } +} + +inline void get_neighbours(const df::coord &map_pos, df::coord(&neighbours)[8]) { + neighbours[0] = map_pos; + neighbours[1] = map_pos; + neighbours[2] = map_pos; + neighbours[3] = map_pos; + neighbours[4] = map_pos; + neighbours[5] = map_pos; + neighbours[6] = map_pos; + neighbours[7] = map_pos; + neighbours[0].x--; neighbours[0].y--; + neighbours[1].y--; + neighbours[2].x++; neighbours[2].y--; + neighbours[3].x--; + neighbours[4].x++; + neighbours[5].x--; neighbours[5].y++; + neighbours[6].y++; + neighbours[7].x++; neighbours[7].y++; +} diff --git a/plugins/channel-safely/include/plugin.h b/plugins/channel-safely/include/plugin.h new file mode 100644 index 000000000..71e4665c7 --- /dev/null +++ b/plugins/channel-safely/include/plugin.h @@ -0,0 +1,23 @@ +#pragma once +#include + +namespace DFHack { + DBG_EXTERN(channelsafely, monitor); + DBG_EXTERN(channelsafely, manager); + DBG_EXTERN(channelsafely, groups); + DBG_EXTERN(channelsafely, jobs); +} + +struct Configuration { + bool debug = false; + bool monitor_active = false; + bool require_vision = true; + bool insta_dig = false; + int32_t refresh_freq = 600; + int32_t monitor_freq = 10; + uint8_t ignore_threshold = 7; + uint8_t fall_threshold = 1; +}; + +extern Configuration config; +extern int32_t mapx, mapy, mapz;