Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/plugins/labormanager.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,34 @@ history shapes specialization:
(smithing, crafting, cooking, mechanics, engraving, and so on) are
preferred for higher-skilled workers -- both when choosing a specialty
and when deciding who to keep out of the laborer pool.
* **Assignment stickiness.** A unit assigned to a skill detail keeps the
assignment while any work for that skill remains -- including claimed
jobs, the fetch-materials phase, and breaks -- so jobs aren't aborted
by reassignment churn. For tool-carrying labors (mining, woodcutting,
hunting) this also prevents dropping and re-equipping tools between
cycles. Assignments are recorded in the save data and restored on load.
* **Builtin tool details.** DF v50 only equips tools for members of the
game's predefined work details, so mining, woodcutting, and hunting
specialists are assigned to the builtin Miners, Woodcutters, and
Hunters details instead of plugin-created ones. Memberships added by
the plugin are tracked and removed cleanly on disable; members you
assigned yourself are never touched.
* **Strange-mood shaping.** Dwarves that can still have a strange mood
(their race has the ``STRANGE_MOODS`` token and they haven't had one
yet) and that have already started a moodable craft are steered toward
skills a strange mood can advance -- preferably valuable ones like
weaponsmithing or armorsmithing -- so a mood pays out in a useful
legendary skill.

Workshop restrictions are honored as well: when a job is posted at a
workshop that has a permitted-worker list, the engine ensures at least
one listed worker is assigned the job's labor, and when the workshop
restricts jobs by skill level it ensures at least one worker within the
allowed range holds that labor. Units on any workshop's permitted list
are treated as reserved for that shop's work -- they are preferentially
assigned the labors its jobs require and deprioritized for the laborer
pool and unrelated specialties.

Labormanager also reads each citizen's unmet personal needs and biases
assignments toward work that satisfies them:

Expand Down Expand Up @@ -146,6 +167,13 @@ Advanced usage
command exists under `autolabor`. In monitor mode the plugin performs
no labor management -- all work details are left to the player -- but
still tracks the job board and shows the task starvation warning.
``labormanager dump``
Print a diagnostic snapshot of everything the engine can see: every
job and posting on the board with its flags and resolved labor, the
per-labor demand and starvation streaks, skill usage, tool counts,
work detail memberships, and each citizen's state, pins, and enabled
labors. Intended for bug reports; also available as
``autolabor dump``, and meaningful in monitor mode.

Monitor mode (``labormanager mode monitor`` or the work details overlay)
runs the same tracking without any labor management, for players who want
Expand Down
16 changes: 16 additions & 0 deletions plugins/autolabor/autolabor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ command_result autolabor_cmd(color_ostream &out, std::vector<std::string> &param
if (mode_command(out, parameters))
return CR_OK;

if (parameters.size() == 1 && parameters[0] == "dump")
{
out.print("=== autolabor state dump ===\n");
out.print("enabled={} mode={}\n", (int)is_enabled, engine_mode());
autolabor::dump_engine_state(out);
return CR_OK;
}

if (parameters.size() == 1 &&
(parameters[0] == "0" || parameters[0] == "enable" ||
parameters[0] == "1" || parameters[0] == "disable"))
Expand Down Expand Up @@ -258,6 +266,14 @@ command_result labormanager_cmd(color_ostream &out, std::vector<std::string> &pa
return plugin_enable(out, enable);
}

if (parameters.size() == 1 && parameters[0] == "dump")
{
out.print("=== autolabor state dump ===\n");
out.print("enabled={} mode={}\n", (int)is_enabled, engine_mode());
autolabor::dump_engine_state(out);
return CR_OK;
}

// in monitor mode the monitor engine handles status/list; everything
// else (balance, labor config, ...) falls through to the modern dialect
if (engine_mode() == MODE_MONITOR && monitor_engine.command(out, parameters))
Expand Down
20 changes: 15 additions & 5 deletions plugins/autolabor/joblabormapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,10 @@ class jlfunc_make : public jlfunc
{
case df::workshop_type::Craftsdwarfs:
{
// v50: stone blocks are stonecutting work, even when they are
// made at a craftsdwarf's workshop
if (j->job_type == df::job_type::ConstructBlocks)
return df::unit_labor::STONECUTTER;
df::item_type jobitem = j->job_items.elements[0]->item_type;
switch (jobitem)
{
Expand Down Expand Up @@ -575,7 +579,13 @@ class jlfunc_make : public jlfunc
}
}
case df::workshop_type::Masons:
return df::unit_labor::MASON;
// v50: the stoneworker's workshop no longer uses the masonry
// labor; stonecutters cut blocks there and stone carvers make
// everything else (masonry is now only for constructing
// buildings out of stone)
if (j->job_type == df::job_type::ConstructBlocks)
return df::unit_labor::STONECUTTER;
return df::unit_labor::STONE_CARVER;
case df::workshop_type::Carpenters:
return df::unit_labor::CARPENTER;
case df::workshop_type::Leatherworks:
Expand Down Expand Up @@ -692,7 +702,7 @@ JobLaborMapper::JobLaborMapper()

jlfunc* jlf_no_labor = jlf_const(df::unit_labor::NONE);

job_to_labor_table[df::job_type::CarveFortification] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::CarveFortification] = jlf_const(df::unit_labor::STONECUTTER);
job_to_labor_table[df::job_type::DetailWall] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::DetailFloor] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::Dig] = jlf_const(df::unit_labor::MINE);
Expand Down Expand Up @@ -910,7 +920,7 @@ JobLaborMapper::JobLaborMapper()
job_to_labor_table[df::job_type::ReportCrime] = jlf_no_labor;
job_to_labor_table[df::job_type::ExecuteCriminal] = jlf_no_labor;
job_to_labor_table[df::job_type::TrainAnimal] = jlf_const(df::unit_labor::ANIMALTRAIN);
job_to_labor_table[df::job_type::CarveTrack] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::CarveTrack] = jlf_const(df::unit_labor::STONECUTTER);
job_to_labor_table[df::job_type::PushTrackVehicle] = jlf_const(df::unit_labor::HANDLE_VEHICLES);
job_to_labor_table[df::job_type::PlaceTrackVehicle] = jlf_const(df::unit_labor::HANDLE_VEHICLES);
job_to_labor_table[df::job_type::StoreItemInVehicle] = jlf_hauling;
Expand All @@ -932,8 +942,8 @@ JobLaborMapper::JobLaborMapper()
job_to_labor_table[df::job_type::AcceptHeistItem] = jlf_no_labor; // added for 47.04 - see #1561

