From 974e5ddc1f3d696278b18fa73d689544504ba8c3 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Sun, 2 Feb 2025 14:01:35 -0800 Subject: [PATCH 1/8] combine into one file --- plugins/spectate/CMakeLists.txt | 3 +- plugins/spectate/pause.cpp | 187 ------------------------ plugins/spectate/pause.h | 76 ---------- plugins/spectate/spectate.cpp | 242 +++++++++++++++++++++++++++++++- 4 files changed, 241 insertions(+), 267 deletions(-) delete mode 100644 plugins/spectate/pause.cpp delete mode 100644 plugins/spectate/pause.h diff --git a/plugins/spectate/CMakeLists.txt b/plugins/spectate/CMakeLists.txt index d2de072d8b..3f205171dc 100644 --- a/plugins/spectate/CMakeLists.txt +++ b/plugins/spectate/CMakeLists.txt @@ -2,7 +2,6 @@ project(spectate) SET(SOURCES - spectate.cpp - pause.cpp) + spectate.cpp) dfhack_plugin(${PROJECT_NAME} ${SOURCES}) diff --git a/plugins/spectate/pause.cpp b/plugins/spectate/pause.cpp deleted file mode 100644 index f94b1e73a9..0000000000 --- a/plugins/spectate/pause.cpp +++ /dev/null @@ -1,187 +0,0 @@ -#include "pause.h" -#include -#include -#include -#include -#include -#include - -#include - -using namespace DFHack; -using namespace Pausing; -using namespace df::enums; - -// marked by REQUIRE_GLOBAL in spectate.cpp -using df::global::plotinfo; -using df::global::d_init; - -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 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 ((plotinfo->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] = 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 : 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] = 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) { - 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) { - d_init->announcements.flags[i].bits.PAUSE = locked_states[i]; - } - } -} diff --git a/plugins/spectate/pause.h b/plugins/spectate/pause.h deleted file mode 100644 index ab736ed531..0000000000 --- a/plugins/spectate/pause.h +++ /dev/null @@ -1,76 +0,0 @@ -#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 index 36bd2d94b2..29ec27718f 100644 --- a/plugins/spectate/spectate.cpp +++ b/plugins/spectate/spectate.cpp @@ -1,5 +1,3 @@ -#include "pause.h" - #include "Debug.h" #include "Export.h" #include "PluginManager.h" @@ -40,6 +38,246 @@ using namespace DFHack; using namespace Pausing; using namespace df::enums; +//////////// +// 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(); +} + +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 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 ((plotinfo->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] = 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 : 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] = 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) { + 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) { + d_init->announcements.flags[i].bits.PAUSE = locked_states[i]; + } + } +} + struct Configuration { bool unpause = false; bool disengage = false; From 1fc6737c1ecb701d90ecdec7aecfabe2f727e7a7 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Sun, 2 Feb 2025 14:02:32 -0800 Subject: [PATCH 2/8] move spectate to main plugins dir --- plugins/CMakeLists.txt | 2 +- plugins/{spectate => }/spectate.cpp | 0 plugins/spectate/CMakeLists.txt | 7 ------- 3 files changed, 1 insertion(+), 8 deletions(-) rename plugins/{spectate => }/spectate.cpp (100%) delete mode 100644 plugins/spectate/CMakeLists.txt diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index ff71159e5e..8f230ff583 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -111,7 +111,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) - add_subdirectory(spectate) + dfhack_plugin(spectate spectate.cpp LINK_LIBRARIES lua) #dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) add_subdirectory(stockpiles) dfhack_plugin(stocks stocks.cpp LINK_LIBRARIES lua) diff --git a/plugins/spectate/spectate.cpp b/plugins/spectate.cpp similarity index 100% rename from plugins/spectate/spectate.cpp rename to plugins/spectate.cpp diff --git a/plugins/spectate/CMakeLists.txt b/plugins/spectate/CMakeLists.txt deleted file mode 100644 index 3f205171dc..0000000000 --- a/plugins/spectate/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ - -project(spectate) - -SET(SOURCES - spectate.cpp) - -dfhack_plugin(${PROJECT_NAME} ${SOURCES}) From 2c27dc6b48753a1cf599d829d4c629138de8b65c Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Sun, 2 Feb 2025 14:03:12 -0800 Subject: [PATCH 3/8] update spectate docs to reflect update plan --- docs/plugins/spectate.rst | 152 +++++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 36 deletions(-) diff --git a/docs/plugins/spectate.rst b/docs/plugins/spectate.rst index 1e400a06e4..cd7a8b96c5 100644 --- a/docs/plugins/spectate.rst +++ b/docs/plugins/spectate.rst @@ -2,8 +2,31 @@ spectate ======== .. dfhack-tool:: - :summary: Automatically follow productive dwarves. - :tags: fort interface + :summary: Automated spectator mode. + :tags: fort inspection interface + +This tool is for those who like to watch their dwarves go about their business. + +When enabled, `spectate` will lock the camera to following the dwarves +scurrying around your fort. Every once in a while, it will automatically switch +to following a different dwarf. It can also switch to following animals, +hostiles, or visiting units. You can switch to the next target (or a previous +target) immediately with the left/right arrow keys. + +`spectate` will disengage and turn itself off when you move the map, just like +the vanilla follow mechanic. It will also disengage immediately if you open the +squads menu for military action. + +It can also annotate your dwarves on the map with their name, job, and other +information, either as floating tooltips or in a panel that comes up when you +hover the mouse over a target. + +Run `gui/spectate` to configure the plugin's settings. + +Settings are saved globally, so your preferences for `spectate` and its +overlays will apply to all forts, not just the currently loaded one. Follow +mode is automatically disabled when you load a fort so you can get your +bearings before re-enabling. Usage ----- @@ -11,52 +34,109 @@ Usage :: enable spectate - spectate + spectate [status] spectate set - spectate enable|disable - -When enabled, the plugin will lock the camera to following the dwarves -scurrying around your fort. Every once in a while, it will automatically switch -to following a different dwarf, preferring dwarves on z-levels with the highest -job activity. - -If you have the ``auto-disengage`` feature disabled, you can switch to a new -dwarf immediately by hitting one of the map movement keys (``wasd`` by -default). To stop following dwarves, bring up `gui/launcher` and run -``disable spectate``. - -Changes to settings will be saved with your fort, but if `spectate` is enabled -when you save the fort, it will disenable itself when you load so you can get -your bearings before re-enabling follow mode with ``enable spectate`` again. + spectate overlay enable|disable Examples -------- ``enable spectate`` - Starting following dwarves and observing life in your fort. + Start following dwarves and observing life in your fort. ``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 auto-unpause true`` + Configure `spectate` to automatically dismiss popups and pause events, like + siege announcements. -``spectate set tick-threshold 1000`` - Set the tick interval between camera changes back to its default value. +``spectate set follow-seconds 30`` + Configure `spectate` to switch targets every 30 seconds when in follow mode. -Features --------- -: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) -:animals: Toggle whether to sometimes follow animals. (default: disabled) -:hostiles: Toggle whether to sometimes follow hostiles (eg. undead, - titans, invaders, etc.) (default: disabled) -:visiting: Toggle whether to sometimes follow visiting units (eg. - diplomats) +``spectate overlay follow enable`` + Show informative tooltips that follow each unit on the map. Settings -------- -:tick-threshold: Set the plugin's tick interval for changing the followed - dwarf. (default: 1000) + +``auto-disengage`` (default: enabled) + Toggle automatically disabling the plugin when the player moves the map or + opens the squad panel. If this is disabled, you will need to manually + disable the plugin to turn off follow mode. + +``auto-unpause`` (default: disabled) + Toggle auto-dismissal of announcements that pause the game, like sieges, + forgotten beasts, etc. + +``cinematic-action`` (default: enabled) + Toggle whether to switch targets more rapidly when there is conflict. + +``follow-seconds`` (default: 10) + Set the time interval for changing the followed unit. + +``include-animals`` (default: disabled) + Toggle whether to sometimes follow fort animals. + +``include-hostiles`` (default: disabled) + Toggle whether to sometimes follow hostiles (eg. undead, titans, invaders, + etc.) + +``include-visiting`` (default: disabled) + Toggle whether to sometimes follow visiting units, like diplomats. + +``include-wildlife`` (default: disabled) + Toggle whether to sometimes follow wildlife. + +``prefer-conflict`` (default: enabled) + Toggle whether to prefer following units in active conflict. + +``prefer-new-arrivals`` (default: enabled) + Toggle whether to prefer following (non-siege) units that have newly + arrived on the map. + +``tooltip-follow-job`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + job of the dwarf in the tooltip. + +``tooltip-follow-name`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + name of the dwarf in the tooltip. + +``tooltip-follow-stress`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + happiness level (stress) of the dwarf in the tooltip. + +``tooltip-hover-job`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + job of the dwarf in the hover panel. + +``tooltip-hover-name`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + name of the dwarf in the hover panel. + +``tooltip-hover-stress`` (default: enabled) + If the ``spectate.follow`` overlay is enabled, toggle whether to show the + happiness level (stress) of the dwarf in the hover panel. + +Overlays +-------- + +``spectate`` provides two overlays via the `overlay` framework to add +information and functionality to the main map. These overlays can be controlled +via the ``spectate overlay`` command or the ``Overlays`` tab in +`gui/control-panel`. + +The information displayed by these overlays can be configured via the +``spectate set`` command or the `gui/spectate` interface. + +``spectate.follow`` + Show informative tooltips that follow each unit on the map. You can enable + this overlay by running ``spectate overlay follow enable`` or, + equivalently, ``overlay enable spectate.follow``. + +``spectate.hover`` + Show a popup panel with selected information when your mouse cursor hovers + over a unit. You can enable this overlay by running + ``spectate overlay hover enable`` or, equivalently, + ``overlay enable spectate.hover``. From a065980a1fc65ba0096f00a43ce771d04704b40c Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Sun, 2 Feb 2025 15:32:09 -0800 Subject: [PATCH 4/8] initial implementation of most of the new behavior --- docs/plugins/spectate.rst | 10 +- plugins/lua/spectate.lua | 149 ++++++ plugins/spectate.cpp | 933 +++++++++++++------------------------- 3 files changed, 461 insertions(+), 631 deletions(-) create mode 100644 plugins/lua/spectate.lua diff --git a/docs/plugins/spectate.rst b/docs/plugins/spectate.rst index cd7a8b96c5..f49849c850 100644 --- a/docs/plugins/spectate.rst +++ b/docs/plugins/spectate.rst @@ -35,6 +35,7 @@ Usage enable spectate spectate [status] + spectate toggle spectate set spectate overlay enable|disable @@ -44,6 +45,10 @@ Examples ``enable spectate`` Start following dwarves and observing life in your fort. +``spectate toggle`` + Toggle the plugin on or off. Intended for use with a keybinding. The + default is Ctrl-Shift-S. + ``spectate`` The plugin reports its configured status. @@ -73,7 +78,8 @@ Settings Toggle whether to switch targets more rapidly when there is conflict. ``follow-seconds`` (default: 10) - Set the time interval for changing the followed unit. + Set the time interval for changing the followed unit. The interval does not + include time that the game is paused. ``include-animals`` (default: disabled) Toggle whether to sometimes follow fort animals. @@ -82,7 +88,7 @@ Settings Toggle whether to sometimes follow hostiles (eg. undead, titans, invaders, etc.) -``include-visiting`` (default: disabled) +``include-visitors`` (default: disabled) Toggle whether to sometimes follow visiting units, like diplomats. ``include-wildlife`` (default: disabled) diff --git a/plugins/lua/spectate.lua b/plugins/lua/spectate.lua new file mode 100644 index 0000000000..ba7e9ba7ba --- /dev/null +++ b/plugins/lua/spectate.lua @@ -0,0 +1,149 @@ +local _ENV = mkmodule('plugins.spectate') + +local argparse = require('argparse') +local json = require('json') +local overlay = require('plugins.overlay') +local utils = require('utils') + +-- settings starting with 'tooltip-' are not passed to the C++ plugin +local lua_only_settings_prefix = 'tooltip-' + +local function get_default_state() + return { + ['auto-disengage']=true, + ['auto-unpause']=false, + ['cinematic-action']=true, + ['follow-seconds']=10, + ['include-animals']=false, + ['include-hostiles']=false, + ['include-visitors']=false, + ['include-wildlife']=false, + ['prefer-conflict']=true, + ['prefer-new-arrivals']=true, + ['tooltip-follow-job']=true, + ['tooltip-follow-name']=true, + ['tooltip-follow-stress']=true, + ['tooltip-hover-job']=true, + ['tooltip-hover-name']=true, + ['tooltip-hover-stress']=true, + } +end + +local function load_state() + local state = get_default_state() + local config = json.open('dfhack-config/spectate.json') + for key in pairs(config.data) do + if state[key] == nil then + config.data[key] = nil + end + end + utils.assign(state, config.data) + config.data = state + return config +end + +local config = load_state() + +function refresh_cpp_config() + for name,value in pairs(config.data) do + if not name:startswith(lua_only_settings_prefix) then + if type(value) == 'boolean' then + value = value and 1 or 0 + end + spectate_setSetting(name, value) + end + end +end + +----------------------------- +-- commandline interface + +local function print_status() + print('spectate is:', isEnabled() and 'enabled' or 'disabled') + print() + print('settings:') + for key, value in pairs(config.data) do + print(' ' .. key .. ': ' .. tostring(value)) + end +end + +local function do_toggle() + if isEnabled() then + dfhack.run_command('disable', 'spectate') + else + dfhack.run_command('enable', 'spectate') + end +end + +local function set_setting(key, value) + if config.data[key] == nil then + qerror('unknown setting: ' .. key) + end + if key == 'follow-seconds' then + value = argparse.positiveInt(value, 'follow-seconds') + else + value = argparse.boolean(value, key) + end + config.data[key] = value + config:write() + if not key:startswith(lua_only_settings_prefix) then + if type(value) == 'boolean' then + value = value and 1 or 0 + end + spectate_setSetting(key, value) + end +end + +local function set_overlay(name, value) + if not name:startswith('spectate.') then + name = 'spectate.' .. name + end + if name ~= 'spectate.follow' and name ~= 'spectate.hover' then + qerror('unknown overlay: ' .. name) + end + value = argparse.boolean(value, name) + dfhack.run_command('overlay', value and 'enable' or 'disable', name) +end + +function parse_commandline(args) + local command = table.remove(args, 1) + if not command or command == 'status' then + print_status() + elseif command == 'toggle' then + do_toggle() + elseif command == 'set' then + set_setting(args[1], args[2]) + elseif command == 'overlay' then + set_overlay(args[1], args[2]) + else + return false + end + + return true +end + +----------------------------- +-- overlays + +FollowOverlay = defclass(FollowOverlay, overlay.OverlayWidget) +FollowOverlay.ATTRS{ + desc='Adds info tooltips that follow units on the map.', + default_pos={x=1,y=1}, + fullscreen=true, + viewscreens='dwarfmode/Default', +} + +HoverOverlay = defclass(HoverOverlay, overlay.OverlayWidget) +HoverOverlay.ATTRS{ + desc='Shows info popup when hovering the mouse over units on the map.', + default_pos={x=1,y=1}, + fullscreen=true, + viewscreens='dwarfmode/Default', +} + +OVERLAY_WIDGETS = { + follow=FollowOverlay, + hover=HoverOverlay, +} + +return _ENV diff --git a/plugins/spectate.cpp b/plugins/spectate.cpp index 29ec27718f..ae33031b14 100644 --- a/plugins/spectate.cpp +++ b/plugins/spectate.cpp @@ -1,716 +1,391 @@ #include "Debug.h" -#include "Export.h" +#include "LuaTools.h" +#include "PluginLua.h" #include "PluginManager.h" -#include "modules/EventManager.h" -#include "modules/World.h" -#include "modules/Maps.h" #include "modules/Gui.h" -#include "modules/Job.h" #include "modules/Units.h" +#include "modules/World.h" -#include "df/job.h" -#include "df/unit.h" -#include "df/historical_figure.h" -#include "df/global_objects.h" +#include "df/announcements.h" +#include "df/d_init.h" #include "df/plotinfost.h" +#include "df/unit.h" #include "df/world.h" -#include "df/viewscreen.h" -#include "df/creature_raw.h" -#include #include -#include - -// Debugging -namespace DFHack { - DBG_DECLARE(log, plugin, DebugCategory::LINFO); -} - -DFHACK_PLUGIN("spectate"); -DFHACK_PLUGIN_IS_ENABLED(enabled); - -REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(plotinfo); -REQUIRE_GLOBAL(d_init); // used in pause.cpp using namespace DFHack; -using namespace Pausing; -using namespace df::enums; -//////////// -// 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; - }; +using std::string; +using std::vector; - // 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) +DFHACK_PLUGIN("spectate"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); - 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 +REQUIRE_GLOBAL(d_init); +REQUIRE_GLOBAL(plotinfo); +REQUIRE_GLOBAL(world); - void Update(); +namespace DFHack { + DBG_DECLARE(spectate, control, DebugCategory::LINFO); + DBG_DECLARE(spectate, cycle, DebugCategory::LINFO); } -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 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 ((plotinfo->main.mode == ui_sidebar_mode::Default) && !allow_player_pause) { - input->erase(interface_key::D_PAUSE); - } - INTERPOSE_NEXT(feed)(input); - } - }; +static uint32_t next_cycle_unpaused_ms = 0; // threshold for the next cycle +static bool was_in_settings = false; // whether we were in the vanilla settings screen last update - IMPLEMENT_VMETHOD_INTERPOSE(player_pause_hook, feed); -} -using namespace pausing; +static const size_t announcement_flag_arr_size = sizeof(decltype(df::announcements::flags)) / sizeof(df::announcement_flags); +static std::unique_ptr saved_announcement_settings; -template -inline bool any_lock(Locks locks) { - return std::any_of(locks.begin(), locks.end(), [](Lock* lock) { return lock->isLocked(); }); +static void save_announcement_settings(color_ostream &out) { + if (!saved_announcement_settings) + saved_announcement_settings = std::make_unique(new uint32_t[announcement_flag_arr_size]); + DEBUG(control,out).print("saving announcement settings\n"); + for (size_t i = 0; i < announcement_flag_arr_size; ++i) + (*saved_announcement_settings)[i] = d_init->announcements.flags[i].whole; } -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(); - }); +static void restore_announcement_settings(color_ostream &out) { + if (!saved_announcement_settings) + return; + DEBUG(control,out).print("restoring saved announcement settings\n"); + for (size_t i = 0; i < announcement_flag_arr_size; ++i) + d_init->announcements.flags[i].whole = (*saved_announcement_settings)[i]; } -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; - } +static void scrub_announcements(color_ostream &out) { + if (Gui::matchFocusString("dwarfmode/Settings")) { + DEBUG(control,out).print("not modifying announcement settings; vanilla settings screen is active\n"); + return; } - 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()); - } + DEBUG(control,out).print("removing PAUSE from announcement settings\n"); + for (auto& flag : d_init->announcements.flags) { + flag.bits.DO_MEGA = false; + flag.bits.PAUSE = false; + flag.bits.RECENTER = false; } - 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] = d_init->announcements.flags[i].bits.PAUSE; - } - return true; +struct Configuration { + bool auto_disengage; + bool auto_unpause; + bool cinematic_action; + bool include_animals; + bool include_hostiles; + bool include_visitors; + bool include_wildlife; + bool prefer_conflict; + bool prefer_new_arrivals; + int32_t follow_ms; + + void reset() { + auto_disengage = true; + auto_unpause = false; + cinematic_action = true; + include_animals = false; + include_hostiles = false; + include_visitors = false; + include_wildlife = false; + prefer_conflict = true; + prefer_new_arrivals = true; + follow_ms = 10000; } - return false; -} +} config; -void AnnouncementLock::lock() { - Lock::lock(); - captureState(); -} +static command_result do_command(color_ostream &out, vector ¶meters); +static void follow_a_dwarf(color_ostream &out); -bool AnnouncementLock::isAnyLocked() const { - return any_lock(locks); -} +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { + DEBUG(control,out).print("initializing %s\n", plugin_name); -bool AnnouncementLock::isOnlyLocked() const { - return only_lock(locks, this); -} - -void AnnouncementLock::reportLocks(color_ostream &out) { - reportLockedLocks(out, locks); -} + commands.push_back(PluginCommand( + plugin_name, + "Automated spectator mode.", + do_command)); -bool PlayerLock::isAnyLocked() const { - return any_lock(locks); + return CR_OK; } -bool PlayerLock::isOnlyLocked() const { - return only_lock(locks, this); +static void cleanup(color_ostream &out) { + if (saved_announcement_settings) { + restore_announcement_settings(out); + delete[] *saved_announcement_settings; + saved_announcement_settings.reset(); + } } -void PlayerLock::reportLocks(color_ostream &out) { - reportLockedLocks(out, locks); -} +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { + out.printerr("Cannot enable %s without a loaded fort.\n", plugin_name); + return CR_FAILURE; + } -bool World::DisableAnnouncementPausing() { - if (!announcementLock.isAnyLocked()) { - for (auto& flag : d_init->announcements.flags) { - flag.bits.PAUSE = false; - //out.print("pause: %d\n", flag.bits.PAUSE); + if (enable != is_enabled) { + is_enabled = enable; + DEBUG(control,out).print("%s from the API; persisting\n", + is_enabled ? "enabled" : "disabled"); + if (enable) { + INFO(control,out).print("Spectate mode enabled!\n"); + config.reset(); + if (!Lua::CallLuaModuleFunction(out, "plugins.spectate", "refresh_cpp_config")) { + WARN(control,out).print("Failed to refresh config\n"); + } + follow_a_dwarf(out); + } else { + INFO(control,out).print("Spectate mode disabled!\n"); + plotinfo->follow_unit = -1; + cleanup(out); } - return true; + } else { + DEBUG(control,out).print("%s from the API, but already %s; no action\n", + is_enabled ? "enabled" : "disabled", + is_enabled ? "enabled" : "disabled"); } - return false; + return CR_OK; } -bool World::SaveAnnouncementSettings() { - if (!announcementLock.isAnyLocked()) { - for (size_t i = 0; i < announcement_flag_arr_size; ++i) { - saved_states[i] = d_init->announcements.flags[i].bits.PAUSE; - } - state_saved = true; - return true; - } - return false; +DFhackCExport command_result plugin_shutdown (color_ostream &out) { + DEBUG(control,out).print("shutting down %s\n", plugin_name); + cleanup(out); + return CR_OK; } -bool World::RestoreAnnouncementSettings() { - if (!announcementLock.isAnyLocked() && state_saved) { - for (size_t i = 0; i < announcement_flag_arr_size; ++i) { - d_init->announcements.flags[i].bits.PAUSE = saved_states[i]; +DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { + switch (event) { + case SC_WORLD_LOADED: + next_cycle_unpaused_ms = 0; + break; + case SC_WORLD_UNLOADED: + if (is_enabled) { + DEBUG(control,out).print("world unloaded; disabling %s\n", + plugin_name); + is_enabled = false; + cleanup(out); } - return true; + break; + default: + break; } - return false; + return CR_OK; } -bool World::EnablePlayerPausing() { - if (!playerLock.isAnyLocked()) { - allow_player_pause = true; +DFhackCExport command_result plugin_onupdate(color_ostream &out) { + if (Gui::matchFocusString("dwarfmode/Settings")) { + if (!was_in_settings) { + DEBUG(cycle,out).print("settings screen active; restoring announcement settings\n"); + restore_announcement_settings(out); + was_in_settings = true; + } + } else if (was_in_settings) { + was_in_settings = false; + if (config.auto_unpause) { + DEBUG(cycle,out).print("settings screen now inactive; disabling announcement pausing\n"); + save_announcement_settings(out); + scrub_announcements(out); + } } - return allow_player_pause; -} -bool World::DisablePlayerPausing() { - if (!playerLock.isAnyLocked()) { - allow_player_pause = false; + if (config.auto_disengage && plotinfo->follow_unit < 0) { + DEBUG(cycle,out).print("auto-disengage triggered\n"); + is_enabled = false; + cleanup(out); + return CR_OK; } - return !allow_player_pause; -} -bool World::IsPlayerPausingEnabled() { - return allow_player_pause; + if ((!config.auto_disengage && plotinfo->follow_unit < 0) || Core::getInstance().getUnpausedMs() >= next_cycle_unpaused_ms) + follow_a_dwarf(out); + return CR_OK; } -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) { - d_init->announcements.flags[i].bits.PAUSE = locked_states[i]; - } +static command_result do_command(color_ostream &out, vector ¶meters) { + bool show_help = false; + if (!Lua::CallLuaModuleFunction(out, "plugins.spectate", "parse_commandline", std::make_tuple(parameters), + 1, [&](lua_State *L) { + show_help = !lua_toboolean(L, -1); + })) { + return CR_FAILURE; } -} - -struct Configuration { - bool unpause = false; - bool disengage = false; - bool animals = false; - bool hostiles = true; - bool visitors = false; - int32_t tick_threshold = 1000; -} config; - -Pausing::AnnouncementLock* pause_lock = nullptr; -bool lock_collision = false; -bool announcements_disabled = false; - -#define base 0.99 -static const std::string CONFIG_KEY = std::string(plugin_name) + "/config"; -enum ConfigData { - UNPAUSE, - DISENGAGE, - TICK_THRESHOLD, - ANIMALS, - HOSTILES, - VISITORS -}; + return show_help ? CR_WRONG_USAGE : CR_OK; +} -static PersistentDataItem pconfig; - -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable); -command_result spectate (color_ostream &out, std::vector & parameters); -#define COORDARGS(id) id.x, id.y, id.z - -namespace SP { - bool following_dwarf = false; - df::unit* our_dorf = nullptr; - int32_t timestamp = -1; - std::default_random_engine RNG; - - void DebugUnitVector(std::vector units) { - if (debug_plugin.isEnabled(DFHack::DebugCategory::LDEBUG)) { - for (auto unit: units) { - DEBUG(plugin).print("[id: %d]\n animal: %d\n hostile: %d\n visiting: %d\n", - unit->id, - Units::isAnimal(unit), - Units::isDanger(unit), - Units::isVisiting(unit)); - } - } - } +///////////////////////////////////////////////////// +// cycle logic +// - void PrintStatus(color_ostream &out) { - out.print("Spectate is %s\n", enabled ? "ENABLED." : "DISABLED."); - out.print(" FEATURES:\n"); - out.print(" %-20s\t%s\n", "auto-unpause: ", config.unpause ? "on." : "off."); - out.print(" %-20s\t%s\n", "auto-disengage: ", config.disengage ? "on." : "off."); - out.print(" %-20s\t%s\n", "animals: ", config.animals ? "on." : "off."); - out.print(" %-20s\t%s\n", "hostiles: ", config.hostiles ? "on." : "off."); - out.print(" %-20s\t%s\n", "visiting: ", config.visitors ? "on." : "off."); - out.print(" SETTINGS:\n"); - out.print(" %-20s\t%" PRIi32 "\n", "tick-threshold: ", config.tick_threshold); - if (following_dwarf) - out.print(" %-21s\t%s[id: %d]\n","FOLLOWING:", our_dorf ? our_dorf->name.first_name.c_str() : "nullptr", plotinfo->follow_unit); - } +static bool is_in_combat(df::unit *unit) { + return false; +} - void SetUnpauseState(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 && config.unpause != state) { - WARN(plugin).print("Spectate auto-unpause: Not enabled yet, there was a lock collision. When the other lock holder releases, auto-unpause will engage on its own.\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; - config.unpause = state; - if (config.unpause) { - // 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 { - WARN(plugin).print("Spectate auto-unpause: Could not fully enable. There was a lock collision, when the other lock holder releases, auto-unpause will engage on its own.\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 - WARN(plugin).print("Spectate auto-unpause: Could not fully disable. There was a lock collision, when the other lock holder releases, auto-unpause will disengage on its own.\n"); - lock_collision = true; - } else { - announcements_disabled = false; - } - } - } - if (lock_collision) { - ERR(plugin).print("Spectate auto-unpause: Could not fully enable. There was a lock collision, when the other lock holder releases, auto-unpause will engage on its own.\n"); - WARN(plugin).print( - " 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(Core::getInstance().getConsole()); - } - } - config.unpause = state; - } +static bool is_fleeing(df::unit *unit) { + return false; +} - void SaveSettings() { - if (pconfig.isValid()) { - pconfig.ival(UNPAUSE) = config.unpause; - pconfig.ival(DISENGAGE) = config.disengage; - pconfig.ival(TICK_THRESHOLD) = config.tick_threshold; - pconfig.ival(ANIMALS) = config.animals; - pconfig.ival(HOSTILES) = config.hostiles; - pconfig.ival(VISITORS) = config.visitors; - } - } +static void get_dwarf_buckets(color_ostream &out, + vector &active_combat_units, + vector &passive_combat_units, + vector &job_units, + vector &other_units) +{ + static const std::unordered_set boring_jobs = { + df::job_type::Eat, + df::job_type::Drink, + df::job_type::Sleep, + }; - void LoadSettings() { - pconfig = World::GetPersistentSiteData(CONFIG_KEY); + for (auto unit : world->units.active) { + if (Units::isDead(unit) || !Units::isActive(unit) || unit->flags1.bits.caged || unit->flags1.bits.chained || Units::isHidden(unit)) + continue; + if (!config.include_animals && Units::isAnimal(unit)) + continue; + if (!config.include_hostiles && Units::isDanger(unit)) + continue; + if (!config.include_visitors && Units::isVisitor(unit)) + continue; + if (!config.include_wildlife && Units::isWildlife(unit)) + continue; - if (!pconfig.isValid()) { - pconfig = World::AddPersistentSiteData(CONFIG_KEY); - SaveSettings(); + if (is_in_combat(unit)) { + if (is_fleeing(unit)) + passive_combat_units.push_back(unit); + else + active_combat_units.push_back(unit); + } else if (unit->job.current_job && !boring_jobs.contains(unit->job.current_job->job_type)) { + job_units.push_back(unit); } else { - config.unpause = pconfig.ival(UNPAUSE); - config.disengage = pconfig.ival(DISENGAGE); - config.tick_threshold = pconfig.ival(TICK_THRESHOLD); - config.animals = pconfig.ival(ANIMALS); - config.hostiles = pconfig.ival(HOSTILES); - config.visitors = pconfig.ival(VISITORS); - pause_lock->unlock(); - SetUnpauseState(config.unpause); + other_units.push_back(unit); } } +} - bool FollowADwarf() { - if (enabled && !World::ReadPauseState()) { - df::coord viewMin = Gui::getViewportPos(); - df::coord viewMax{viewMin}; - const auto &dims = Gui::getDwarfmodeViewDims().map().second; - viewMax.x += dims.x - 1; - viewMax.y += dims.y - 1; - viewMax.z = viewMin.z; - std::vector units; - static auto add_if = [&](std::function check) { - for (auto unit : world->units.active) { - if (check(unit)) { - units.push_back(unit); - } - } - }; - static auto valid = [](df::unit* unit) { - if (Units::isAnimal(unit)) { - return config.animals; - } - if (Units::isVisiting(unit)) { - return config.visitors; - } - if (Units::isDanger(unit)) { - return config.hostiles; - } - return true; - }; - static auto calc_extra_weight = [](size_t idx, double r1, double r2) { - switch(idx) { - case 0: - return r2; - case 1: - return (r2-r1)/1.3; - case 2: - return (r2-r1)/2; - default: - return 0.0; - } - }; - /// Collecting our choice pool - /////////////////////////////// - std::array ranges{}; - std::array range_exists{}; - static auto build_range = [&](size_t idx){ - size_t first = idx * 2; - size_t second = idx * 2 + 1; - size_t previous = first - 1; - // first we get the end of the range - ranges[second] = units.size() - 1; - // then we calculate whether the range exists, and set the first index appropriately - if (idx == 0) { - range_exists[idx] = ranges[second] >= 0; - ranges[first] = 0; - } else { - range_exists[idx] = ranges[second] > ranges[previous]; - ranges[first] = ranges[previous] + (range_exists[idx] ? 1 : 0); - } - }; - - /// RANGE 0 (in view + working) - // grab valid working units - add_if([&](df::unit* unit) { - return valid(unit) && - Units::isUnitInBox(unit, COORDARGS(viewMin), COORDARGS(viewMax)) && - Units::isCitizen(unit, true) && - unit->job.current_job; - }); - build_range(0); - - /// RANGE 1 (in view) - add_if([&](df::unit* unit) { - return valid(unit) && Units::isUnitInBox(unit, COORDARGS(viewMin), COORDARGS(viewMax)); - }); - build_range(1); - - /// RANGE 2 (working citizens) - add_if([](df::unit* unit) { - return valid(unit) && Units::isCitizen(unit, true) && unit->job.current_job; - }); - build_range(2); - - /// RANGE 3 (citizens) - add_if([](df::unit* unit) { - return valid(unit) && Units::isCitizen(unit, true); - }); - build_range(3); - - /// RANGE 4 (any valid) - add_if(valid); - build_range(4); - - // selecting from our choice pool - if (!units.empty()) { - std::array bw{23,17,13,7,1}; // probability weights for each range - std::vector i; - std::vector w; - bool at_least_one = false; - // in one word, elegance - for(size_t idx = 0; idx < range_exists.size(); ++idx) { - if (range_exists[idx]) { - at_least_one = true; - const auto &r1 = ranges[idx*2]; - const auto &r2 = ranges[idx*2+1]; - double extra = calc_extra_weight(idx, r1, r2); - i.push_back(r1); - w.push_back(bw[idx] + extra); - if (r1 != r2) { - i.push_back(r2); - w.push_back(bw[idx] + extra); - } - } - } - if (!at_least_one) { - return false; - } - DebugUnitVector(units); - std::piecewise_linear_distribution<> follow_any(i.begin(), i.end(), w.begin()); - // if you're looking at a warning about a local address escaping, it means the unit* from units (which aren't local) - size_t idx = follow_any(RNG); - our_dorf = units[idx]; - plotinfo->follow_unit = our_dorf->id; - timestamp = world->frame_counter; - return true; - } else { - WARN(plugin).print("units vector is empty!\n"); - } - } - return false; - } - - void onUpdate(color_ostream &out) { - // keeps announcement pause settings locked - World::Update(); // from pause.h - - // Plugin Management - if (lock_collision) { - if (config.unpause) { - // 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 (config.unpause && !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"); - } +static std::default_random_engine rng; - // plugin logic - static int32_t last_tick = -1; - int32_t tick = world->frame_counter; - if (!World::ReadPauseState() && tick - last_tick >= 1) { - last_tick = tick; - // validate follow state - if (!following_dwarf || !our_dorf || plotinfo->follow_unit < 0 || tick - timestamp >= config.tick_threshold) { - // we're not following anyone - following_dwarf = false; - if (!config.disengage) { - // try to - following_dwarf = FollowADwarf(); - } else if (!World::ReadPauseState()) { - plugin_enable(out, false); - } - } - } +static uint32_t get_next_cycle_unpaused_ms(bool has_active_combat) { + int32_t delay_ms = config.follow_ms; + if (has_active_combat) { + std::normal_distribution distribution(config.follow_ms / 2, config.follow_ms / 6); + int32_t delay_ms = distribution(rng); + delay_ms = std::min(config.follow_ms, std::max(1, delay_ms)); } -}; - -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; + return Core::getInstance().getUnpausedMs() + delay_ms; } -DFhackCExport command_result plugin_shutdown (color_ostream &out) { - delete pause_lock; - return CR_OK; +static void add_bucket(const vector &bucket, vector &units, vector &intervals, vector &weights, float weight) { + if (bucket.empty()) + return; + intervals.push_back(units.size() + bucket.size()); + weights.push_back(weight); + units.insert(units.end(), bucket.begin(), bucket.end()); } -DFhackCExport command_result plugin_load_site_data (color_ostream &out) { - SP::LoadSettings(); - if (enabled) { - SP::following_dwarf = SP::FollowADwarf(); - SP::PrintStatus(out); +#define DUMP_BUCKET(name) \ + DEBUG(cycle,out).print("bucket: " #name ", size: %zd\n", name.size()); \ + if (debug_cycle.isEnabled(DebugCategory::LTRACE)) { \ + for (auto u : name) { \ + DEBUG(cycle,out).print(" unit %d: %s\n", u->id, DF2CONSOLE(Units::getReadableName(u)).c_str()); \ + } \ } - return DFHack::CR_OK; -} -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { - if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { - out.printerr("Cannot run %s without a loaded fort.\n", plugin_name); - return CR_FAILURE; +#define DUMP_FLOAT_VECTOR(name) \ + DEBUG(cycle,out).print(#name ":\n"); \ + for (float f : name) { \ + DEBUG(cycle,out).print(" %d\n", (int)f); \ } - if (enable && !enabled) { - out.print("Spectate mode enabled!\n"); - enabled = true; // enable_auto_unpause won't do anything without this set now - SP::SetUnpauseState(config.unpause); - } else if (!enable && enabled) { - // warp 8, engage! - out.print("Spectate mode disabled!\n"); - // we need to retain whether auto-unpause is enabled, but we also need to disable its effect - bool temp = config.unpause; - SP::SetUnpauseState(false); - config.unpause = temp; +static const float ACTIVE_COMBAT_PREFERRED_WEIGHT = 25.0f; +static const float PASSIVE_COMBAT_PREFERRED_WEIGHT = 8.0f; +static const float JOB_WEIGHT = 3.0f; +static const float OTHER_WEIGHT = 1.0f; + +static void follow_a_dwarf(color_ostream &out) { + DEBUG(cycle,out).print("choosing a unit to follow\n"); + + vector active_combat_units; + vector passive_combat_units; + vector job_units; + vector other_units; + get_dwarf_buckets(out, active_combat_units, passive_combat_units, job_units, other_units); + + next_cycle_unpaused_ms = get_next_cycle_unpaused_ms(!active_combat_units.empty()); + + // coalesce the buckets and add weights + vector units; + vector intervals; + vector weights; + intervals.push_back(0); + add_bucket(active_combat_units, units, intervals, weights, config.prefer_conflict ? ACTIVE_COMBAT_PREFERRED_WEIGHT : JOB_WEIGHT); + add_bucket(passive_combat_units, units, intervals, weights, config.prefer_conflict ? PASSIVE_COMBAT_PREFERRED_WEIGHT : JOB_WEIGHT); + add_bucket(job_units, units, intervals, weights, JOB_WEIGHT); + add_bucket(other_units, units, intervals, weights, OTHER_WEIGHT); + + if (units.empty()) { + DEBUG(cycle,out).print("no units to follow\n"); + return; } - enabled = enable; - return DFHack::CR_OK; -} -DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { - if (enabled) { - switch (event) { - case SC_WORLD_UNLOADED: - SP::our_dorf = nullptr; - SP::following_dwarf = false; - enabled = false; - default: - break; - } + std::piecewise_constant_distribution distribution(intervals.begin(), intervals.end(), weights.begin()); + int unit_idx = distribution(rng); + df::unit *unit = units[unit_idx]; + + if (debug_cycle.isEnabled(DebugCategory::LDEBUG)) { + DUMP_BUCKET(active_combat_units); + DUMP_BUCKET(passive_combat_units); + DUMP_BUCKET(job_units); + DUMP_BUCKET(other_units); + DUMP_FLOAT_VECTOR(intervals); + DUMP_FLOAT_VECTOR(weights); + DEBUG(cycle,out).print("selected unit idx %d\n", unit_idx); } - return CR_OK; -} -DFhackCExport command_result plugin_onupdate(color_ostream &out) { - SP::onUpdate(out); - return DFHack::CR_OK; + DEBUG(cycle,out).print("now following unit %d: %s\n", unit->id, Units::getReadableName(unit).c_str()); + plotinfo->follow_unit = unit->id; } -command_result spectate (color_ostream &out, std::vector & parameters) { - if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { - out.printerr("Cannot run %s without a loaded fort.\n", plugin_name); - return CR_FAILURE; - } - - 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"){ - SP::SetUnpauseState(state); - } else if (parameters[1] == "auto-disengage") { - config.disengage = state; - } else if (parameters[1] == "animals") { - config.animals = state; - } else if (parameters[1] == "hostiles") { - config.hostiles = state; - } else if (parameters[1] == "visiting") { - config.visitors = state; - } else if (parameters[1] == "tick-threshold" && set && parameters.size() == 3) { - try { - config.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; - } +///////////////////////////////////////////////////// +// Lua API +// + +static void spectate_setSetting(color_ostream &out, string name, int val) { + DEBUG(control,out).print("entering spectate_setSetting %s = %d\n", name.c_str(), val); + + if (name == "auto-disengage") { + config.auto_disengage = val; + } else if (name == "auto-unpause") { + if (val && !config.auto_unpause) { + save_announcement_settings(out); + scrub_announcements(out); + } else if (!val && config.auto_unpause) { + restore_announcement_settings(out); + } + config.auto_unpause = val; + } else if (name == "cinematic-action") { + config.cinematic_action = val; + } else if (name == "include-animals") { + config.include_animals = val; + } else if (name == "include-hostiles") { + config.include_hostiles = val; + } else if (name == "include-visitors") { + config.include_visitors = val; + } else if (name == "include-wildlife") { + config.include_wildlife = val; + } else if (name == "prefer-conflict") { + config.prefer_conflict = val; + } else if (name == "prefer-new-arrivals") { + config.prefer_new_arrivals = val; + } else if (name == "follow-seconds") { + if (val <= 0) { + WARN(control,out).print("follow-seconds must be a positive integer\n"); + return; } + config.follow_ms = val * 1000; } else { - SP::PrintStatus(out); + WARN(control,out).print("Unknown setting: %s\n", name.c_str()); } - SP::SaveSettings(); - return DFHack::CR_OK; } + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(spectate_setSetting), + DFHACK_LUA_END +}; From 516296d7c3351c778cfd042e12c20dbd52b25d2d Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Mon, 3 Feb 2025 04:14:04 -0800 Subject: [PATCH 5/8] add global keybinding to toggle spectate --- data/init/dfhack.keybindings.init | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/init/dfhack.keybindings.init b/data/init/dfhack.keybindings.init index f4068a5cd1..1fa7b9facc 100644 --- a/data/init/dfhack.keybindings.init +++ b/data/init/dfhack.keybindings.init @@ -49,6 +49,9 @@ keybinding add Ctrl-T@dwarfmode/ViewSheets/UNIT|dwarfmode/ViewSheets/ITEM|dungeo # quicksave keybinding add Ctrl-Alt-S@dwarfmode quicksave +# toggle spectate +keybinding add Ctrl-Shift-S@dwarfmode/Default "spectate toggle" + # designate the whole vein for digging keybinding add Ctrl-V@dwarfmode digv keybinding add Ctrl-Shift-V@dwarfmode "digv x" From 70a02dd9c6f17080dd2d90845de797d3b18c1b3e Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Mon, 3 Feb 2025 04:27:18 -0800 Subject: [PATCH 6/8] update changelog --- docs/changelog.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.txt b/docs/changelog.txt index 3668383d2b..80b8c76045 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -55,10 +55,14 @@ Template for new versions: ## New Tools ## New Features +- `spectate`: can now specify number of seconds (in real time) before switching to follow a new unit unit +- `spectate`: new "cinematic-action" mode that dynamically speeds up perspective switches based on intensity of conflict ## Fixes +- `spectate`: don't allow temporarily modified announcement settings to be written to disk when "auto-unpause" mode is enabled ## Misc Improvements +- `spectate`: player-set configuration is now stored globally instead of per-fort ## Documentation From b934af10a892d05faf15e537a6a74113dbef5bfc Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Mon, 3 Feb 2025 04:33:58 -0800 Subject: [PATCH 7/8] add changelog for global keybinding --- docs/changelog.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.txt b/docs/changelog.txt index 80b8c76045..d521d96745 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -57,6 +57,7 @@ Template for new versions: ## New Features - `spectate`: can now specify number of seconds (in real time) before switching to follow a new unit unit - `spectate`: new "cinematic-action" mode that dynamically speeds up perspective switches based on intensity of conflict +- `spectate`: new global keybinding for toggling spectate mode: Ctrl-Shift-S ## Fixes - `spectate`: don't allow temporarily modified announcement settings to be written to disk when "auto-unpause" mode is enabled From c762108dade1d7b5a19bd29117b28aaf18bdd38a Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Mon, 3 Feb 2025 04:36:01 -0800 Subject: [PATCH 8/8] update scripts ref --- scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts b/scripts index 6a952903e7..b6e8e30fb5 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit 6a952903e72199e469b44e0c74764c1c89f4b2e3 +Subproject commit b6e8e30fb5f98d1f1925f988675471a44a464375