🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

dfhack-mcp

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dfhack-mcp - npm Package Compare versions

Comparing version
1.2.0
to
1.4.0
+262
dist/dfhack-queries/mcp_burrows.lua
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local utils = require('utils')
local burrowsapi = dfhack.burrows
local a = { ... }
local sub = a[1]
local UNITS_CAP = 200
local function tile_count(b)
local total = 0
for _, blk in ipairs(burrowsapi.listBlocks(b)) do
for x = 0, 15 do
for y = 0, 15 do
if burrowsapi.isAssignedBlockTile(b, blk, x, y) then total = total + 1 end
end
end
end
return total
end
local function find_burrow(name)
local match, count = nil, 0
for _, b in ipairs(df.global.plotinfo.burrows.list) do
if b.name == name then
if not match then match = b end
count = count + 1
end
end
return match, count
end
local function find_burrow_by_id(id)
for _, b in ipairs(df.global.plotinfo.burrows.list) do
if b.id == id then return b end
end
return nil
end
local function civ_alert_burrow_ids()
local list = df.global.plotinfo.alerts.list
if #list < 2 then return {} end
local out = {}
for i = 0, #list[1].burrows - 1 do out[#out + 1] = list[1].burrows[i] end
return out
end
local function burrow_facts(b, alert_set)
local units = {}
for i = 0, #b.units - 1 do units[#units + 1] = b.units[i] end
table.sort(units)
local truncated = false
while #units > UNITS_CAP do
table.remove(units)
truncated = true
end
return {
id = b.id,
name = b.name,
tile_count = tile_count(b),
assigned_units = units,
assigned_units_total = #b.units,
assigned_units_truncated = truncated,
civilian_alert_linked = alert_set[b.id] == true,
}
end
if sub == nil or sub == 'list' then
local alert_ids = civ_alert_burrow_ids()
local alert_set = {}
for _, id in ipairs(alert_ids) do alert_set[id] = true end
local out = {}
for _, b in ipairs(df.global.plotinfo.burrows.list) do
out[#out + 1] = burrow_facts(b, alert_set)
end
table.sort(out, function(x, y) return x.id < y.id end)
emit({
count = #out,
burrows = out,
civilian_alert = {
configured = #df.global.plotinfo.alerts.list >= 2,
active = df.global.plotinfo.alerts.civ_alert_idx ~= 0,
burrows = alert_ids,
},
})
return
end
local function alert_signature(list_len, civ_idx, alert_ids, burrow_id)
local sorted = {}
for _, id in ipairs(alert_ids) do sorted[#sorted + 1] = id end
table.sort(sorted)
return string.format('civalert/list_len=%d/civ_idx=%d/burrow_id=%d/burrows=%s',
list_len, civ_idx, burrow_id, table.concat(sorted, ','))
end
local function parse_toggle()
local bname = a[2]
local en_raw = a[3]
local id_raw = a[4]
local enabled = (en_raw == 'true' or en_raw == '1')
local blocked = {}
local bid = tonumber(id_raw)
local b
if bid then
b = find_burrow_by_id(bid)
if not b then blocked[#blocked + 1] = 'no burrow with id ' .. tostring(bid) end
elseif bname and bname ~= '' then
local count
b, count = find_burrow(bname)
if count == 0 then
blocked[#blocked + 1] = 'no burrow named "' .. bname .. '"'
elseif count > 1 then
blocked[#blocked + 1] = count .. ' burrows are named "' .. bname ..
'" — use burrow_id to disambiguate'
b = nil
end
else
blocked[#blocked + 1] = 'burrow (name) or burrow_id is required'
end
return { bname = bname, enabled = enabled, b = b, blocked = blocked }
end
if sub == 'plan_alert' or sub == 'apply_alert' then
local p = parse_toggle()
if #p.blocked > 0 then
emit({ blocked = p.blocked })
return
end
local list = df.global.plotinfo.alerts.list
local list_len = #list
local alert_ids = civ_alert_burrow_ids()
local alert_set = {}
for _, id in ipairs(alert_ids) do alert_set[id] = true end
local currently_in = alert_set[p.b.id] == true
local civ_idx = df.global.plotinfo.alerts.civ_alert_idx
local currently_sounding = civ_idx ~= 0
local resulting_ids = {}
for _, id in ipairs(alert_ids) do
if id ~= p.b.id then resulting_ids[#resulting_ids + 1] = id end
end
if p.enabled then resulting_ids[#resulting_ids + 1] = p.b.id end
table.sort(resulting_ids)
local resulting_sounding = p.enabled or (currently_sounding and #resulting_ids > 0)
local noop = (currently_in == p.enabled) and (currently_sounding == resulting_sounding)
if sub == 'plan_alert' then
emit({
preview = {
burrow_id = p.b.id,
burrow_name = p.b.name,
enabled = p.enabled,
currently_in_civilian_alert = currently_in,
civilian_alert_currently_sounding = currently_sounding,
civilian_alert_configured = list_len >= 2,
resulting_civilian_alert_burrows = resulting_ids,
resulting_sounding = resulting_sounding,
},
signature = alert_signature(list_len, civ_idx, alert_ids, p.b.id),
noop = noop or nil,
})
return
end
if #list < 2 then
while #list < 2 do
local item = df.alert_statest:new()
item.id = df.global.plotinfo.alerts.next_id
df.global.plotinfo.alerts.next_id = df.global.plotinfo.alerts.next_id + 1
item.name = 'civ-alert'
list:insert('#', item)
end
end
local civ = list[1]
if p.enabled then
if not utils.binsearch(civ.burrows, p.b.id) then
utils.insert_sorted(civ.burrows, p.b.id)
end
if df.global.plotinfo.alerts.civ_alert_idx == 0 and #civ.burrows > 0 then
df.global.plotinfo.alerts.civ_alert_idx = 1
end
else
utils.erase_sorted(civ.burrows, p.b.id)
if #civ.burrows == 0 then
df.global.plotinfo.alerts.civ_alert_idx = 0
end
end
local new_alert_ids = {}
local new_alert_set = {}
for i = 0, #civ.burrows - 1 do
local id = civ.burrows[i]
new_alert_ids[#new_alert_ids + 1] = id
new_alert_set[id] = true
end
local new_sounding = df.global.plotinfo.alerts.civ_alert_idx ~= 0
-- The simple "call again with enabled inverted" reversal is NOT always
-- faithful: e.g. enabling a burrow while another is already linked-but-
-- silent sounds the alarm for both, but inverting only removes this
-- burrow's membership — it does not re-silence the other. Simulate the
-- inverse call from the POST-apply state and compare against the TRUE
-- pre-apply state to report this honestly rather than always claiming true.
local inv_ids = {}
for _, id in ipairs(new_alert_ids) do
if id ~= p.b.id then inv_ids[#inv_ids + 1] = id end
end
if not p.enabled then inv_ids[#inv_ids + 1] = p.b.id end
table.sort(inv_ids)
local inv_sounding = (not p.enabled) or (new_sounding and #inv_ids > 0)
local function same_id_set(a, b)
if #a ~= #b then return false end
for i, id in ipairs(a) do
if id ~= b[i] then return false end
end
return true
end
local faithful = (inv_sounding == currently_sounding) and same_id_set(inv_ids, alert_ids)
emit({
changes = {
burrow_id = p.b.id,
burrow_name = p.b.name,
enabled = p.enabled,
civilian_alert_burrows = new_alert_ids,
civilian_alert_sounding = new_sounding,
},
undo = {
reversible = true,
reversal = 'call civilian_alert again on the same burrow with enabled inverted',
faithful = faithful,
note = faithful
and ('if other burrows remain in the civilian-alert set, removing THIS burrow does not ' ..
'by itself silence the alarm for them — it only clears when the set becomes empty')
or ('faithful=false: with other burrows also linked, inverting restores THIS burrow\'s ' ..
'own membership but not necessarily the exact prior sounding state or other burrows\' ' ..
'membership — check civilian_alert (from burrows()) before relying on it'),
},
readback = {
burrow = burrow_facts(p.b, new_alert_set),
civilian_alert = {
configured = true,
active = df.global.plotinfo.alerts.civ_alert_idx ~= 0,
burrows = new_alert_ids,
},
},
})
return
end
emit({ error = 'unknown subcommand: ' .. tostring(sub) })
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local terrain = reqscript('mcp_readTerrain')
local TILE_BUDGET = 20000000
local AQUIFER_LAYERS_CAP = 50
local WATER_LAYERS_CAP = 200
local FLOOD_RISK_CAP = 50
local WELLS_CAP = 20
local WELL_SCAN_DEPTH = 40
local MAGMA_SEA_MIN_TILES = 20
local m = df.global.world.map
local x_count, y_count, z_count = m.x_count, m.y_count, m.z_count
local function cheb(ax, ay, bx, by) return math.max(math.abs(ax - bx), math.abs(ay - by)) end
local function bearing(fromx, fromy, tox, toy)
local dx, dy = tox - fromx, toy - fromy
local tol = 2
local s = ''
if dy < -tol then s = 'N' elseif dy > tol then s = 'S' end
if dx > tol then s = s .. 'E' elseif dx < -tol then s = s .. 'W' end
return (s == '') and 'here' or s
end
local cx, cy, cz, n = 0, 0, 0, 0
local interior_groups = {}
for _, u in ipairs(dfhack.units.getCitizens(true)) do
local p = u.pos
if p and p.x >= 0 then
cx = cx + p.x; cy = cy + p.y; cz = cz + p.z; n = n + 1
local g = dfhack.maps.getWalkableGroup(xyz2pos(p.x, p.y, p.z))
if g ~= 0 then interior_groups[g] = true end
end
end
local core = (n > 0) and { x = math.floor(cx / n), y = math.floor(cy / n), z = math.floor(cz / n) } or nil
local function near_interior(x, y, z)
for dy = -1, 1 do
for dx = -1, 1 do
if not (dx == 0 and dy == 0) then
local nx, ny = x + dx, y + dy
if nx >= 0 and ny >= 0 and nx < x_count and ny < y_count then
if not terrain.is_hidden(nx, ny, z) then
local g = dfhack.maps.getWalkableGroup(xyz2pos(nx, ny, z))
if g ~= 0 and interior_groups[g] then return true end
end
end
end
end
end
return false
end
local aquifer_by_z = {}
local water_by_z = {}
local magma_by_z = {}
-- Per-z-level top-N batching -- see docs/tools/fluids.md Implementation notes.
local flood_risk = {}
local flood_risk_total = 0
local collecting_candidates = true
local scanned_tiles = 0
local scan_complete = true
local last_z_scanned = nil
for z = z_count - 1, 0, -1 do
if scanned_tiles >= TILE_BUDGET then scan_complete = false; break end
last_z_scanned = z
local level_candidates = {}
local by = 0
while by < y_count do
local bx = 0
while bx < x_count do
local blk = dfhack.maps.getTileBlock(bx, by, z)
if blk then
for ly = 0, 15 do
local y = by + ly
if y < y_count then
for lx = 0, 15 do
local x = bx + lx
if x < x_count then
scanned_tiles = scanned_tiles + 1
local des = blk.designation[lx][ly]
if not terrain.is_hidden(x, y, z, blk) then
if des.water_table then
local occ = blk.occupancy[lx][ly]
local bucket = aquifer_by_z[z]
if not bucket then bucket = { light = 0, heavy = 0 }; aquifer_by_z[z] = bucket end
if occ.heavy_aquifer then bucket.heavy = bucket.heavy + 1
else bucket.light = bucket.light + 1 end
end
if des.flow_size > 0 then
if des.liquid_type then
magma_by_z[z] = (magma_by_z[z] or 0) + 1
else
local wb = water_by_z[z]
if not wb then
wb = { tiles = 0, salt = 0, fresh = 0, stagnant = 0, flowing = 0, max_depth = 0 }
water_by_z[z] = wb
end
wb.tiles = wb.tiles + 1
if des.water_salt then wb.salt = wb.salt + 1 else wb.fresh = wb.fresh + 1 end
if des.water_stagnant then wb.stagnant = wb.stagnant + 1 else wb.flowing = wb.flowing + 1 end
if des.flow_size > wb.max_depth then wb.max_depth = des.flow_size end
if des.flow_size >= 7 and core and near_interior(x, y, z) then
flood_risk_total = flood_risk_total + 1
if collecting_candidates then
level_candidates[#level_candidates + 1] = {
x = x, y = y, z = z,
salt = des.water_salt, stagnant = des.water_stagnant,
footing = terrain.sym(blk.tiletype[lx][ly], false),
from_core = { dist = cheb(x, y, core.x, core.y), dz = core.z - z,
dir = bearing(core.x, core.y, x, y) },
}
end
end
end
end
end
end
end
end
end
end
bx = bx + 16
end
by = by + 16
end
if collecting_candidates and #level_candidates > 0 then
table.sort(level_candidates, function(a, b)
if a.x ~= b.x then return a.x < b.x end
return a.y < b.y
end)
for _, c in ipairs(level_candidates) do flood_risk[#flood_risk + 1] = c end
if #flood_risk >= FLOOD_RISK_CAP then collecting_candidates = false end
end
end
local flood_risk_truncated = false
if #flood_risk > FLOOD_RISK_CAP then
local capped = {}
for i = 1, FLOOD_RISK_CAP do capped[i] = flood_risk[i] end
flood_risk = capped
flood_risk_truncated = true
elseif flood_risk_total > #flood_risk then
flood_risk_truncated = true
end
local function classify(b)
if b.heavy > 0 and b.light > 0 then return 'mixed'
elseif b.heavy > 0 then return 'heavy'
else return 'light' end
end
local aq_zs = {}
for z in pairs(aquifer_by_z) do aq_zs[#aq_zs + 1] = z end
table.sort(aq_zs, function(a, b) return a > b end)
local aquifer_layers = {}
local cur = nil
for _, z in ipairs(aq_zs) do
local b = aquifer_by_z[z]
local cls = classify(b)
if cur and cur.classification == cls and cur.z_bottom == z + 1 then
cur.z_bottom = z
cur.light_tiles = cur.light_tiles + b.light
cur.heavy_tiles = cur.heavy_tiles + b.heavy
else
cur = { z_top = z, z_bottom = z, classification = cls, light_tiles = b.light, heavy_tiles = b.heavy }
aquifer_layers[#aquifer_layers + 1] = cur
end
end
local aquifer_layers_total = #aquifer_layers
local aquifer_layers_truncated = false
if #aquifer_layers > AQUIFER_LAYERS_CAP then
local capped = {}
for i = 1, AQUIFER_LAYERS_CAP do capped[i] = aquifer_layers[i] end
aquifer_layers = capped
aquifer_layers_truncated = true
end
local water_zs = {}
for z in pairs(water_by_z) do water_zs[#water_zs + 1] = z end
table.sort(water_zs, function(a, b) return a > b end)
local water_layers = {}
for _, z in ipairs(water_zs) do
local wb = water_by_z[z]
water_layers[#water_layers + 1] = {
z = z, tiles = wb.tiles, salt_tiles = wb.salt, fresh_tiles = wb.fresh,
stagnant_tiles = wb.stagnant, flowing_tiles = wb.flowing, max_depth = wb.max_depth,
}
end
local water_layers_total = #water_layers
local water_layers_truncated = false
if #water_layers > WATER_LAYERS_CAP then
local capped = {}
for i = 1, WATER_LAYERS_CAP do capped[i] = water_layers[i] end
water_layers = capped
water_layers_truncated = true
end
local magma_sea = nil
do
local zs = {}
for z in pairs(magma_by_z) do zs[#zs + 1] = z end
table.sort(zs, function(a, b) return a > b end)
for _, z in ipairs(zs) do
if magma_by_z[z] >= MAGMA_SEA_MIN_TILES then
magma_sea = { top_z = z, revealed_tile_count = magma_by_z[z] }
break
end
end
end
local function well_water_source(w)
local x, y = w.centerx, w.centery
for z = w.z, math.max(0, w.z - WELL_SCAN_DEPTH), -1 do
local blk = dfhack.maps.getTileBlock(x, y, z)
if blk then
local lx, ly = x % 16, y % 16
local des = blk.designation[lx][ly]
if terrain.is_hidden(x, y, z, blk) then return { source = 'unknown', depth_to_source = nil } end
if des.flow_size > 0 then
return { source = des.liquid_type and 'magma' or 'water', depth_to_source = w.z - z }
end
local mat = df.tiletype.attrs[blk.tiletype[lx][ly]].material
if df.tiletype_material[mat] == 'FROZEN_LIQUID' then
return { source = 'frozen', depth_to_source = w.z - z }
end
end
end
return { source = 'unknown', depth_to_source = nil }
end
local well_bldgs = df.global.world.buildings.other.WELL or {}
local wells = {}
for _, w in ipairs(well_bldgs) do
local found = well_water_source(w)
wells[#wells + 1] = {
x = w.centerx, y = w.centery, z = w.z,
source = found.source, depth_to_source = found.depth_to_source,
}
end
table.sort(wells, function(a, b)
if a.z ~= b.z then return a.z > b.z end
if a.x ~= b.x then return a.x < b.x end
return a.y < b.y
end)
local wells_total = #wells
local wells_truncated = false
if #wells > WELLS_CAP then
local capped = {}
for i = 1, WELLS_CAP do capped[i] = wells[i] end
wells = capped
wells_truncated = true
end
emit({
aquifer_layers = aquifer_layers,
aquifer_layers_total = aquifer_layers_total,
aquifer_layers_truncated = aquifer_layers_truncated,
water_layers = water_layers,
water_layers_total = water_layers_total,
water_layers_truncated = water_layers_truncated,
magma_sea = magma_sea,
flood_risk_tiles = flood_risk,
flood_risk_total = flood_risk_total,
flood_risk_truncated = flood_risk_truncated,
wells = wells,
wells_total = wells_total,
wells_truncated = wells_truncated,
legend = terrain.TERRAIN_LEGEND,
scan = { complete = scan_complete, tiles_scanned = scanned_tiles, last_z_scanned = last_z_scanned },
})
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local visibility = reqscript('mcp_unitVisibility')
local T = df.item_type
local CORPSE_TYPES = { [T.CORPSE] = true, [T.CORPSEPIECE] = true, [T.REMAINS] = true }
local CLOTHES_TYPES = { [T.ARMOR] = true, [T.SHOES] = true, [T.HELM] = true, [T.GLOVES] = true, [T.PANTS] = true }
local items_total, stone, corpses, clothes = 0, 0, 0, 0
for _, item in ipairs(df.global.world.items.all) do
items_total = items_total + 1
local ty = item:getType()
if ty == T.BOULDER then
stone = stone + 1
elseif CORPSE_TYPES[ty] then
corpses = corpses + 1
elseif CLOTHES_TYPES[ty] then
clothes = clothes + 1
end
end
local units_active, units_dead_on_map = 0, 0
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not visibility.is_hidden(u) then
if dfhack.units.isDead(u) then
units_dead_on_map = units_dead_on_map + 1
else
units_active = units_active + 1
end
end
end
emit({
fps = df.global.enabler.calculated_fps,
gfps = df.global.enabler.calculated_gfps,
items = {
total = items_total,
stone = stone,
corpses = corpses,
clothes = clothes,
},
units = {
active = units_active,
dead_on_map = units_dead_on_map,
},
})
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local ROUTES_CAP = 100
local VEHICLES_CAP = 200
-- Live enum bindings -- see docs/tools/hauling_routes.md Caveats/Implementation notes.
local DIRECTIONS = df.stop_depart_condition.T_direction
local LEAVE_MODES = df.stop_depart_condition.T_mode
local hauling = df.global.plotinfo.hauling
local vehicle_id_by_item_id = {}
for _, v in ipairs(df.global.world.vehicles.all) do
vehicle_id_by_item_id[v.item_id] = v.id
end
local function stop_payload(s)
local stockpiles = {}
for _, link in ipairs(s.stockpiles) do
stockpiles[#stockpiles + 1] = {
building_id = link.building_id,
take = link.mode.take or false,
give = link.mode.give or false,
}
end
table.sort(stockpiles, function(a, b) return a.building_id < b.building_id end)
local conditions = {}
for _, c in ipairs(s.conditions) do
conditions[#conditions + 1] = {
direction = DIRECTIONS[c.direction],
mode = LEAVE_MODES[c.mode],
timeout = c.timeout,
load_percent = c.load_percent,
at_most = c.flags.at_most or false,
desired = c.flags.desired or false,
}
end
local parked_vehicle_id = nil
if s.cart_id and s.cart_id ~= -1 then
parked_vehicle_id = vehicle_id_by_item_id[s.cart_id]
end
return {
id = s.id,
name = (s.name ~= '' and s.name) or nil,
pos = { x = s.pos.x, y = s.pos.y, z = s.pos.z },
stockpiles = stockpiles,
conditions = conditions,
parked_vehicle_id = parked_vehicle_id,
}
end
local routes = {}
for _, r in ipairs(hauling.routes) do
local stops = {}
for _, s in ipairs(r.stops) do
stops[#stops + 1] = stop_payload(s)
end
table.sort(stops, function(a, b) return a.id < b.id end)
local route_vehicles = {}
for i, vid in ipairs(r.vehicle_ids) do
local stop_idx = r.vehicle_stops[i]
local current_stop_id = nil
-- r.stops indexing is 0-based, matching vehicle_stops[i]'s own convention
-- (no +1) -- see docs/tools/hauling_routes.md Implementation notes.
if stop_idx and stop_idx >= 0 then
local stop_obj = r.stops[stop_idx]
if stop_obj then current_stop_id = stop_obj.id end
end
route_vehicles[#route_vehicles + 1] = { vehicle_id = vid, current_stop_id = current_stop_id }
end
table.sort(route_vehicles, function(a, b) return a.vehicle_id < b.vehicle_id end)
routes[#routes + 1] = {
id = r.id,
name = (r.name ~= '' and r.name) or nil,
stops = stops,
vehicles = route_vehicles,
}
end
table.sort(routes, function(a, b) return a.id < b.id end)
local routes_total = #routes
local routes_truncated = false
if #routes > ROUTES_CAP then
local capped = {}
for i = 1, ROUTES_CAP do capped[i] = routes[i] end
routes = capped
routes_truncated = true
end
-- Every vehicle a (possibly-capped) route actually references -- by
-- route_vehicles[].vehicle_id or a stop's parked_vehicle_id -- must survive
-- the vehicles[] cap below, or hauling_routes_cross_references_resolve would
-- fail on an otherwise-valid fort just because the referenced vehicle's id
-- happened to sort past the cap.
local referenced_vehicle_ids = {}
for _, r in ipairs(routes) do
for _, rv in ipairs(r.vehicles) do referenced_vehicle_ids[rv.vehicle_id] = true end
for _, s in ipairs(r.stops) do
if s.parked_vehicle_id then referenced_vehicle_ids[s.parked_vehicle_id] = true end
end
end
local vehicles = {}
for _, v in ipairs(df.global.world.vehicles.all) do
if df.vehicle_type[v.type] == 'ITEM' then
vehicles[#vehicles + 1] = {
vehicle_id = v.id,
item_id = v.item_id,
backing_item_exists = df.item.find(v.item_id) ~= nil,
route_id = (v.route_id and v.route_id ~= -1) and v.route_id or nil,
on_track = v.flag.ON_TRACK or false,
}
end
end
local vehicles_total = #vehicles
local vehicles_truncated = false
if #vehicles > VEHICLES_CAP then
-- Every referenced vehicle is kept unconditionally -- referential integrity
-- with routes[] takes priority over the cap being a hard ceiling, so
-- vehicles.length can exceed VEHICLES_CAP in the (unhit-on-fixture) case of
-- a fort whose capped routes alone reference more than 200 vehicles. Only
-- the unreferenced remainder is capped.
local referenced, unreferenced = {}, {}
for _, v in ipairs(vehicles) do
if referenced_vehicle_ids[v.vehicle_id] then referenced[#referenced + 1] = v
else unreferenced[#unreferenced + 1] = v end
end
local budget = math.max(0, VEHICLES_CAP - #referenced)
if budget < #unreferenced then
vehicles_truncated = true
if budget > 0 then
local kept = {}
for i = 1, budget do kept[i] = unreferenced[i] end
unreferenced = kept
else
unreferenced = {}
end
end
vehicles = referenced
for _, v in ipairs(unreferenced) do vehicles[#vehicles + 1] = v end
end
table.sort(vehicles, function(a, b) return a.vehicle_id < b.vehicle_id end)
emit({
routes = routes,
routes_total = routes_total,
routes_truncated = routes_truncated,
vehicles = vehicles,
vehicles_total = vehicles_total,
vehicles_truncated = vehicles_truncated,
})
--@ module = true
-- mcp_jsonOut: see CONTRIBUTING.md "Shared internals: DF text encoding".
local json = require('json')
local function convert(t)
for k, v in pairs(t) do
local ty = type(v)
if ty == 'string' then t[k] = dfhack.df2utf(v)
elseif ty == 'table' then convert(v) end
end
end
function emit(t)
convert(t)
print(json.encode(t))
end
if dfhack_flags and dfhack_flags.module then
return
end
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local leverapi = reqscript('lever')
local a = { ... }
local sub = a[1]
local bt = df.building_type
local GATE_FLAG_NAMES = setmetatable({
[bt.Bridge] = { closed = 'raised', open = 'lowered', closing = 'raising', opening = 'lowering' },
[bt.Weapon] = { closed = 'retracted', open = 'unretracted', closing = 'retracting', opening = 'unretracting' },
}, { __index = function() return { closed = 'closed', open = 'open', closing = 'closing', opening = 'opening' } end })
local function job_facts(jobs)
local out = {}
for _, j in ipairs(jobs) do
if j.job_type == df.job_type.PullLever then
out[#out + 1] = {
id = j.id,
do_now = j.flags.do_now,
repeating = j.flags['repeat'],
suspended = j.flags.suspend,
}
end
end
return out
end
local function linked_targets(trap)
local out = {}
for _, m in ipairs(trap.linked_mechanisms) do
local tref = dfhack.items.getGeneralRef(m, df.general_ref_type.BUILDING_HOLDER)
local tg = tref and tref:getBuilding()
if tg then
local target = {
building_id = tg.id,
type = df.building_type[tg:getType()],
pos = { x = tg.centerx, y = tg.centery, z = tg.z },
}
local ok, flags = pcall(function() return tg.gate_flags end)
if ok and flags then
local names = GATE_FLAG_NAMES[tg:getType()]
if flags[names.closing] then target.state = names.closing
elseif flags[names.opening] then target.state = names.opening
elseif flags[names.closed] then target.state = names.closed
else target.state = names.open end
else
local ok2, dflags = pcall(function() return tg.door_flags end)
if ok2 and dflags then
target.state = dflags.closed and 'closed' or 'open'
end
end
out[#out + 1] = target
end
end
return out
end
local function lever_facts(b)
return {
building_id = b.id,
name = b.name,
pos = { x = b.centerx, y = b.centery, z = b.z },
state = b.state,
linked_targets = linked_targets(b),
pending_pull_jobs = job_facts(b.jobs),
}
end
local function plate_facts(b)
local p = b.plate_info
return {
building_id = b.id,
name = b.name,
pos = { x = b.centerx, y = b.centery, z = b.z },
linked_targets = linked_targets(b),
triggers = {
citizens = p.flags.citizens,
creatures = p.flags.units,
creature_weight_min = p.unit_min,
creature_weight_max = p.unit_max,
minecart_track = p.flags.track,
minecart_weight_min = p.track_min,
minecart_weight_max = p.track_max,
water = p.flags.water,
water_depth_min = p.water_min,
water_depth_max = p.water_max,
magma = p.flags.magma,
magma_depth_min = p.magma_min,
magma_depth_max = p.magma_max,
},
}
end
local LEVER_CAP, PLATE_CAP, UNLINKED_CAP = 200, 200, 200
local function cap(list, n)
local truncated = false
while #list > n do
table.remove(list)
truncated = true
end
return truncated
end
if sub == nil or sub == 'list' then
local levers, plates = {}, {}
local linked_target_ids = {}
local bridges = {}
for _, b in ipairs(df.global.world.buildings.all) do
local t = b:getType()
if t == bt.Trap then
local tt = df.trap_type[b.trap_type]
if tt == 'Lever' then
local facts = lever_facts(b)
levers[#levers + 1] = facts
for _, tgt in ipairs(facts.linked_targets) do linked_target_ids[tgt.building_id] = true end
elseif tt == 'PressurePlate' then
local facts = plate_facts(b)
plates[#plates + 1] = facts
for _, tgt in ipairs(facts.linked_targets) do linked_target_ids[tgt.building_id] = true end
end
elseif t == bt.Bridge then
bridges[#bridges + 1] = { building_id = b.id, pos = { x = b.centerx, y = b.centery, z = b.z } }
end
end
table.sort(levers, function(x, y) return x.building_id < y.building_id end)
table.sort(plates, function(x, y) return x.building_id < y.building_id end)
local unlinked_levers, unlinked_bridges = {}, {}
for _, lv in ipairs(levers) do
if #lv.linked_targets == 0 then unlinked_levers[#unlinked_levers + 1] = lv.building_id end
end
for _, br in ipairs(bridges) do
if not linked_target_ids[br.building_id] then
unlinked_bridges[#unlinked_bridges + 1] = br.building_id
end
end
table.sort(unlinked_bridges)
-- Counts are captured from the FULL sets, before any of the four lists below
-- get capped, so *_count always reflects the true total even when truncated.
local lever_count, plate_count, bridge_count = #levers, #plates, #bridges
local levers_truncated = cap(levers, LEVER_CAP)
local plates_truncated = cap(plates, PLATE_CAP)
local unlinked_levers_truncated = cap(unlinked_levers, UNLINKED_CAP)
local unlinked_bridges_truncated = cap(unlinked_bridges, UNLINKED_CAP)
emit({
lever_count = lever_count,
levers = levers,
levers_truncated = levers_truncated,
plate_count = plate_count,
pressure_plates = plates,
pressure_plates_truncated = plates_truncated,
unlinked_levers = unlinked_levers,
unlinked_levers_truncated = unlinked_levers_truncated,
bridge_count = bridge_count,
unlinked_bridges = unlinked_bridges,
unlinked_bridges_truncated = unlinked_bridges_truncated,
})
return
end
local function find_lever(id)
local b = df.building.find(id)
if not b then return nil end
if not df.building_trapst:is_instance(b) then return nil end
if df.trap_type[b.trap_type] ~= 'Lever' then return nil end
return b
end
local function pull_signature(lever)
local targets = linked_targets(lever)
table.sort(targets, function(x, y) return x.building_id < y.building_id end)
local target_parts = {}
for _, t in ipairs(targets) do
target_parts[#target_parts + 1] = string.format('%d:%s', t.building_id, tostring(t.state))
end
local jobs = job_facts(lever.jobs)
table.sort(jobs, function(x, y) return x.id < y.id end)
local job_parts = {}
for _, j in ipairs(jobs) do
job_parts[#job_parts + 1] = string.format('%d:%s:%s:%s', j.id, tostring(j.do_now),
tostring(j.repeating), tostring(j.suspended))
end
return string.format('pull/id=%d/state=%d/targets=%s/jobs=%s', lever.id, lever.state,
table.concat(target_parts, ','), table.concat(job_parts, ','))
end
local function parse_pull()
local lever_id = tonumber(a[2])
local urgent_raw = a[3]
local urgent = not (urgent_raw == 'false' or urgent_raw == '0')
local blocked = {}
if not lever_id or lever_id ~= math.floor(lever_id) then
blocked[#blocked + 1] = 'lever_id must be an integer'
end
local lever = lever_id and find_lever(lever_id) or nil
if lever_id and not lever then
blocked[#blocked + 1] = 'no lever with building id ' .. tostring(lever_id)
end
return { lever_id = lever_id, urgent = urgent, lever = lever, blocked = blocked }
end
if sub == 'plan_pull' or sub == 'apply_pull' then
local p = parse_pull()
if #p.blocked > 0 then
emit({ blocked = p.blocked })
return
end
if sub == 'plan_pull' then
emit({
preview = {
lever_id = p.lever.id,
lever_name = p.lever.name,
pos = { x = p.lever.centerx, y = p.lever.centery, z = p.lever.z },
current_state = p.lever.state,
linked_targets = linked_targets(p.lever),
pending_pull_jobs = job_facts(p.lever.jobs),
urgent = p.urgent,
},
signature = pull_signature(p.lever),
})
return
end
leverapi.leverPullJob(p.lever, p.urgent)
emit({
changes = {
lever_id = p.lever.id,
queued = true,
urgent = p.urgent,
},
undo = {
reversible = true,
reversal = 'call pull_lever again on the same lever_id — a second pull toggles it back',
note = 'this QUEUES a job; the lever only flips (and any linked bridge/door/spike/' ..
'support only actually moves) once a dwarf walks over and completes it — not ' ..
'immediately on apply',
},
readback = lever_facts(p.lever),
})
return
end
emit({ error = 'unknown subcommand: ' .. tostring(sub) })
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local LOCATION_CAP = 50
local RESIDENCY_CAP = 50
local DEADLINE_SOON = 7
local TICKS_PER_DAY = 1200
local TICKS_PER_YEAR = TICKS_PER_DAY * 28 * 12
local SITE_ID = df.global.plotinfo.site_id
local now_ticks = df.global.cur_year * TICKS_PER_YEAR + df.global.cur_year_tick
local function total_ticks(year, tick) return year * TICKS_PER_YEAR + (tick or 0) end
local function age_days(year, tick)
local delta = now_ticks - total_ticks(year, tick)
if delta < 0 then delta = 0 end
return math.floor(delta / TICKS_PER_DAY)
end
-- end_tick's real unit is unconfirmed (df-structures original-name
-- end_season_count, not end_year_tick) -- see docs/tools/petitions.md Caveats.
local function deadline_days(end_year, end_tick)
if not end_year or end_year < 0 then return nil end
local delta = total_ticks(end_year, end_tick) - now_ticks
if delta < 0 then delta = 0 end
return math.floor(delta / TICKS_PER_DAY)
end
local function petition_status(agr, ageDays)
if agr.flags.convicted_accepted then return 'satisfied' end
if agr.flags.petition_not_accepted then return 'denied' end
if ageDays >= 336 then return 'expired' end
return 'outstanding'
end
local function unit_name(u)
if not u then return nil end
local ok, s = pcall(function() return dfhack.units.getReadableName(u) end)
if ok and s and s ~= '' then return s end
return nil
end
local function hf_name(hf)
if not hf then return nil end
local ok, s = pcall(function() return dfhack.translation.translateName(hf.name, true) end)
if ok and s and s ~= '' then return s end
return 'hf ' .. tostring(hf.id)
end
local function entity_name(ent)
if not ent then return nil end
local ok, s = pcall(function() return dfhack.translation.translateName(ent.name, true) end)
if ok and s and s ~= '' then return s end
return 'entity ' .. tostring(ent.id)
end
local function find_party(agr, party_id)
if not party_id or party_id < 0 then return nil end
for _, p in ipairs(agr.parties) do
if p.id == party_id then return p end
end
return nil
end
local function party_name(party)
if not party then return 'unknown' end
if party.histfig_ids and #party.histfig_ids > 0 then
local hf = df.historical_figure.find(party.histfig_ids[0])
if hf then
local u = (hf.unit_id and hf.unit_id ~= -1) and df.unit.find(hf.unit_id) or nil
return unit_name(u) or hf_name(hf) or 'unknown'
end
end
if party.entity_ids and #party.entity_ids > 0 then
local ent = df.historical_entity.find(party.entity_ids[0])
if ent then return entity_name(ent) or 'unknown' end
end
return 'unknown'
end
local function location_deity(loc)
local dtype = loc.deity_type
if dtype == df.religious_practice_type.WORSHIP_HFID then
local hf = loc.deity_data and df.historical_figure.find(loc.deity_data.Deity)
if hf then return hf_name(hf) end
elseif dtype == df.religious_practice_type.RELIGION_ENID then
local ent = loc.deity_data and df.historical_entity.find(loc.deity_data.Religion)
if ent then
local deities = ent.relations and ent.relations.deities
if deities and #deities > 0 then
local dhf = df.historical_figure.find(deities[0])
if dhf then return hf_name(dhf) end
end
return entity_name(ent)
end
end
return nil
end
local function guild_profession(loc)
local p = df.profession[loc.profession]
if not p then return nil end
return string.lower(tostring(p)):gsub('_', ' ')
end
local awaiting = {}
for _, aid in ipairs(df.global.plotinfo.petitions) do awaiting[aid] = true end
local location_petitions = {}
local residency_petitions = {}
for _, agr in ipairs(df.global.world.agreements.all) do
if #agr.details > 0 then
local det = agr.details[0]
local dtype = df.agreement_details_type[det.type]
if dtype == 'Location' then
local loc = det.data.Location
if loc and loc.site == SITE_ID then
local applicant = find_party(agr, loc.applicant)
local ageDays = age_days(det.year, det.year_tick)
local building = df.abstract_building_type[loc.type]
location_petitions[#location_petitions + 1] = {
agreement_id = agr.id,
building = building,
tier = loc.tier,
petitioner = party_name(applicant),
deity = (building == 'TEMPLE') and location_deity(loc) or nil,
guild_profession = (building == 'GUILDHALL') and guild_profession(loc) or nil,
agreed_year = det.year,
age_days = ageDays,
warned_ready = loc.flags.warned_is_ready or false,
awaiting_decision = awaiting[agr.id] or false,
status = petition_status(agr, ageDays),
}
end
elseif dtype == 'Residency' or dtype == 'Citizenship' then
local data = (dtype == 'Residency') and det.data.Residency or det.data.Citizenship
if data and data.site == SITE_ID then
local applicant = find_party(agr, data.applicant)
local ageDays = age_days(det.year, det.year_tick)
residency_petitions[#residency_petitions + 1] = {
agreement_id = agr.id,
kind = dtype,
petitioner = party_name(applicant),
agreed_year = det.year,
age_days = ageDays,
deadline_days = deadline_days(data.end_year, data.end_season_tick),
awaiting_decision = awaiting[agr.id] or false,
status = petition_status(agr, ageDays),
}
end
end
end
end
-- Active-first sort and pre-cap counting -- see docs/tools/petitions.md Caveats.
local function petition_rank(p) return (p.status == 'outstanding' or p.awaiting_decision) and 0 or 1 end
local function by_active_then_id(a, b)
local ra, rb = petition_rank(a), petition_rank(b)
if ra ~= rb then return ra < rb end
return a.agreement_id < b.agreement_id
end
table.sort(location_petitions, by_active_then_id)
table.sort(residency_petitions, by_active_then_id)
local awaiting_decision_count = 0
for _, p in ipairs(location_petitions) do
if p.awaiting_decision then awaiting_decision_count = awaiting_decision_count + 1 end
end
for _, p in ipairs(residency_petitions) do
if p.awaiting_decision then awaiting_decision_count = awaiting_decision_count + 1 end
end
local function cap(list, n)
local truncated = false
if #list > n then
local kept = {}
for i = 1, n do kept[i] = list[i] end
truncated = true
return kept, truncated
end
return list, truncated
end
local location_petitions_truncated, residency_petitions_truncated
location_petitions, location_petitions_truncated = cap(location_petitions, LOCATION_CAP)
residency_petitions, residency_petitions_truncated = cap(residency_petitions, RESIDENCY_CAP)
local alerts = {}
for _, p in ipairs(location_petitions) do
if p.warned_ready and p.status == 'outstanding' then
alerts[#alerts + 1] = p.petitioner .. "'s " .. string.lower(p.building) ..
' petition is ready to establish but still outstanding (' .. p.age_days .. ' days)'
end
end
for _, p in ipairs(residency_petitions) do
if p.status == 'outstanding' and p.deadline_days ~= nil and p.deadline_days <= DEADLINE_SOON then
alerts[#alerts + 1] = p.petitioner .. "'s " .. string.lower(p.kind) ..
' petition has ' .. p.deadline_days .. ' day(s) left to decide'
end
end
if awaiting_decision_count > 0 then
alerts[#alerts + 1] = awaiting_decision_count .. ' petition(s) awaiting a decision'
end
emit({
location_petitions = location_petitions,
location_petitions_truncated = location_petitions_truncated,
residency_petitions = residency_petitions,
residency_petitions_truncated = residency_petitions_truncated,
awaiting_decision_count = awaiting_decision_count,
alerts = alerts,
})
local json = require('json')
local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
emit({ error = 'no fort loaded' })
return
end
local PILES_CAP = 200
local LINKS_CAP = 50
local BACKLOG_CAP = 150
local ROTTING_TYPES_CAP = 100
local CATEGORY_FLAGS = {
'animals', 'food', 'furniture', 'corpses', 'refuse', 'stone', 'ammo', 'coins',
'bars_blocks', 'gems', 'finished_goods', 'leather', 'cloth', 'wood', 'weapons',
'armor', 'sheet',
}
local function capped_sorted_ids(vec)
local ids = {}
for _, b in ipairs(vec) do ids[#ids + 1] = b.id end
table.sort(ids)
local truncated = false
if #ids > LINKS_CAP then
local capped = {}
for i = 1, LINKS_CAP do capped[i] = ids[i] end
ids = capped
truncated = true
end
return ids, truncated
end
local STOCKPILE_EXTENT = df.building_extents_type.Stockpile
-- room.extents footprint reading -- see docs/tools/stockpiles.md Implementation notes.
local function extent_tiles(sp)
local ext = sp.room.extents
local ew, eh = sp.room.width, sp.room.height
if not ext or not ew or not eh or ew <= 0 or eh <= 0 then return nil end
return ext, sp.room.x, sp.room.y, ew, eh
end
local function in_extents(ext, ex, ey, ew, x, y)
local dx, dy = x - ex, y - ey
return ext[dy * ew + dx] == STOCKPILE_EXTENT
end
local piles = {}
for _, sp in ipairs(df.global.world.buildings.other.STOCKPILE) do
local categories = {}
for _, cat in ipairs(CATEGORY_FLAGS) do
if sp.settings.flags[cat] then categories[#categories + 1] = cat end
end
table.sort(categories)
local give_to, give_to_truncated = capped_sorted_ids(sp.links.give_to_pile)
local take_from, take_from_truncated = capped_sorted_ids(sp.links.take_from_pile)
local ext, ex, ey, ew, eh = extent_tiles(sp)
local size = 0
if ext then
for dy = 0, eh - 1 do
for dx = 0, ew - 1 do
if ext[dy * ew + dx] == STOCKPILE_EXTENT then size = size + 1 end
end
end
else
size = (sp.x2 - sp.x1 + 1) * (sp.y2 - sp.y1 + 1)
end
piles[#piles + 1] = {
id = sp.id,
x1 = sp.x1, y1 = sp.y1, x2 = sp.x2, y2 = sp.y2, z = sp.z,
size = size,
categories = categories,
barrels_allowed = sp.storage.max_barrels > 0,
bins_allowed = sp.storage.max_bins > 0,
max_wheelbarrows = sp.storage.max_wheelbarrows,
links_only = sp.stockpile_flag.use_links_only or false,
give_to = give_to,
give_to_truncated = give_to_truncated,
take_from = take_from,
take_from_truncated = take_from_truncated,
item_count = 0,
_extents = ext, _ex = ex, _ey = ey, _ew = ew, _occupied = {},
}
end
local function pile_at(x, y, z)
for _, p in ipairs(piles) do
if z == p.z and x >= p.x1 and x <= p.x2 and y >= p.y1 and y <= p.y2 then
if not p._extents or in_extents(p._extents, p._ex, p._ey, p._ew, x, y) then
return p
end
end
end
return nil
end
local IT = df.item_type
local backlog_counts = {}
local rotting_counts = {}
local rotting_total = 0
local dump_flagged_count = 0
for _, item in ipairs(df.global.world.items.other.IN_PLAY) do
local fl = item.flags
if fl.dump then dump_flagged_count = dump_flagged_count + 1 end
local x, y, z = dfhack.items.getPosition(item)
local pile = x and pile_at(x, y, z) or nil
if fl.rotten then
if fl.on_ground and not pile then
rotting_total = rotting_total + 1
local tok = IT[item:getType()]
rotting_counts[tok] = (rotting_counts[tok] or 0) + 1
end
elseif not (fl.dump or fl.forbid or fl.construction or fl.trader or fl.garbage_collect) then
if pile then
pile.item_count = pile.item_count + 1
pile._occupied[x .. ',' .. y] = true
elseif fl.on_ground then
local tok = IT[item:getType()]
backlog_counts[tok] = (backlog_counts[tok] or 0) + 1
end
end
end
for _, p in ipairs(piles) do
local occupied = 0
for _ in pairs(p._occupied) do occupied = occupied + 1 end
p.occupied_tiles = occupied
p._extents, p._ex, p._ey, p._ew, p._occupied = nil, nil, nil, nil, nil
end
table.sort(piles, function(a, b) return a.id < b.id end)
local piles_total = #piles
local piles_truncated = false
if #piles > PILES_CAP then
local capped = {}
for i = 1, PILES_CAP do capped[i] = piles[i] end
piles = capped
piles_truncated = true
end
local unstored_backlog = {}
local unstored_backlog_item_count = 0
for tok, count in pairs(backlog_counts) do
unstored_backlog[#unstored_backlog + 1] = { item_type = tok, count = count }
unstored_backlog_item_count = unstored_backlog_item_count + count
end
table.sort(unstored_backlog, function(a, b) return a.item_type < b.item_type end)
local unstored_backlog_truncated = false
if #unstored_backlog > BACKLOG_CAP then
local capped = {}
for i = 1, BACKLOG_CAP do capped[i] = unstored_backlog[i] end
unstored_backlog = capped
unstored_backlog_truncated = true
end
local rotting_by_type = {}
for tok, count in pairs(rotting_counts) do
rotting_by_type[#rotting_by_type + 1] = { item_type = tok, count = count }
end
table.sort(rotting_by_type, function(a, b) return a.item_type < b.item_type end)
local rotting_by_type_truncated = false
if #rotting_by_type > ROTTING_TYPES_CAP then
local capped = {}
for i = 1, ROTTING_TYPES_CAP do capped[i] = rotting_by_type[i] end
rotting_by_type = capped
rotting_by_type_truncated = true
end
emit({
piles = piles,
piles_total = piles_total,
piles_truncated = piles_truncated,
unstored_backlog = unstored_backlog,
unstored_backlog_item_count = unstored_backlog_item_count,
unstored_backlog_truncated = unstored_backlog_truncated,
rotting_outside_stockpiles = {
count = rotting_total,
by_type = rotting_by_type,
by_type_truncated = rotting_by_type_truncated,
},
dump_flagged_count = dump_flagged_count,
})
+1
-1
local args = {...}
local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -6,0 +6,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local args = {...}
local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -5,0 +5,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

@@ -5,3 +5,3 @@ local args = {...}

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -8,0 +8,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

@@ -5,3 +5,3 @@ local args = {...}

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -8,0 +8,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

@@ -6,3 +6,3 @@ local args = {...}

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -9,0 +9,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

-- mcp_geology: see docs/tools/geology.md for the data model and field paths.
local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -6,0 +6,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -9,2 +9,137 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local WORN_MODES = {
[df.inv_item_role_type.Worn] = true,
[df.inv_item_role_type.Weapon] = true,
[df.inv_item_role_type.Strapped] = true,
[df.inv_item_role_type.Flask] = true,
}
local function occupant_unit(pos)
if pos.occupant == -1 then return nil end
local hf = df.historical_figure.find(pos.occupant)
local u = hf and df.unit.find(hf.unit_id)
if not u or dfhack.units.isDead(u) or not dfhack.units.isActive(u) then return nil end
return u
end
local function worn_item_ids(u)
local worn = {}
for _, ii in ipairs(u.inventory) do
if WORN_MODES[ii.mode] then worn[ii.item.id] = true end
end
return worn
end
local SPECIAL_SLOTS = { { key = 'quiver', label = 'QUIVER' }, { key = 'backpack', label = 'BACKPACK' },
{ key = 'flask', label = 'FLASK' } }
local function uniform_rows(pos, worn)
local by_type = {}
local order = {}
local function row_for(tname)
local row = by_type[tname]
if not row then
row = { item_type = tname, assigned_count = 0, missing_count = 0 }
by_type[tname] = row
order[#order + 1] = tname
end
return row
end
for _, slot in ipairs(pos.equipment.uniform) do
for _, spec in ipairs(slot) do
local row = row_for(df.item_type[spec.item_type])
if #spec.assigned == 0 then
row.assigned_count = row.assigned_count + 1
row.missing_count = row.missing_count + 1
else
for ai = 0, #spec.assigned - 1 do
local id = spec.assigned[ai]
row.assigned_count = row.assigned_count + 1
if not worn[id] then row.missing_count = row.missing_count + 1 end
end
end
end
end
-- quiver/backpack/flask are assigned outside equipment.uniform (their own
-- single-item-id fields), so uniform-unstick.lua treats them as special
-- cases too — an unassigned or not-worn one is still a real gap.
for _, s in ipairs(SPECIAL_SLOTS) do
local id = pos.equipment[s.key]
if id ~= -1 then
local row = row_for(s.label)
row.assigned_count = row.assigned_count + 1
if not worn[id] then row.missing_count = row.missing_count + 1 end
end
end
table.sort(order)
local out = {}
for _, tname in ipairs(order) do out[#out + 1] = by_type[tname] end
return out
end
local function roster_row(pos)
local u = occupant_unit(pos)
if not u then return nil end
local worn = worn_item_ids(u)
local rows = uniform_rows(pos, worn)
local missing_total = 0
for _, r in ipairs(rows) do missing_total = missing_total + r.missing_count end
return {
unit_id = u.id,
name = dfhack.units.getReadableName(u),
uniform = missing_total > 0 and rows or {},
uniform_complete = missing_total == 0,
}
end
local function ammo_quantity(item_id)
local it = df.item.find(item_id)
if not it then return 0 end
local ok, n = pcall(function() return it:getStackSize() end)
return (ok and n) or 0
end
local function ammo_facts(sq)
local specs = {}
for i = 0, #sq.ammo.ammunition - 1 do
local am = sq.ammo.ammunition[i]
local assigned_qty = 0
for ai = 0, #am.assigned - 1 do
assigned_qty = assigned_qty + ammo_quantity(am.assigned[ai])
end
specs[#specs + 1] = {
item_type = df.item_type[am.item_type],
target_amount = am.amount,
assigned_count = assigned_qty,
}
end
local carried_qty = 0
for i = 0, #sq.ammo.ammo_items - 1 do
carried_qty = carried_qty + ammo_quantity(sq.ammo.ammo_items[i])
end
return {
specs = specs,
ammo_items_assigned = carried_qty,
}
end
local function training_facts(sq)
local month = dfhack.world.ReadCurrentMonth()
local routine = sq.schedule.routine[sq.cur_routine_idx]
local m = routine and routine.month[month]
local orders = {}
if m then
for i = 0, #m.orders - 1 do
orders[#orders + 1] = df.squad_order_type[m.orders[i].order:getType()]
end
end
return {
cur_routine_idx = sq.cur_routine_idx,
month = month,
sleep_mode = m and df.squad_sleep_option_type[m.sleep_mode] or nil,
uniform_mode = m and df.squad_civilian_uniform_type[m.uniform_mode] or nil,
active_orders = orders,
}
end
local fort = df.global.plotinfo.main.fortress_entity

@@ -19,8 +154,20 @@ local squads = {}

local filled, total = 0, 0
local roster = {}
for _, pos in ipairs(sq.positions) do
total = total + 1
if pos.occupant ~= -1 then filled = filled + 1 end
if pos.occupant ~= -1 then
filled = filled + 1
local row = roster_row(pos)
if row then roster[#roster + 1] = row end
end
end
assigned_positions = assigned_positions + filled
squads[#squads+1] = { name = name, filled = filled, positions = total }
squads[#squads+1] = {
name = name,
filled = filled,
positions = total,
roster = roster,
ammo = ammo_facts(sq),
training = training_facts(sq),
}
end

@@ -58,2 +205,9 @@ end

end
for _, sq in ipairs(squads) do
for _, row in ipairs(sq.roster) do
if not row.uniform_complete then
alerts[#alerts+1] = row.name .. ' (' .. sq.name .. ') has an incomplete uniform'
end
end
end

@@ -60,0 +214,0 @@ emit({

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

@@ -41,2 +41,11 @@ --@ module = true

-- Fail-closed single-tile fog-of-war check, sharable across every reader that
-- needs a per-tile (not per-window) test. Pass an already-fetched block (blk)
-- to skip the re-fetch when the caller has one in hand; omit it otherwise.
function is_hidden(x, y, z, blk)
blk = blk or dfhack.maps.getTileBlock(x, y, z)
if not blk then return true end
return blk.designation[x % 16][y % 16].hidden
end
function read_window(x0, y0, z, w, h)

@@ -96,4 +105,6 @@ local m = df.global.world.map

local emit = reqscript('mcp_jsonOut').emit
if df.global.gamemode ~= df.game_mode.DWARF then
print(json.encode({ error = 'no fort loaded' }))
emit({ error = 'no fort loaded' })
return

@@ -109,6 +120,6 @@ end

if not (x0 and y0 and z) then
print(json.encode({ error = 'usage: mcp_readTerrain X0 Y0 Z [W] [H]' }))
emit({ error = 'usage: mcp_readTerrain X0 Y0 Z [W] [H]' })
return
end
print(json.encode(read_window(x0, y0, z, w, h)))
emit(read_window(x0, y0, z, w, h))
local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -12,15 +12,5 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local function u(s)
if s == nil then return nil end
s = tostring(s)
if dfhack.df2utf then
local ok, v = pcall(dfhack.df2utf, s)
if ok then return v end
end
return s
end
local function name_str(name, english)
local ok, s = pcall(dfhack.translation.translateName, name, english)
if ok and s and s ~= '' then return u(s) end
if ok and s and s ~= '' then return s end
return nil

@@ -27,0 +17,0 @@ end

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -11,2 +11,5 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local CARAVANS_CAP = 8
local MANIFEST_CATEGORY_CAP = 30
local AGREEMENT_CAP = 150
local PRICE_FIXED_POINT = 128

@@ -52,2 +55,94 @@ local depots = df.global.world.buildings.other.TRADE_DEPOT or {}

local function capped_sorted(list, cap, less)
table.sort(list, less)
local truncated = false
if #list > cap then
local kept = {}
for i = 1, cap do kept[i] = list[i] end
list = kept
truncated = true
end
return list, truncated
end
local function manifest_of(c)
local m = { count = 0, approx_value = 0 }
local cat_counts = {}
for _, item_id in ipairs(c.goods) do
local it = df.item.find(item_id)
if it then
m.count = m.count + 1
-- No pcall: a valuation failure must reach manifest_error, not silently undercount.
m.approx_value = m.approx_value + dfhack.items.getValue(it, c)
local ok2, ty = pcall(function() return df.item_type[it:getType()] end)
local cat = (ok2 and ty) or 'UNKNOWN'
cat_counts[cat] = (cat_counts[cat] or 0) + 1
end
end
local cats = {}
for cat, n in pairs(cat_counts) do cats[#cats + 1] = { category = cat, count = n } end
local list, truncated = capped_sorted(cats, MANIFEST_CATEGORY_CAP, function(a, b) return a.category < b.category end)
m.by_category = list
m.by_category_truncated = truncated
return m
end
local function price_pct(fixed_point)
return math.floor(fixed_point * 100 / PRICE_FIXED_POINT)
end
local function export_agreements_of(c)
local rows = {}
local bp = c.buy_prices
if bp and bp.items then
local by_cat = {}
for idx, price in ipairs(bp.price) do
local ty = bp.items.item_type[idx]
local cat = df.item_type[ty] or tostring(ty)
local pct = price_pct(price)
local e = by_cat[cat]
if not e then
e = { category = cat, entries = 0, price_pct_min = pct, price_pct_max = pct }
by_cat[cat] = e
end
e.entries = e.entries + 1
if pct < e.price_pct_min then e.price_pct_min = pct end
if pct > e.price_pct_max then e.price_pct_max = pct end
end
for _, e in pairs(by_cat) do rows[#rows + 1] = e end
end
return capped_sorted(rows, AGREEMENT_CAP, function(a, b) return a.category < b.category end)
end
local function import_agreements_of(c)
local rows = {}
local sp = c.sell_prices
if sp and sp.price then
for cat_idx, price_vec in ipairs(sp.price) do
if #price_vec > 0 then
local cat = df.entity_sell_category[cat_idx] or tostring(cat_idx)
local pmin, pmax = nil, nil
for _, price in ipairs(price_vec) do
local pct = price_pct(price)
if not pmin or pct < pmin then pmin = pct end
if not pmax or pct > pmax then pmax = pct end
end
rows[#rows + 1] = { category = cat, entries = #price_vec, price_pct_min = pmin, price_pct_max = pmax }
end
end
end
return capped_sorted(rows, AGREEMENT_CAP, function(a, b) return a.category < b.category end)
end
local function agreements_of(c)
local export_rows, export_truncated = export_agreements_of(c)
local import_rows, import_truncated = import_agreements_of(c)
return {
export = export_rows,
export_truncated = export_truncated,
import = import_rows,
import_truncated = import_truncated,
}
end
local caravans = {}

@@ -62,2 +157,7 @@ for _, c in ipairs(df.global.plotinfo.caravans) do

end
-- manifest_error/agreements_error on failure -- see docs/tools/trade.md.
local ok_m, m = pcall(manifest_of, c)
if ok_m then row.manifest = m else row.manifest_error = tostring(m) end
local ok_a, a = pcall(agreements_of, c)
if ok_a then row.agreements = a else row.agreements_error = tostring(a) end
caravans[#caravans + 1] = row

@@ -64,0 +164,0 @@ end

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

local json = require('json')
local function emit(t) print(json.encode(t)) end
local emit = reqscript('mcp_jsonOut').emit

@@ -4,0 +4,0 @@ if df.global.gamemode ~= df.game_mode.DWARF then

{
"name": "dfhack-mcp",
"version": "1.2.0",
"version": "1.4.0",
"description": "MCP server exposing a live Dwarf Fortress fort to an AI agent as curated, semantic tools",
"mcpName": "io.github.alexanderolvera/dfhack-mcp",
"type": "module",

@@ -57,3 +58,4 @@ "bin": {

"@modelcontextprotocol/sdk": "^1.12.0",
"dfhack-remote-node": "^2.0.0"
"dfhack-remote-node": "^2.0.0",
"zod": "^4.4.3"
},

@@ -71,4 +73,5 @@ "devDependencies": {

"overrides": {
"esbuild": "^0.28.1"
"esbuild": "^0.28.1",
"@hono/node-server": "^2.0.5"
}
}

@@ -9,6 +9,7 @@ # dfhack-mcp

**Read-only by default.** The 30 sensor and reference tools only observe the
**Read-only by default.** The 37 sensor and reference tools only observe the
game. A handful of **actuators** that _change_ the fort — queue manager orders,
apply quickfort blueprints, assign labor — ship behind an explicit opt-in and
stay hidden until you enable them (see [Taking action](#taking-action-actuators)).
apply quickfort blueprints, assign labor, sound the civilian alert, pull a lever —
ship behind an explicit opt-in and stay hidden until you enable them (see
[Taking action](#taking-action-actuators)).

@@ -49,6 +50,11 @@ Two kinds of tools:

dfhack-mcp` fetches and runs the latest release; its runtime dependencies — the
MCP SDK and the
MCP SDK, `zod`, and the
[`dfhack-remote-node`](https://www.npmjs.com/package/dfhack-remote-node) RPC
transport — are pulled from npm automatically.
Clients that browse the [official MCP Registry](https://registry.modelcontextprotocol.io)
can find this server there as **`io.github.alexanderolvera/dfhack-mcp`**; the
listing is generated from [`server.json`](server.json) and points at the same npm
package, so either route installs the identical build.
Then just have your fort running (next section) and ask your agent something like

@@ -88,5 +94,7 @@ _"check on my fort and flag anything urgent."_

- **`jobs_and_labor()`** — workforce utilization: busy vs. idle adults (children excluded), idle %, and a ranked breakdown of active jobs.
- **`military()`** — squads, enlisted soldiers, filled positions, and readiness against hostiles on the map.
- **`military()`** — squads, enlisted soldiers, filled positions, readiness against hostiles on the map, and per-squad roster equipment gaps (missing uniform pieces), ammo, and active training order.
- **`injuries_and_health()`** — wounded / patients / bedridden / unconscious counts, plus the care needed (diagnosis, surgery, suture, …).
- **`defenses()`** — active hostiles with map positions and distance/direction/z-delta to the fort core and nearest drawbridge, plus a controllable-structure inventory (bridges, levers, floodgates, hatches, cage traps, doors).
- **`burrows()`** — every burrow's size and membership, plus the civilian-alert safety-burrow set (configured/active/linked burrows) — the read half of `civilian_alert`.
- **`mechanisms()`** — every lever's position, state, and linked target(s) (bridge/door/floodgate/hatch/support/weapon-trap); pressure-plate trigger conditions; unlinked levers and bridges.
- **`moods()`** — any active strange mood (fey/secretive/possessed/macabre/fell): the dwarf, driving skill, workshop state, and each demanded material cross-referenced against fort stock — the "demands bones, fort has zero" early warning.

@@ -184,2 +192,9 @@ - **`mandates_and_justice()`** — the nobility's overhead: active production mandates and export bans, unmet noble room demands, and justice state (open cases, convictions awaiting punishment, restraint capacity).

**Emergency response**
- **`burrows()`** — _read-only, always available._ Every burrow's size and membership, plus the civilian alert's own state: `configured` (has the fort ever set one up), `active` (is it sounding right now), and the linked burrow ids.
- **`civilian_alert(burrow, enabled)`** — add or remove one burrow from the civilian-alert safety set. `enabled=true` also sounds the alarm if it wasn't already; `enabled=false` only silences it once the set becomes fully empty. Reversal: the same call with `enabled` inverted.
- **`mechanisms()`** — _read-only, always available._ Every lever/pressure-plate's position and linked target(s) (bridge/door/floodgate/hatch/support/weapon-trap), plus unlinked levers and bridges.
- **`pull_lever(lever_id, urgent?)`** — queue a job for a dwarf to pull a named lever (`urgent` defaults to do-now priority). This queues the job; the physical toggle happens once a dwarf completes it, not on apply. Reversal: pull the same lever again.
**Saving the game**

@@ -186,0 +201,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display