Here is a script I'm using to track stuff over time in fortress mode. It writes metrics to a CSV output, can track several different metrics, and is intended to be used with dfhack's repeat function.
Can monitor:
- Total Experience in skills
- Mental and Physical attributes
- Personality facets
- Stress
- Focus
- Needs
- Beliefs (where they differ from the cultural norm)
- All of the above
Output:
Writes to CSV (delimited by ","). Keeps a record of local timestamp, DF game timestamp (YY-MM-DD), a customizable "test tag", the dwarf name, and metrics. Metrics are written into a KeyValue Pair blob (Focus=90,Stress=3562).
Example output line:
2018-08-20_15:06:44,10-05-02,NoTag,`Hauler' Al†thkab(2135),Focus=90,Stress=3562,
Known Problems:
- Output. The KVP style metric output makes it easy to know what the metrics are, somewhat challenging to graph.
- I'm not sure about the best way to show needs actual strength.
-- Track changes to DF dorf stats
-- Monitor 1.2
-- Track changes by writing to a file
-- Fortress mode script called from DFHack
-- List functions from vjek's make-legendary
-- https://dfhack.readthedocs.io/en/stable/docs/_auto/base.html#make-legendary
-- LNP: [04412r02-x64]
local utils=require('utils')
validArgs = utils.invert({
'help',
'verbose',
'outputfile',
'targets',
'watch',
'tag',
'noLog',
'noTranslate'
})
local args = utils.processArgs({...}, validArgs)
local verbose = false
local translate = true
local noLog = false
local outputfile = "record.txt"
local targets = "all"
local watch = "skills"
local tag = "NoTag"
local watchTable = {"skills"}
local targetTable = {"all"}
local helpme = [===[
monitor.lua
=========
This script writes a record of dwarf stats to file. Stats are recorded in key-value pairs, so some spreadsheet kung-fu is required.
This script is intended to be used with the dfhack repeat function to record stats over time.
arguments:
-help
print this help message
-noLog
Don't write to the log file. Defaults to false (always write to log)
-outputfile
The filename to write to. Defaults to record.txt in the main DF directory
-tag
A settable string used to differentiate multiple collections runs. Examples: "Test1:NoTavern", "Test2:NoAlcohol"
-targets
A comma separated list of unit ids to monitor. Also takes all. Defaults to all, which is all citizens.
-noTranslate
Disable translation of names to English for display and write.
-watch
A comma separated list of stats you want to gather. Valid options are:
skills: Default. The total experience in all skills
attributes: The values for physical and mental attributes
personality: The values for all personality facets
stress: The unit stress level (integer)
focus: The unit focus as a percentage: (current_focus/undistracted_focus)*100
needs: The active need levels for a unit (1-10). 1 seems to be the highest
beliefs: The active beliefs for a unit
relations: The relationship strength for non-family/deity relations
all: All of the above. VERY VERBOSE.
-verbose
prints debug data to the dfhack console. Default is false
Examples:
Record the stress levels for all dwarves to the screen (once) with their untranslated names
monitor -watch stress -verbose -translate false
Record the stress levels for all dwarves (once)
monitor -watch stress
Record the skills of all dwarves to myfile.txt using the tag "Trial_1" (once)
monitor -tag Trial_1 -outputfile myfile.txt
Record the stress and focus levels for units 592 and 2135 every day
repeat -name monitor -time 1 -timeUnits days -command [ "monitor -targets 592,2135 -watch focus,stress" ]
]===]
-- Handle help requests
if args.help then
print(helpme)
return
end
if ( args.verbose ) then
verbose = true
end
if ( args.noTranslate ) then
translate = false
end
if ( args.noLog ) then
noLog = true
end
if ( args.targets ) then
targetTable = {}
-- Set the units to watch
for i in (args.targets .. ","):gmatch("([^,]*),") do
-- if verbose then print("Targeting: ".. i) end
table.insert(targetTable, i)
end
end
if ( args.tag ) then
tag = string.gsub(args.tag, "%s+", "")
tag = string.gsub(tag, ",", "")
end
if ( args.watch ) then
watchTable = {}
-- Set the groups to watch
for i in (args.watch .. ","):gmatch("([^,]*),") do
-- if verbose then print("Watching Group: ".. i) end
table.insert(watchTable, i)
end
end
if( args.outputfile ) then
outputfile = args.outputfile
end
function printTable(ts, df_ts, uname, tag, o)
-- Now write all the data into an output file
-- Expects a timestamp, a dwarf fortress timestamp, the dwarf's name, the run tag, and a data table
io.write(ts .. "," .. df_ts .. "," .. tag .. "," .. uname .. ",")
for k,v in ipairs(o) do
io.write(string.gsub(v[2], "%s+", "").."="..v[3]..",")
end
io.write("\n")
end
function count_this(to_be_counted)
local count = -1
local var1 = ""
while var1 ~= nil do
count = count + 1
var1 = (to_be_counted[count])
end
count=count-1
return count
end
function PrintSkillList()
local count_max = count_this(df.job_skill)
local i
for i=0, count_max do
print("'"..df.job_skill.attrs[i].caption.."' "..df.job_skill[i].." Type: "..df.job_skill_class[df.job_skill.attrs[i].type])
end
end
function PrintSkillClassList()
local i
local count_max = count_this(df.job_skill_class)
for i=0, count_max do
print(df.job_skill_class[i])
end
end
function PrintNeedList()
local i
local count_max = count_this(df.need_type)
for i=0, count_max do
print(df.need_type[i])
end
end
function PrintBeliefList(unit)
local i
local count_max = count_this(df.value_type)
for i=0, count_max do
print(df.value_type[i])
end
end
function GetUnitSkills(unit, unitSkillTable)
-- Get the unit's skills
local i
local tmpUnitSkillTable={}
local count_max = count_this(df.job_skill)
-- We need to add all the possible skills to a table with a default level and xp
for i=0, count_max do
-- Expected table structure is {id, skill caption, experience }
local skill_name=string.lower(df.job_skill.attrs[i].caption)
skill_name = string.gsub(skill_name, "^%l", string.upper)
skill_name = string.gsub(skill_name, "%s+", "_")
skill_name = string.gsub(skill_name, "[.-]", "_")
skill_name = string.gsub(skill_name, "_%l", string.upper)
skill_name = string.gsub(skill_name, "_", "")
table.insert(tmpUnitSkillTable,{i,skill_name,0})
end
-- Unit skills are sparsely populated.
-- We now need to loop over the unit skills and overwrite the default values with actual values
-- DF starts indexes at zero, LUA starts tables indexes at 1
for i=0, #unit.status.current_soul.skills-1 do
tbl_skill = unit.status.current_soul.skills[i].id + 1
skill_id = unit.status.current_soul.skills[i].id
skill_lvl = unit.status.current_soul.skills[i].rating
skill_exp = unit.status.current_soul.skills[i].experience
local skill_name = tmpUnitSkillTable[tbl_skill][2]
-- Experience formula courtesy of Maklak
-- http://www.bay12forums.com/smf/index.php?topic=66525.msg3953394#msg3953394
total_xp = math.floor(500*skill_lvl + 100*(skill_lvl*(skill_lvl-1))/2) + skill_exp
if verbose then print (skill_id, " ", skill_lvl, " ", skill_exp, " ", total_xp ) end
-- Overwrite the default skill table with unit specific skill levels
tmpUnitSkillTable[tbl_skill] = {skill_id,skill_name,total_xp}
end
-- Replay table into target data structure.
for i=1, #tmpUnitSkillTable do
if verbose then print (i,tmpUnitSkillTable[i][1],tmpUnitSkillTable[i][2],tmpUnitSkillTable[i][3]) end
table.insert(unitSkillTable,{i,tmpUnitSkillTable[i][2],tmpUnitSkillTable[i][3]})
end
end
function GetUnitAttributes(unit, unitAttributeTable)
-- Get the unit's mental and physical attributes
-- Unit is expected to be unit id
local i = 0
for k,v in pairs(unit.status.current_soul.mental_attrs) do
local attr_name = string.lower(tostring(k))
attr_name = string.gsub(attr_name, "^%l", string.upper)
attr_name = string.gsub(attr_name, "_%l", string.upper)
attr_name = string.gsub(attr_name, "_", "")
if verbose then print("Current value for "..attr_name.." is "..v.value) end
table.insert(unitAttributeTable,{k,attr_name,v.value})
end
for k,v in pairs(unit.body.physical_attrs) do
local attr_name = string.lower(tostring(k))
attr_name = string.gsub(attr_name, "^%l", string.upper)
attr_name = string.gsub(attr_name, "_%l", string.upper)
attr_name = string.gsub(attr_name, "_", "")
if verbose then print("Current value for "..attr_name.." is "..v.value) end
table.insert(unitAttributeTable,{k,attr_name,v.value})
end
end
function GetUnitPersonality(unit, unitPersonalityTable)
-- Get the unit's personality. Apply temporary trait overlays
-- Unit is expected to be unit id
local i = 0
local tmpUnitPersonalityTable= {}
for k,v in pairs(unit.status.current_soul.personality.traits) do
local trait_name = string.lower(tostring(k))
trait_name = string.gsub(trait_name, "^%l", string.upper)
trait_name = string.gsub(trait_name, "_%l", string.upper)
trait_name = string.gsub(trait_name, "_", "")
if verbose then print("Current value for " .. trait_name.." is "..v) end
if(unit.status.current_soul.personality.temporary_trait_changes == nil) then
-- Handle things with no trait changes
if verbose then print("Final value for " .. trait_name.." is "..v) end
table.insert(unitPersonalityTable,{i,trait_name,offset})
else
table.insert(tmpUnitPersonalityTable,{i,trait_name,v})
end
i = i + 1
end
if(unit.status.current_soul.personality.temporary_trait_changes == nil) then
-- Exiting early
print("Leaving personality w/o doing temporary_trait_changes")
return
end
i = 0
for k,v in pairs(unit.status.current_soul.personality.temporary_trait_changes) do
tbl_index = i + 1
counter = tmpUnitPersonalityTable[tbl_index][1]
trait_name = tmpUnitPersonalityTable[tbl_index][2]
offset = tmpUnitPersonalityTable[tbl_index][3] + v
if verbose then print("Offset:" .. trait_name .. " is " .. offset) end
table.insert(unitPersonalityTable,{i,trait_name,offset})
i = i + 1
end
end
function GetUnitStress(unit, unitStressTable)
-- Get the unit's stress
if verbose then print("Current value for Stress is "..unit.status.current_soul.personality.stress_level) end
table.insert(unitStressTable,{1,"Stress",unit.status.current_soul.personality.stress_level})
end
function GetUnitFocus(unit, unitFocusTable)
-- Get the unit focus as a percent
local focus = math.floor((unit.status.current_soul.personality.current_focus/unit.status.current_soul.personality.undistracted_focus)*100)
if verbose then print("Current value for Focus is "..focus .. "%:" .. unit.status.current_soul.personality.current_focus .. "/" .. unit.status.current_soul.personality.undistracted_focus) end
table.insert(unitFocusTable,{1,"Focus",focus})
end
function GetUnitNeeds(unit, unitNeedsTable)
-- Get the unit's needs
local i
local tmpUnitNeedsTable={}
local count_max = count_this(df.need_type)
-- We need to add all the possible needs to a table
for i=0, count_max do
-- Expected table structure is {id, need name, 0 }
table.insert(tmpUnitNeedsTable,{i,df.need_type[i],0})
end
for i=0, #unit.status.current_soul.personality.needs-1 do
tbl_index = unit.status.current_soul.personality.needs[i].id + 1
needs_name = tmpUnitNeedsTable[tbl_index][2]
-- need_level is just a counter, not a multiplier
needs_lvl = unit.status.current_soul.personality.needs[i].need_level
needs_focus = unit.status.current_soul.personality.needs[i].focus_level
-- This allows us to track the needs for multiple deities. Comparison will break if spelling is ever corrected
if(needs_name == "PrayOrMedidate") then
needs_name = needs_name .. "(" .. unit.status.current_soul.personality.needs[i].deity_id .. ")"
end
if verbose then print(tbl_index,needs_name,needs_lvl,needs_focus) end
-- I'm hopeing that new needs will gracefully be added?
table.insert(unitNeedsTable,{i,needs_name,needs_focus})
end
end
function GetUnitBeliefs(unit, unitBeliefTable)
-- Get the cultural beliefs, then overlay with the unit's personal beliefs.
-- Courtesy of Atkana and the DFHack thread
-- http://www.bay12forums.com/smf/index.php?topic=164123.msg7836773#msg7836773
local j = 1
local tmpUnitBeliefTable={}
-- Check for the unit's culture in all the world's cultures.
for i=0, #df.global.world.cultural_identities.all do
if(unit.civ_id == df.global.world.cultural_identities.all[i].civ_id ) then
for k,v in pairs(df.global.world.cultural_identities.all[i].values) do
-- Break when we get to unnamed entries. Unknown why they are there.
if(#tostring(k)<3) then break end
if verbose then print("Cultural",j,k,v) end
table.insert(tmpUnitBeliefTable,{j,k,v})
j = j + 1
end
-- Stop after the first civ match
break
end
end
for i=0, #unit.status.current_soul.personality.values-1 do
local tbl_index = unit.status.current_soul.personality.values[i].type + 1
if verbose then print("Personal",i,tbl_index,tmpUnitBeliefTable[tbl_index][2],unit.status.current_soul.personality.values[i].strength) end
tmpUnitBeliefTable[tbl_index] = {tbl_index,tmpUnitBeliefTable[tbl_index][2],unit.status.current_soul.personality.values[i].strength}
end
-- Replay table into target data structure.
for i=1, #tmpUnitBeliefTable do
local belief_name = string.lower(tmpUnitBeliefTable[i][2])
belief_name = string.gsub(belief_name, "^%l", string.upper)
belief_name = string.gsub(belief_name, "_%l", string.upper)
belief_name = string.gsub(belief_name, "_", "")
if verbose then print (i,tmpUnitBeliefTable[i][1],belief_name,tmpUnitBeliefTable[i][3]) end
table.insert(unitBeliefTable,{i,belief_name,tmpUnitBeliefTable[i][3]})
end
end
function GetUnitRelations(unit, unitRelationsTable)
-- Get the various relations for a unit. Goal here is to track relation strength
-- These are stored against the historical id, not the unit id. Some conversion is necessary.
-- Courtesy of PatrickLundell and the DFHack thread
-- http://www.bay12forums.com/smf/index.php?topic=172722.msg7896495#msg7896495
-- Initialize variables
local i = 0
local tmpUnitRelationsTable = {}
-- Convert unit id to historical id
local hf = df.historical_figure.find(unit.hist_figure_id)
-- confirm unit actually has relationships for listing
if(hf.info.relationships == nil ) then
return
-- Just stop
end
-- Get a list of unit's relation ids and strengths
-- Presumably, rank 0 means no relation
for i=0, #hf.info.relationships.list-1 do
if( hf.info.relationships.list[i].rank > 0) then
local r_histfig_id = hf.info.relationships.list[i].histfig_id
local r_local_id = df.historical_figure.find(hf.info.relationships.list[i].histfig_id).unit_id
local r_attitude = 0
local r_counter = 0
-- Does work: Testing for array length; Doesn't work: testing for next array element, testing for array, testing for exist
local attitude = #hf.info.relationships.list[i].attitude
if attitude >0 then
r_attitude = hf.info.relationships.list[i].attitude[0]
r_counter = hf.info.relationships.list[i].counter[0]
end
local r_rank = hf.info.relationships.list[i].rank
local myString = r_rank .. "(" .. r_attitude .. ")"
-- Always convert historical ids back to unit ids
-- How should offsite/never arrived stuff work here?
-- Expected format here is #, local unit name, rank (attitude)
table.insert(unitRelationsTable,{i,dfhack.TranslateName(df.unit.find(r_local_id).name, translate),myString})
if verbose then print ("id:" .. i .. "|" .. dfhack.TranslateName(df.unit.find(r_local_id).name, translate) .. ": rank=" .. r_rank .. ", attitude=" .. r_attitude .. ", r_counter=" .. r_counter) end
end
end
end
function GetDFDate()
-- Handle the formatting of the Dwarf Fortress date
-- Thanks to Kurik Amudnil for the date stuff
-- http://www.bay12forums.com/smf/index.php?PHPSESSID=669fc6cc7664043c4b34992a301abb0c&topic=91166.msg4247785#msg4247785
-- Would it be useful to return a part of the DF date?
-- local absTick = 1200*28*12*df.global.cur_year + df.global.cur_year_tick
local dfYear = df.global.cur_year
local dfMonth = math.floor((df.global.cur_year_tick / 33600) + 1)
local dfDay = math.floor((df.global.cur_year_tick % 33600)/1200)+1
if(dfMonth < 10) then
dfMonth = "0"..dfMonth
end
if(dfDay < 10) then
dfDay = "0"..dfDay
end
dfDateString = dfYear .. "-" .. dfMonth .. "-" .. dfDay
return dfDateString
end
function SetTargets(targetTable)
local tmpTargetTable = {}
local citizenTable={}
for k,u in ipairs(df.global.world.units.active) do
if (dfhack.units.isCitizen(u)) then
table.insert(citizenTable,u)
end
end
for k, v in pairs(targetTable) do
local check = targetTable[k]
if verbose then print("Targeting: "..check) end
if (string.lower(check) == string.lower("all")) then
tmpTargetTable = citizenTable
break -- Break out of the loop
elseif tonumber(check) ~= nil then
table.insert(tmpTargetTable, df.unit.find(check))
else
qerror('Error: Unhandled string. Was expecting a unit id or all')
end
end
-- Overwrite target table with new targets
targetTable = tmpTargetTable
return targetTable
end
function SetWatchGroups(watchTable)
local tmpWatchTable = {}
for k, v in pairs(watchTable) do
local check = watchTable[k]
if verbose then print(check) end
if string.lower(check) == string.lower('all') then
-- Reset the table, only do all
tmpWatchTable = {}
table.insert(tmpWatchTable, "all")
break -- Break out of the loop
elseif ( (check == "skills") or
(check == "attributes") or
(check == "personality") or
(check == "stress") or
(check == "focus") or
(check == "needs") or
(check == "beliefs") or
(check == "relations") ) then
table.insert(tmpWatchTable, check)
else
qerror('Error: Unhandled -watch string, try -help')
end
end
-- Overwrite target table with new targets
watchTable = tmpWatchTable
return watchTable
end
-- Let's do this
-- Opens a file in append mode
file = io.open(outputfile, "a")
io.output(file)
-- Get the current time
local runTimestamp = os.date("%Y-%m-%d_%H:%M:%S")
-- Get the DF date
local dfDay = GetDFDate()
-- Set the units to watch
targetTable = SetTargets(targetTable)
-- Set the things to watch
watchTable = SetWatchGroups(watchTable)
-- Get list of valid targets(citizens)
for k,u in ipairs(targetTable) do
if verbose then print("Collecting stats for: ", dfhack.TranslateName(dfhack.units.getVisibleName(u), translate)) end
local uKVPTable={}
local unitName = dfhack.TranslateName(dfhack.units.getVisibleName(u), translate) .."(".. u.id .. ")"
for x,watch in pairs(watchTable) do
if verbose then print(watch) end
if(watch == "skills") then
GetUnitSkills(u, uKVPTable)
end
if(watch == "attributes") then
GetUnitAttributes(u, uKVPTable)
end
if(watch == "personality") then
GetUnitPersonality(u, uKVPTable)
end
if(watch == "stress") then
GetUnitStress(u, uKVPTable)
end
if(watch == "focus") then
GetUnitFocus(u, uKVPTable)
end
if(watch == "needs") then
GetUnitNeeds(u, uKVPTable)
end
if(watch == "beliefs") then
GetUnitBeliefs(u, uKVPTable)
end
if(watch == "relations") then
GetUnitRelations(u, uKVPTable)
end
if(watch == "all") then
GetUnitSkills(u, uKVPTable)
GetUnitAttributes(u, uKVPTable)
GetUnitPersonality(u, uKVPTable)
GetUnitStress(u, uKVPTable)
GetUnitFocus(u, uKVPTable)
GetUnitNeeds(u, uKVPTable)
GetUnitBeliefs(u, uKVPTable)
GetUnitRelations(u, uKVPTable)
end
end
if noLog then
-- nothing. just do nothing
else
printTable(runTimestamp, dfDay, unitName, tag, uKVPTable)
end
end
io.close(file)
Edit: All belief values are now available.
Edit 20190324: Fixes and changes
-- I had focus_level multiplied by need_level. This is wrong, it should just increment by need_level.
-- Translating names is now globally consistent via noTranslate option
-- You can now disable logging via noLog option
-- Script now tracks relationship strength for acquaintances (rank).