// v50 additions
job_to_labor_table[df::job_type::SmoothWall] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::SmoothFloor] = jlf_const(df::unit_labor::ENGRAVER);
job_to_labor_table[df::job_type::SmoothWall] = jlf_const(df::unit_labor::STONECUTTER);
job_to_labor_table[df::job_type::SmoothFloor] = jlf_const(df::unit_labor::STONECUTTER);
job_to_labor_table[df::job_type::PolishStones] = jlf_const(df::unit_labor::STONE_CRAFT);
job_to_labor_table[df::job_type::ConstructBag] = jlf_const(df::unit_labor::LEATHER);
job_to_labor_table[df::job_type::EncrustWithStones] = jlf_const(df::unit_labor::ENCRUST_GEM);
Expand Down
126 changes: 126 additions & 0 deletions plugins/autolabor/laborcommon.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#include "laborcommon.h"

#include <map>
#include <set>
#include <sstream>

#include "joblabormapper.h"

#include "Debug.h"

#include "modules/Units.h"
#include "modules/World.h"

Expand Down Expand Up @@ -27,6 +35,10 @@ using namespace df::enums;
using df::global::plotinfo;
using df::global::world;

namespace DFHack {
DBG_DECLARE(autolabor, labor_probe, DebugCategory::LINFO);
}

namespace autolabor {

PersistentDataItem plugin_config;
Expand Down Expand Up @@ -322,4 +334,118 @@ int total_skill(df::unit *u)
return total;
}

// ---------------------------------------------------------------------------
// labor probe
//
// The v50 stoneworker labor split (stonecutter / stone carver / mason /
// engraver / stonecrafter) was re-derived from documentation rather than
// verified against the game. The game only lets a unit claim a job if it
// has the job's true gating labor enabled, so logging which of the disputed
// labors are enabled on each observed worker lets A/B testing identify the
// real mapping (e.g. a unit making rock blocks with STONECUTTER on and
// MASON off confirms the mapping; an enabled mask that consistently
// disagrees with `mapped` flags a wrong guess).
// ---------------------------------------------------------------------------

const std::vector<df::unit_labor> disputed_labors = {
df::unit_labor::STONECUTTER,
df::unit_labor::STONE_CARVER,
df::unit_labor::MASON,
df::unit_labor::ENGRAVER,
df::unit_labor::STONE_CRAFT,
};

