diff --git a/docs/changelog.txt b/docs/changelog.txt index 536d21358..3ede58a70 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -58,9 +58,15 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: - UX: You can now drag the scrollbar to scroll to a specific spot - `overlay`: reduce the size of the "DFHack Launcher" button - Constructions module: ``findAtTile`` now uses a binary search intead of a linear search. +- `spectate`: new ``auto-unpause`` option for auto-dismissal of announcement pause events (e.g. sieges). +- `spectate`: new ``auto-disengage`` option for auto-disengagement of plugin through player interaction whilst unpaused. +- `spectate`: new ``focus-jobs`` option for following a dwarf after their job has finished (when disabled). +- `spectate`: new ``tick-threshold``, option for specifying the change interval (maximum follow time when focus-jobs is enabled) +- `spectate`: added persistent configuration of the plugin settings - `gui/cp437-table`: new global keybinding for the clickable on-screen keyboard for players with keyboard layouts that prevent them from using certain keys: Ctrl-Shift-K ## Documentation +- `spectate`: improved documentation of features and functionality ## API - Constructions module: added ``insert()`` to insert constructions into the game's sorted list. diff --git a/docs/plugins/spectate.rst b/docs/plugins/spectate.rst index d9e9d3929..1ca3efeb8 100644 --- a/docs/plugins/spectate.rst +++ b/docs/plugins/spectate.rst @@ -2,9 +2,8 @@ spectate ======== .. dfhack-tool:: - :summary: Automatically follow exciting dwarves. + :summary: Automatically follow productive dwarves. :tags: fort interface - :no-command: Usage ----- @@ -12,6 +11,38 @@ Usage :: enable spectate + spectate set + spectate enable|disable -The plugin will automatically switch which dwarf is being followed periodically, -preferring dwarves on z-levels with the highest job activity. + +When enabled, the plugin will automatically switch which dwarf is being +followed periodically, preferring dwarves on z-levels with the highest +job activity. + +Changes to plugin settings will be saved per world. Whether the plugin itself +is enabled or not is not saved. + +Examples +-------- + +``spectate`` + The plugin reports its configured status. + +``spectate enable auto-unpause`` + Enable the spectate plugin to automatically dismiss pause events caused + by the game. Siege events are one example of such a game event. + +``spectate set tick-threshold 50`` + Set the tick interval the followed dwarf can be changed at back to its + default value. + +Features +-------- +:focus-jobs: Toggle whether the plugin should always be following a job. (default: disabled) +:auto-unpause: Toggle auto-dismissal of game pause events. (default: disabled) +:auto-disengage: Toggle auto-disengagement of plugin through player intervention while unpaused. (default: disabled) + +Settings +-------- +:tick-threshold: Set the plugin's tick interval for changing the followed dwarf. + Acts as a maximum follow time when used with focus-jobs enabled. (default: 50) diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index dc341b39d..c9438e04e 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -163,7 +163,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(siege-engine siege-engine.cpp LINK_LIBRARIES lua) dfhack_plugin(sort sort.cpp LINK_LIBRARIES lua) dfhack_plugin(steam-engine steam-engine.cpp) - dfhack_plugin(spectate spectate.cpp) + add_subdirectory(spectate) dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) add_subdirectory(stockpiles) dfhack_plugin(stocks stocks.cpp) diff --git a/plugins/spectate.cpp b/plugins/spectate.cpp deleted file mode 100644 index 1161c3f32..000000000 --- a/plugins/spectate.cpp +++ /dev/null @@ -1,147 +0,0 @@ -// -// Created by josh on 7/28/21. -// - -#include "Core.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -std::map freq; -std::set job_tracker; -std::default_random_engine RNG; - -//#include "df/world.h" - -using namespace DFHack; -using namespace df::enums; - -DFHACK_PLUGIN("spectate"); -DFHACK_PLUGIN_IS_ENABLED(enabled); -bool following_dwarf = false; -void* job_watched = nullptr; -int32_t timestamp = -1; -REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(ui); - -command_result spectate (color_ostream &out, std::vector & parameters); - -DFhackCExport command_result plugin_init (color_ostream &out, std::vector &commands) { - commands.push_back(PluginCommand("spectate", - "Automated spectator mode.", - spectate)); - return CR_OK; -} - -DFhackCExport command_result plugin_shutdown (color_ostream &out) { - return CR_OK; -} - -void onTick(color_ostream& out, void* tick); -void onJobStart(color_ostream &out, void* job); -void onJobCompletion(color_ostream &out, void* job); - -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { - namespace EM = EventManager; - if (enable && !enabled) { - out.print("Spectate mode enabled!\n"); - using namespace EM::EventType; - EM::EventHandler ticking(onTick, 15); - EM::EventHandler start(onJobStart, 0); - EM::EventHandler complete(onJobCompletion, 0); - EM::registerListener(EventType::TICK, ticking, plugin_self); - EM::registerListener(EventType::JOB_STARTED, start, plugin_self); - EM::registerListener(EventType::JOB_COMPLETED, complete, plugin_self); - } else if (!enable && enabled) { - out.print("Spectate mode disabled!\n"); - EM::unregisterAll(plugin_self); - job_tracker.clear(); - freq.clear(); - } - enabled = enable; - return CR_OK; -} - -command_result spectate (color_ostream &out, std::vector & parameters) { - return plugin_enable(out, !enabled); -} - -void onTick(color_ostream& out, void* ptr) { - int32_t tick = df::global::world->frame_counter; - // || seems to be redundant as the first always evaluates true when job_watched is nullptr.. todo: figure what is supposed to happen - if (!following_dwarf || (job_watched == nullptr && (tick - timestamp) > 50)) { - std::vector dwarves; - for (auto unit: df::global::world->units.active) { - if (!Units::isCitizen(unit)) { - continue; - } - dwarves.push_back(unit); - } - std::uniform_int_distribution follow_any(0, dwarves.size() - 1); - if (df::global::ui) { - df::unit* unit = dwarves[follow_any(RNG)]; - df::global::ui->follow_unit = unit->id; - job_watched = unit->job.current_job; - following_dwarf = true; - if (!job_watched) { - timestamp = tick; - } - } - } -} - -void onJobStart(color_ostream& out, void* job_ptr) { - int32_t tick = df::global::world->frame_counter; - auto job = (df::job*) job_ptr; - int zcount = ++freq[job->pos.z]; - job_tracker.emplace(job->id); - if (!following_dwarf || (job_watched == nullptr && (tick - timestamp) > 50)) { - following_dwarf = true; - double p = 0.99 * ((double) zcount / job_tracker.size()); - std::bernoulli_distribution follow_job(p); - if (!job->flags.bits.special && follow_job(RNG)) { - job_watched = job_ptr; - df::unit* unit = Job::getWorker(job); - if (df::global::ui && unit) { - df::global::ui->follow_unit = unit->id; - } - } else { - timestamp = tick; - std::vector nonworkers; - for (auto unit: df::global::world->units.active) { - if (!Units::isCitizen(unit) || unit->job.current_job) { - continue; - } - nonworkers.push_back(unit); - } - std::uniform_int_distribution<> follow_drunk(0, nonworkers.size() - 1); - if (df::global::ui) { - df::global::ui->follow_unit = nonworkers[follow_drunk(RNG)]->id; - } - } - } -} - -void onJobCompletion(color_ostream &out, void* job_ptr) { - auto job = (df::job*)job_ptr; - freq[job->pos.z]--; - freq[job->pos.z] = freq[job->pos.z] < 0 ? 0 : freq[job->pos.z]; - job_tracker.erase(job->id); - if (following_dwarf && job_ptr == job_watched) { - following_dwarf = false; - job_watched = nullptr; - } -} diff --git a/plugins/spectate/CMakeLists.txt b/plugins/spectate/CMakeLists.txt new file mode 100644 index 000000000..d2de072d8 --- /dev/null +++ b/plugins/spectate/CMakeLists.txt @@ -0,0 +1,8 @@ + +project(spectate) + +SET(SOURCES + spectate.cpp + pause.cpp) + +dfhack_plugin(${PROJECT_NAME} ${SOURCES}) diff --git a/plugins/spectate/pause.cpp b/plugins/spectate/pause.cpp new file mode 100644 index 000000000..5c60e2194 --- /dev/null +++ b/plugins/spectate/pause.cpp @@ -0,0 +1,184 @@ +#include "pause.h" +#include +#include +#include +#include +#include +#include + +#include + +using namespace DFHack; +using namespace Pausing; +using namespace df::enums; + +std::unordered_set PlayerLock::locks; +std::unordered_set AnnouncementLock::locks; + +namespace pausing { + AnnouncementLock announcementLock("monitor"); + PlayerLock playerLock("monitor"); + + const size_t announcement_flag_arr_size = sizeof(decltype(df::announcements::flags)) / sizeof(df::announcement_flags); + bool state_saved = false; // indicates whether a restore state is ok + bool saved_states[announcement_flag_arr_size]; // state to restore + bool locked_states[announcement_flag_arr_size]; // locked state (re-applied each frame) + bool allow_player_pause = true; // toggles player pause ability + + using df::global::ui; + using namespace df::enums; + struct player_pause_hook : df::viewscreen_dwarfmodest { + typedef df::viewscreen_dwarfmodest interpose_base; + DEFINE_VMETHOD_INTERPOSE(void, feed, (std::set* input)) { + if ((ui->main.mode == ui_sidebar_mode::Default) && !allow_player_pause) { + input->erase(interface_key::D_PAUSE); + } + INTERPOSE_NEXT(feed)(input); + } + }; + + IMPLEMENT_VMETHOD_INTERPOSE(player_pause_hook, feed); +} +using namespace pausing; + +template +inline bool any_lock(Locks locks) { + return std::any_of(locks.begin(), locks.end(), [](Lock* lock) { return lock->isLocked(); }); +} + +template +inline bool only_lock(Locks locks, LockT* this_lock) { + return std::all_of(locks.begin(), locks.end(), [&](Lock* lock) { + if (lock == this_lock) { + return lock->isLocked(); + } + return !lock->isLocked(); + }); +} + +template +inline bool only_or_none_locked(Locks locks, LockT* this_lock) { + for (auto &L: locks) { + if (L == this_lock) { + continue; + } + if (L->isLocked()) { + return false; + } + } + return true; +} + +template +inline bool reportLockedLocks(color_ostream &out, Locks locks) { + out.color(DFHack::COLOR_YELLOW); + for (auto &L: locks) { + if (L->isLocked()) { + out.print("Lock: '%s'\n", L->name.c_str()); + } + } + out.reset_color(); + return true; +} + +bool AnnouncementLock::captureState() { + if (only_or_none_locked(locks, this)) { + for (size_t i = 0; i < announcement_flag_arr_size; ++i) { + locked_states[i] = df::global::d_init->announcements.flags[i].bits.PAUSE; + } + return true; + } + return false; +} + +void AnnouncementLock::lock() { + Lock::lock(); + captureState(); +} + +bool AnnouncementLock::isAnyLocked() const { + return any_lock(locks); +} + +bool AnnouncementLock::isOnlyLocked() const { + return only_lock(locks, this); +} + +void AnnouncementLock::reportLocks(color_ostream &out) { + reportLockedLocks(out, locks); +} + +bool PlayerLock::isAnyLocked() const { + return any_lock(locks); +} + +bool PlayerLock::isOnlyLocked() const { + return only_lock(locks, this); +} + +void PlayerLock::reportLocks(color_ostream &out) { + reportLockedLocks(out, locks); +} + +bool World::DisableAnnouncementPausing() { + if (!announcementLock.isAnyLocked()) { + for (auto& flag : df::global::d_init->announcements.flags) { + flag.bits.PAUSE = false; + //out.print("pause: %d\n", flag.bits.PAUSE); + } + return true; + } + return false; +} + +bool World::SaveAnnouncementSettings() { + if (!announcementLock.isAnyLocked()) { + for (size_t i = 0; i < announcement_flag_arr_size; ++i) { + saved_states[i] = df::global::d_init->announcements.flags[i].bits.PAUSE; + } + state_saved = true; + return true; + } + return false; +} + +bool World::RestoreAnnouncementSettings() { + if (!announcementLock.isAnyLocked() && state_saved) { + for (size_t i = 0; i < announcement_flag_arr_size; ++i) { + df::global::d_init->announcements.flags[i].bits.PAUSE = saved_states[i]; + } + return true; + } + return false; +} + +bool World::EnablePlayerPausing() { + if (!playerLock.isAnyLocked()) { + allow_player_pause = true; + } + return allow_player_pause; +} + +bool World::DisablePlayerPausing() { + if (!playerLock.isAnyLocked()) { + allow_player_pause = false; + } + return !allow_player_pause; +} + +bool World::IsPlayerPausingEnabled() { + return allow_player_pause; +} + +void World::Update() { + static bool did_once = false; + if (!did_once) { + did_once = true; + INTERPOSE_HOOK(player_pause_hook, feed).apply(); + } + if (announcementLock.isAnyLocked()) { + for (size_t i = 0; i < announcement_flag_arr_size; ++i) { + df::global::d_init->announcements.flags[i].bits.PAUSE = locked_states[i]; + } + } +} diff --git a/plugins/spectate/pause.h b/plugins/spectate/pause.h new file mode 100644 index 000000000..ab736ed53 --- /dev/null +++ b/plugins/spectate/pause.h @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include + +namespace DFHack { + //////////// + // Locking mechanisms for control over pausing + namespace Pausing + { + class Lock + { + bool locked = false; + public: + const std::string name; + explicit Lock(const char* name) : name(name){} + virtual ~Lock()= default; + virtual bool isAnyLocked() const = 0; + virtual bool isOnlyLocked() const = 0; + bool isLocked() const { return locked; } + virtual void lock() { locked = true; } //simply locks the lock + void unlock() { locked = false; } + virtual void reportLocks(color_ostream &out) = 0; + }; + + // non-blocking lock resource used in conjunction with the announcement functions in World + class AnnouncementLock : public Lock + { + static std::unordered_set locks; + public: + explicit AnnouncementLock(const char* name): Lock(name) { locks.emplace(this); } + ~AnnouncementLock() override { locks.erase(this); } + bool captureState(); // captures the state of announcement settings, iff this is the only locked lock (note it does nothing if 0 locks are engaged) + void lock() override; // locks and attempts to capture state + bool isAnyLocked() const override; // returns true if any instance of AnnouncementLock is locked + bool isOnlyLocked() const override; // returns true if locked and no other instance is locked + void reportLocks(color_ostream &out) override; + }; + + // non-blocking lock resource used in conjunction with the Player pause functions in World + class PlayerLock : public Lock + { + static std::unordered_set locks; + public: + explicit PlayerLock(const char* name): Lock(name) { locks.emplace(this); } + ~PlayerLock() override { locks.erase(this); } + bool isAnyLocked() const override; // returns true if any instance of PlayerLock is locked + bool isOnlyLocked() const override; // returns true if locked and no other instance is locked + void reportLocks(color_ostream &out) override; + }; + + // non-blocking lock resource used in conjunction with the pause set state function in World +// todo: integrate with World::SetPauseState +// class PauseStateLock : public Lock +// { +// static std::unordered_set locks; +// public: +// explicit PauseStateLock(const char* name): Lock(name) { locks.emplace(this); } +// ~PauseStateLock() override { locks.erase(this); } +// bool isAnyLocked() const override; // returns true if any instance of PlayerLock is locked +// bool isOnlyLocked() const override; // returns true if locked and no other instance is locked +// void reportLocks(color_ostream &out) override; +// }; + } + namespace World { + bool DisableAnnouncementPausing(); // disable announcement pausing if all locks are open + bool SaveAnnouncementSettings(); // save current announcement pause settings if all locks are open + bool RestoreAnnouncementSettings(); // restore saved announcement pause settings if all locks are open and there is state information to restore (returns true if a restore took place) + + bool EnablePlayerPausing(); // enable player pausing if all locks are open + bool DisablePlayerPausing(); // disable player pausing if all locks are open + bool IsPlayerPausingEnabled(); // returns whether the player can pause or not + + void Update(); + } +} diff --git a/plugins/spectate/spectate.cpp b/plugins/spectate/spectate.cpp new file mode 100644 index 000000000..5f720806c --- /dev/null +++ b/plugins/spectate/spectate.cpp @@ -0,0 +1,366 @@ +// +// Created by josh on 7/28/21. +// + +#include "pause.h" + +#include "Core.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +DFHACK_PLUGIN("spectate"); +DFHACK_PLUGIN_IS_ENABLED(enabled); +REQUIRE_GLOBAL(world); +REQUIRE_GLOBAL(ui); +REQUIRE_GLOBAL(pause_state); +REQUIRE_GLOBAL(d_init); + +using namespace DFHack; +using namespace Pausing; +using namespace df::enums; + +void onTick(color_ostream& out, void* tick); +void onJobStart(color_ostream &out, void* job); +void onJobCompletion(color_ostream &out, void* job); + +uint64_t tick_threshold = 1000; +bool focus_jobs_enabled = false; +bool disengage_enabled = false; +bool unpause_enabled = false; +Pausing::AnnouncementLock* pause_lock = nullptr; +bool lock_collision = false; +bool announcements_disabled = false; + +bool following_dwarf = false; +df::unit* our_dorf = nullptr; +df::job* job_watched = nullptr; +int32_t timestamp = -1; + +std::set job_tracker; +std::map freq; +std::default_random_engine RNG; + +void enable_auto_unpause(color_ostream &out, bool state); + + +#define base 0.99 + +static const std::string CONFIG_KEY = std::string(plugin_name) + "/config"; +enum ConfigData { + UNPAUSE, + DISENGAGE, + JOB_FOCUS, + TICK_THRESHOLD +}; + +static PersistentDataItem config; +inline void saveConfig() { + if (config.isValid()) { + config.ival(UNPAUSE) = unpause_enabled; + config.ival(DISENGAGE) = disengage_enabled; + config.ival(JOB_FOCUS) = focus_jobs_enabled; + config.ival(TICK_THRESHOLD) = tick_threshold; + } +} + +command_result spectate (color_ostream &out, std::vector & parameters); + +DFhackCExport command_result plugin_init (color_ostream &out, std::vector &commands) { + commands.push_back(PluginCommand("spectate", + "Automated spectator mode.", + spectate, + false)); + pause_lock = new AnnouncementLock("spectate"); + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown (color_ostream &out) { + delete pause_lock; + return CR_OK; +} + +DFhackCExport command_result plugin_load_data (color_ostream &out) { + config = World::GetPersistentData(CONFIG_KEY); + + if (!config.isValid()) { + config = World::AddPersistentData(CONFIG_KEY); + saveConfig(); + } else { + unpause_enabled = config.ival(UNPAUSE); + disengage_enabled = config.ival(DISENGAGE); + focus_jobs_enabled = config.ival(JOB_FOCUS); + tick_threshold = config.ival(TICK_THRESHOLD); + pause_lock->unlock(); + enable_auto_unpause(out, unpause_enabled); + } + return DFHack::CR_OK; +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + namespace EM = EventManager; + if (enable && !enabled) { + out.print("Spectate mode enabled!\n"); + using namespace EM::EventType; + EM::EventHandler ticking(onTick, 15); + EM::EventHandler start(onJobStart, 0); + EM::EventHandler complete(onJobCompletion, 0); + EM::registerListener(EventType::TICK, ticking, plugin_self); + EM::registerListener(EventType::JOB_STARTED, start, plugin_self); + EM::registerListener(EventType::JOB_COMPLETED, complete, plugin_self); + enabled = true; // enable_auto_unpause won't do anything without this set now + enable_auto_unpause(out, unpause_enabled); + } else if (!enable && enabled) { + // warp 8, engage! + out.print("Spectate mode disabled!\n"); + EM::unregisterAll(plugin_self); + // we need to retain whether auto-unpause is enabled, but we also need to disable its effect + bool temp = unpause_enabled; + enable_auto_unpause(out, false); + unpause_enabled = temp; + job_tracker.clear(); + freq.clear(); + } + enabled = enable; + return DFHack::CR_OK; +} + +DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { + if (enabled) { + switch (event) { + case SC_MAP_UNLOADED: + case SC_BEGIN_UNLOAD: + case SC_WORLD_UNLOADED: + our_dorf = nullptr; + job_watched = nullptr; + following_dwarf = false; + default: + break; + } + } + return CR_OK; +} + +DFhackCExport command_result plugin_onupdate(color_ostream &out) { + // keeps announcement pause settings locked + World::Update(); // from pause.h + if (lock_collision) { + if (unpause_enabled) { + // player asked for auto-unpause enabled + World::SaveAnnouncementSettings(); + if (World::DisableAnnouncementPausing()) { + // now that we've got what we want, we can lock it down + lock_collision = false; + } + } else { + if (World::RestoreAnnouncementSettings()) { + lock_collision = false; + } + } + } + int failsafe = 0; + while (unpause_enabled && !world->status.popups.empty() && ++failsafe <= 10) { + // dismiss announcement popup(s) + Gui::getCurViewscreen(true)->feed_key(interface_key::CLOSE_MEGA_ANNOUNCEMENT); + if (World::ReadPauseState()) { + // WARNING: This has a possibility of conflicting with `reveal hell` - if Hermes himself runs `reveal hell` on precisely the right moment that is + World::SetPauseState(false); + } + } + if (failsafe >= 10) { + out.printerr("spectate encountered a problem dismissing a popup!\n"); + } + if (disengage_enabled && !World::ReadPauseState()) { + if (our_dorf && our_dorf->id != df::global::ui->follow_unit) { + plugin_enable(out, false); + } + } + return DFHack::CR_OK; +} + +void enable_auto_unpause(color_ostream &out, bool state) { + // we don't need to do any of this yet if the plugin isn't enabled + if (enabled) { + // todo: R.E. UNDEAD_ATTACK event [still pausing regardless of announcement settings] + // lock_collision == true means: enable_auto_unpause() was already invoked and didn't complete + // The onupdate function above ensure the procedure properly completes, thus we only care about + // state reversal here ergo `enabled != state` + if (lock_collision && unpause_enabled != state) { + out.print("handling collision\n"); + // if unpaused_enabled is true, then a lock collision means: we couldn't save/disable the pause settings, + // therefore nothing to revert and the lock won't even be engaged (nothing to unlock) + lock_collision = false; + unpause_enabled = state; + if (unpause_enabled) { + // a collision means we couldn't restore the pause settings, therefore we only need re-engage the lock + pause_lock->lock(); + } + return; + } + // update the announcement settings if we can + if (state) { + if (World::SaveAnnouncementSettings()) { + World::DisableAnnouncementPausing(); + announcements_disabled = true; + pause_lock->lock(); + } else { + out.printerr("lock collision enabling auto-unpause\n"); + lock_collision = true; + } + } else { + pause_lock->unlock(); + if (announcements_disabled) { + if (!World::RestoreAnnouncementSettings()) { + // this in theory shouldn't happen, if others use the lock like we do in spectate + out.printerr("lock collision disabling auto-unpause\n"); + lock_collision = true; + } else { + announcements_disabled = false; + } + } + } + if (lock_collision) { + out.printerr( + "auto-unpause: must wait for another Pausing::AnnouncementLock to be lifted.\n" + "The action you were attempting will complete when the following lock or locks lift.\n"); + pause_lock->reportLocks(out); + } + } + unpause_enabled = state; +} + +command_result spectate (color_ostream &out, std::vector & parameters) { + if (!parameters.empty()) { + if (parameters.size() >= 2 && parameters.size() <= 3) { + 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; + } + if(parameters[1] == "auto-unpause"){ + enable_auto_unpause(out, state); + } else if (parameters[1] == "auto-disengage") { + disengage_enabled = state; + } else if (parameters[1] == "focus-jobs") { + focus_jobs_enabled = state; + } else if (parameters[1] == "tick-threshold" && set && parameters.size() == 3) { + try { + tick_threshold = std::abs(std::stol(parameters[2])); + } catch (const std::exception &e) { + out.printerr("%s\n", e.what()); + } + } else { + return DFHack::CR_WRONG_USAGE; + } + } + } else { + out.print("Spectate is %s\n", enabled ? "ENABLED." : "DISABLED."); + out.print("tick-threshold: %" PRIu64 "\n", tick_threshold); + out.print("focus-jobs: %s\n", focus_jobs_enabled ? "on." : "off."); + out.print("auto-unpause: %s\n", unpause_enabled ? "on." : "off."); + out.print("auto-disengage: %s\n", disengage_enabled ? "on." : "off."); + } + saveConfig(); + return DFHack::CR_OK; +} + +// every tick check whether to decide to follow a dwarf +void onTick(color_ostream& out, void* ptr) { + int32_t tick = df::global::world->frame_counter; + if (our_dorf) { + if (!Units::isAlive(our_dorf)) { + following_dwarf = false; + df::global::ui->follow_unit = -1; + } + } + if (!following_dwarf || (focus_jobs_enabled && !job_watched) || (tick - timestamp) > (int32_t) tick_threshold) { + std::vector dwarves; + for (auto unit: df::global::world->units.active) { + if (!Units::isCitizen(unit)) { + continue; + } + dwarves.push_back(unit); + } + std::uniform_int_distribution follow_any(0, dwarves.size() - 1); + // if you're looking at a warning about a local address escaping, it means the unit* from dwarves (which aren't local) + our_dorf = dwarves[follow_any(RNG)]; + df::global::ui->follow_unit = our_dorf->id; + job_watched = our_dorf->job.current_job; + following_dwarf = true; + if (!job_watched) { + timestamp = tick; + } + } +} + +// every new worked job needs to be considered +void onJobStart(color_ostream& out, void* job_ptr) { + // todo: detect mood jobs + int32_t tick = df::global::world->frame_counter; + auto job = (df::job*) job_ptr; + // don't forget about it + int zcount = ++freq[job->pos.z]; + job_tracker.emplace(job->id); + // if we're not doing anything~ then let's pick something + if ((focus_jobs_enabled && !job_watched) || (tick - timestamp) > (int32_t) tick_threshold) { + following_dwarf = true; + // todo: allow the user to configure b, and also revise the math + const double b = base; + double p = b * ((double) zcount / job_tracker.size()); + std::bernoulli_distribution follow_job(p); + if (!job->flags.bits.special && follow_job(RNG)) { + job_watched = job; + if (df::unit* unit = Job::getWorker(job)) { + our_dorf = unit; + df::global::ui->follow_unit = unit->id; + } + } else { + timestamp = tick; + std::vector nonworkers; + for (auto unit: df::global::world->units.active) { + if (!Units::isCitizen(unit) || unit->job.current_job) { + continue; + } + nonworkers.push_back(unit); + } + std::uniform_int_distribution<> follow_drunk(0, nonworkers.size() - 1); + df::global::ui->follow_unit = nonworkers[follow_drunk(RNG)]->id; + } + } +} + +// every job completed can be forgotten about +void onJobCompletion(color_ostream &out, void* job_ptr) { + auto job = (df::job*) job_ptr; + // forget about it + freq[job->pos.z]--; + freq[job->pos.z] = freq[job->pos.z] < 0 ? 0 : freq[job->pos.z]; + // the job doesn't exist, so we definitely need to get rid of that + job_tracker.erase(job->id); + // the event manager clones jobs and returns those clones for completed jobs. So the pointers won't match without a refactor of EM passing clones to both events + if (job_watched && job_watched->id == job->id) { + job_watched = nullptr; + } +}