| 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, | ||
| }) |
| 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 |
+6
-3
| { | ||
| "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" | ||
| } | ||
| } |
+20
-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
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
44
22.22%259
6.15%558524
-70.42%3
50%1680
-89.42%31
47.62%2
100%+ Added