static JobLaborMapper *probe_mapper = nullptr;
// (job id, unit id) -> enabled-labor bitmask; only new or changed
// combinations are logged so the channel doesn't spam every cycle
static std::map<std::pair<int32_t, int32_t>, int> probe_seen;
// (unit id, job type) pairs already warned about for a mapped-labor
// mismatch; each unit/job-type combination warns once
static std::set<std::pair<int32_t, int32_t>> probe_warned;

void probe_labor_observation(df::unit *u)
{
df::job *j = u->job.current_job;
if (!j)
return;
if (!probe_mapper)
probe_mapper = new JobLaborMapper();

df::unit_labor mapped = probe_mapper->find_job_labor(j);

// strong mismatch signal independent of the disputed set: the unit is
// doing the job without the labor we mapped it to. the game gates job
// claiming on the real labor, so either our mapping is wrong or the job
// accepts multiple labors and we picked the wrong one -- either way the
// unit's enabled labors hold the better candidate. (a claimed job can
// also outlive a labor removal, so this is only a *possible* mismatch)
if (mapped > unit_labor::NONE && mapped < NUM_LABORS &&
!u->status.labors[mapped])
{
auto wkey = std::make_pair(u->id, (int32_t)j->job_type);
if (!probe_warned.count(wkey))
{
probe_warned.insert(wkey);
std::stringstream es;
bool comma = false;
FOR_ENUM_ITEMS(unit_labor, l)
{
if (l == unit_labor::NONE || !u->status.labors[l])
continue;
if (comma)
es << ',';
es << ENUM_KEY_STR(unit_labor, l);
comma = true;
}
INFO(labor_probe).print(
"possible labor mapping mismatch: unit {} ({}) is doing job "
"{} {} (mapped {}) without that labor enabled; enabled "
"labors: {{{}}}\n",
u->id, Units::getReadableName(u), j->id,
ENUM_KEY_STR(job_type, j->job_type),
ENUM_KEY_STR(unit_labor, mapped),
comma ? es.str() : std::string("NONE"));
}
}

const int n_disputed = disputed_labors.size();
int mask = 0;
bool in_cluster = false;
for (int i = 0; i < n_disputed; i++)
{
if (disputed_labors[i] == mapped)
in_cluster = true;
if (u->status.labors[disputed_labors[i]])
mask |= 1 << i;
}
if (!in_cluster)
return;

auto key = std::make_pair(j->id, u->id);
auto seen = probe_seen.find(key);
if (seen != probe_seen.end() && seen->second == mask)
return;
probe_seen[key] = mask;

std::stringstream ss;
bool comma = false;
for (int i = 0; i < n_disputed; i++)
{
if (!(mask & (1 << i)))
continue;
if (comma)
ss << ',';
ss << ENUM_KEY_STR(unit_labor, disputed_labors[i]);
comma = true;
}
if (!comma)
ss << "NONE";

TRACE(labor_probe).print(
"unit {} ({}) job {} {}: mapped={} enabled={{{}}}\n",
u->id, Units::getReadableName(u), j->id,
ENUM_KEY_STR(job_type, j->job_type),
ENUM_KEY_STR(unit_labor, mapped), ss.str());
}

}
12 changes: 12 additions & 0 deletions plugins/autolabor/laborcommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,16 @@ dwarf_state get_dwarf_state(df::unit *u);
int top_skill(df::unit *u, df::job_skill *skill_out = nullptr);
int total_skill(df::unit *u);

// labors whose v50 job->labor mappings were re-derived rather than verified
// against the game (the stoneworker split); the labor probe and the modern
// engine's mismatch escalation both work from this list
extern const std::vector<df::unit_labor> disputed_labors;

// labor-probe instrumentation: if the unit is doing a job that maps to a
// labor whose job->labor mapping is disputed, log which of the disputed
// labors are enabled on the unit (via the "labor_probe" debug channel).
// the true gating labor must be enabled on the worker, so comparing
// observations across units identifies the labor the game actually uses.
void probe_labor_observation(df::unit *u);

}
2 changes: 2 additions & 0 deletions plugins/autolabor/legacyengine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,8 @@ void LegacyEngine::update(color_ostream &out)

state_count[dwarf_info[dwarf].state]++;

autolabor::probe_labor_observation(dwarfs[dwarf]);

TRACE(legacy_cycle, out).print("Dwarf {} \"{}\": penalty {}, state {}\n",
dwarf, dwarfs[dwarf]->name.first_name, dwarf_info[dwarf].mastery_penalty, state_names[dwarf_info[dwarf].state]);
}
Expand Down
Loading
Loading