@@ -132,34 +132,50 @@ local args = {...} | ||
| local function build_artifacts(cursor, limit) | ||
| local fort_site_id = df.global.plotinfo.site_id | ||
| local function in_scope(ar, scope) | ||
| if scope == 'world' then return true end | ||
| return sget(ar, 'site') == fort_site_id | ||
| end | ||
| local function build_artifacts(cursor, limit, scope) | ||
| local all = df.global.world.artifacts.all | ||
| local total = #all | ||
| local world_total = #all | ||
| local indices = {} | ||
| for i = 0, world_total - 1 do | ||
| if in_scope(all[i], scope) then indices[#indices + 1] = i end | ||
| end | ||
| local total = #indices | ||
| local list = {} | ||
| local i = cursor | ||
| while i < total and #list < limit do | ||
| local ok, rec = pcall(artifact_record, all[i]) | ||
| if ok then | ||
| list[#list + 1] = rec | ||
| else | ||
| list[#list + 1] = { id = (all[i] and all[i].id) or nil, error = 'unreadable artifact record' } | ||
| local n = cursor | ||
| while n < total and #list < limit do | ||
| local ar = all[indices[n + 1]] | ||
| local ok, rec = pcall(artifact_record, ar) | ||
| if not ok then | ||
| rec = { id = (ar and ar.id) or nil, error = 'unreadable artifact record' } | ||
| end | ||
| i = i + 1 | ||
| if scope == 'world' then rec.site_local = sget(ar, 'site') == fort_site_id end | ||
| list[#list + 1] = rec | ||
| n = n + 1 | ||
| end | ||
| local next_cursor | ||
| if i < total then next_cursor = tostring(i) end | ||
| return list, total, next_cursor | ||
| if n < total then next_cursor = tostring(n) end | ||
| return list, total, world_total, next_cursor | ||
| end | ||
| local chunk_by_id = {} | ||
| local art_images_loaded = false | ||
| do | ||
| local ok = pcall(function() | ||
| local chunks = df.global.world.art_image_chunks.all | ||
| for i = 0, #chunks - 1 do chunk_by_id[chunks[i].id] = chunks[i] end | ||
| end) | ||
| art_images_loaded = ok and next(chunk_by_id) ~= nil | ||
| end | ||
| local function resolve_subject(art_id, art_subid) | ||
| local chunk = chunk_by_id[art_id] | ||
| if not chunk then return nil end | ||
| local ok, subj = pcall(function() | ||
| local chunks = df.global.world.art_image_chunks.all | ||
| for i = 0, #chunks - 1 do | ||
| if chunks[i].id == art_id then | ||
| local imgs = chunks[i].images | ||
| if art_subid >= 0 and art_subid < #imgs then | ||
| local nm = tname(imgs[art_subid].name, true) | ||
| if nm then return nm end | ||
| end | ||
| return nil | ||
| end | ||
| end | ||
| local imgs = chunk.images | ||
| if art_subid >= 0 and art_subid < #imgs then return tname(imgs[art_subid].name, true) end | ||
| return nil | ||
@@ -239,2 +255,3 @@ end) | ||
| distinct_subjects = distinct, | ||
| art_images_loaded = art_images_loaded, | ||
| subjects_resolvable = any_resolved, | ||
@@ -257,7 +274,11 @@ quality = quality, | ||
| local artifacts, total, next_cursor = build_artifacts(cursor, limit) | ||
| local scope = (args[3] == 'world') and 'world' or 'site' | ||
| local artifacts, total, world_total, next_cursor = build_artifacts(cursor, limit, scope) | ||
| emit({ | ||
| scope = scope, | ||
| artifacts = artifacts, | ||
| artifact_count = total, | ||
| artifact_count_world = world_total, | ||
| returned = #artifacts, | ||
@@ -264,0 +285,0 @@ cursor = cursor, |
@@ -10,11 +10,9 @@ local json = require('json') | ||
| local terrain = reqscript('mcp_readTerrain') | ||
| local visibility = reqscript('mcp_unitVisibility') | ||
| local function footing(p) | ||
| local blk = dfhack.maps.getTileBlock(p.x, p.y, p.z) | ||
| if not blk then return { discovered = false } end | ||
| local des = blk.designation[p.x % 16][p.y % 16] | ||
| if des.hidden then return { discovered = false } end | ||
| local ch = terrain.sym(blk.tiletype[p.x % 16][p.y % 16], false) | ||
| return { discovered = true, symbol = ch, terrain = terrain.TERRAIN_LEGEND[ch], | ||
| open_to_sky = des.outside } | ||
| return { symbol = ch, terrain = terrain.TERRAIN_LEGEND[ch], open_to_sky = des.outside } | ||
| end | ||
@@ -90,3 +88,4 @@ | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) | ||
| and not (u.flags1.caged or u.flags1.chained) then | ||
| and not (u.flags1.caged or u.flags1.chained) | ||
| and not visibility.is_hidden(u) then | ||
| local p = u.pos | ||
@@ -107,7 +106,8 @@ local token = nil | ||
| for _, br in ipairs(bridges) do | ||
| local d = cheb(p.x, p.y, br.x, br.y) | ||
| local d = math.max(cheb(p.x, p.y, br.x, br.y), math.abs(br.z - p.z)) | ||
| if not best or d < best then best = d; bi = br end | ||
| end | ||
| if bi then | ||
| th.nearest_bridge = { x = bi.x, y = bi.y, z = bi.z, dist = best, | ||
| th.nearest_bridge = { x = bi.x, y = bi.y, z = bi.z, | ||
| dist = cheb(p.x, p.y, bi.x, bi.y), | ||
| dz = bi.z - p.z, dir = bearing(p.x, p.y, bi.x, bi.y) } | ||
@@ -114,0 +114,0 @@ end |
@@ -14,2 +14,3 @@ local json = require('json') | ||
| local WATER_LAYERS_CAP = 200 | ||
| local ICE_LAYERS_CAP = 200 | ||
| local FLOOD_RISK_CAP = 50 | ||
@@ -62,4 +63,13 @@ local WELLS_CAP = 20 | ||
| local ICE_WALKABLE = {} | ||
| for tt in ipairs(df.tiletype) do | ||
| local a = df.tiletype.attrs[tt] | ||
| if a.material == df.tiletype_material.FROZEN_LIQUID then | ||
| ICE_WALKABLE[tt] = df.tiletype_shape.attrs[a.shape].walkable and true or false | ||
| end | ||
| end | ||
| local aquifer_by_z = {} | ||
| local water_by_z = {} | ||
| local ice_by_z = {} | ||
| local magma_by_z = {} | ||
@@ -95,2 +105,10 @@ | ||
| if not terrain.is_hidden(x, y, z, blk) then | ||
| local walkable_ice = ICE_WALKABLE[blk.tiletype[lx][ly]] | ||
| if walkable_ice ~= nil then | ||
| local ib = ice_by_z[z] | ||
| if not ib then ib = { tiles = 0, walkable = 0, solid = 0 }; ice_by_z[z] = ib end | ||
| ib.tiles = ib.tiles + 1 | ||
| if walkable_ice then ib.walkable = ib.walkable + 1 | ||
| else ib.solid = ib.solid + 1 end | ||
| end | ||
| if des.water_table then | ||
@@ -215,2 +233,21 @@ local occ = blk.occupancy[lx][ly] | ||
| local ice_zs = {} | ||
| for z in pairs(ice_by_z) do ice_zs[#ice_zs + 1] = z end | ||
| table.sort(ice_zs, function(a, b) return a > b end) | ||
| local ice_layers = {} | ||
| for _, z in ipairs(ice_zs) do | ||
| local ib = ice_by_z[z] | ||
| ice_layers[#ice_layers + 1] = | ||
| { z = z, tiles = ib.tiles, walkable_tiles = ib.walkable, solid_tiles = ib.solid } | ||
| end | ||
| local ice_layers_total = #ice_layers | ||
| local ice_layers_truncated = false | ||
| if #ice_layers > ICE_LAYERS_CAP then | ||
| local capped = {} | ||
| for i = 1, ICE_LAYERS_CAP do capped[i] = ice_layers[i] end | ||
| ice_layers = capped | ||
| ice_layers_truncated = true | ||
| end | ||
| local magma_sea = nil | ||
@@ -272,2 +309,5 @@ do | ||
| local footing_glyphs = {} | ||
| for _, t in ipairs(flood_risk) do footing_glyphs[t.footing] = true end | ||
| emit({ | ||
@@ -280,2 +320,5 @@ aquifer_layers = aquifer_layers, | ||
| water_layers_truncated = water_layers_truncated, | ||
| ice_layers = ice_layers, | ||
| ice_layers_total = ice_layers_total, | ||
| ice_layers_truncated = ice_layers_truncated, | ||
| magma_sea = magma_sea, | ||
@@ -288,4 +331,4 @@ flood_risk_tiles = flood_risk, | ||
| wells_truncated = wells_truncated, | ||
| legend = terrain.TERRAIN_LEGEND, | ||
| legend = next(footing_glyphs) and terrain.legend_for(footing_glyphs) or nil, | ||
| scan = { complete = scan_complete, tiles_scanned = scanned_tiles, last_z_scanned = last_z_scanned }, | ||
| }) |
@@ -53,2 +53,60 @@ local json = require('json') | ||
| local DEATH_WINDOW_YEARS = 1 | ||
| local DEATH_SCAN_CAP = 5000 | ||
| local SITE_ID = df.global.plotinfo.site_id | ||
| local GROUP_ID = df.global.plotinfo.group_id | ||
| local function is_ours(hf) | ||
| if not hf then return false end | ||
| for _, link in ipairs(hf.entity_links) do | ||
| if link.entity_id == GROUP_ID | ||
| and tostring(df.histfig_entity_link_type[link:getType()]) == 'MEMBER' then | ||
| return true | ||
| end | ||
| end | ||
| return false | ||
| end | ||
| local since_year = df.global.cur_year - DEATH_WINDOW_YEARS | ||
| local citizen_deaths, slain_by_citizen, outsider_deaths_at_site = 0, 0, 0 | ||
| local death_causes = {} | ||
| pcall(function() | ||
| local ed = df.global.world.history.events_death | ||
| local scanned = 0 | ||
| for i = #ed - 1, 0, -1 do | ||
| scanned = scanned + 1 | ||
| if scanned > DEATH_SCAN_CAP then break end | ||
| local e = ed[i] | ||
| if e.year < since_year then break end | ||
| if e.site == SITE_ID then | ||
| local victim = df.historical_figure.find(e.victim_hf) | ||
| if is_ours(victim) then | ||
| citizen_deaths = citizen_deaths + 1 | ||
| local cause = tostring(df.death_type[e.death_cause]) | ||
| death_causes[cause] = (death_causes[cause] or 0) + 1 | ||
| local slayer = (e.slayer_hf ~= -1) and df.historical_figure.find(e.slayer_hf) or nil | ||
| if is_ours(slayer) then slain_by_citizen = slain_by_citizen + 1 end | ||
| else | ||
| outsider_deaths_at_site = outsider_deaths_at_site + 1 | ||
| end | ||
| end | ||
| end | ||
| end) | ||
| local cause_rows = {} | ||
| for cause, n in pairs(death_causes) do cause_rows[#cause_rows + 1] = { cause = cause, count = n } end | ||
| table.sort(cause_rows, function(a, b) | ||
| if a.count ~= b.count then return a.count > b.count end | ||
| return a.cause < b.cause | ||
| end) | ||
| local recent_deaths = { | ||
| since_year = since_year, | ||
| citizens = citizen_deaths, | ||
| citizens_slain_by_citizen = slain_by_citizen, | ||
| outsiders_at_site = outsider_deaths_at_site, | ||
| citizen_causes = cause_rows, | ||
| } | ||
| local UNHAPPY_FRACTION_ALERT = 0.10 | ||
@@ -66,2 +124,14 @@ local UNHAPPY_MIN_ALERT = 3 | ||
| end | ||
| if citizen_deaths > 0 then | ||
| local parts = {} | ||
| for _, r in ipairs(cause_rows) do | ||
| parts[#parts + 1] = r.count .. ' ' .. string.lower(r.cause):gsub('_', ' ') | ||
| end | ||
| alerts[#alerts+1] = citizen_deaths .. ' citizen death' .. (citizen_deaths == 1 and '' or 's') .. | ||
| ' since year ' .. since_year .. ' (' .. table.concat(parts, ', ') .. ')' | ||
| end | ||
| if slain_by_citizen > 0 then | ||
| alerts[#alerts+1] = slain_by_citizen .. ' of those citizen' .. | ||
| (slain_by_citizen == 1 and ' was' or 's were') .. ' slain by another citizen' | ||
| end | ||
@@ -75,3 +145,4 @@ emit({ | ||
| happiness = hap, | ||
| recent_deaths = recent_deaths, | ||
| alerts = alerts, | ||
| }) |
@@ -288,3 +288,3 @@ -- mcp_geology: see docs/tools/geology.md for the data model and field paths. | ||
| local out = { | ||
| surface_z = surface_z, | ||
| surface_z_max = surface_z, | ||
| layers = layers, | ||
@@ -291,0 +291,0 @@ aquifer = aquifer, |
@@ -21,9 +21,56 @@ local json = require('json') | ||
| local DESCRIPTIVE_PART_FLAGS = { edged_damage = true, diagnosed = true } | ||
| local function part_is_active(pt) | ||
| if pt.bleeding > 0 or pt.pain > 0 or pt.swelling > 0 or pt.strain > 0 then return true end | ||
| for name, set in pairs(pt.flags1) do | ||
| name = tostring(name) | ||
| if set == true and not DESCRIPTIVE_PART_FLAGS[name] and name:sub(1, 5) ~= 'scar_' then | ||
| return true | ||
| end | ||
| end | ||
| return pt.flags2.needs_setting == true | ||
| end | ||
| local function wound_is_active(w) | ||
| if w.flags.severed_part or w.flags.infection or w.flags.stuck_weapon or w.flags.popped_out then | ||
| return true | ||
| end | ||
| if w.pain > 0 or w.nausea > 0 or w.dizziness > 0 or w.paralysis > 0 | ||
| or w.numbness > 0 or w.fever > 0 then | ||
| return true | ||
| end | ||
| for _, pt in ipairs(w.parts) do | ||
| if part_is_active(pt) then return true end | ||
| end | ||
| return false | ||
| end | ||
| local function wound_state(u) | ||
| local ws = u.body and u.body.wounds | ||
| if not ws or #ws == 0 then return 'none' end | ||
| for _, w in ipairs(ws) do | ||
| local ok, active = pcall(wound_is_active, w) | ||
| if not ok or active then return 'active' end | ||
| end | ||
| return 'resolved' | ||
| end | ||
| local function is_asleep(u) | ||
| local job = u.job and u.job.current_job | ||
| return job ~= nil and job.job_type == df.job_type.Sleep | ||
| end | ||
| local citizens = dfhack.units.getCitizens(true) | ||
| local wounded, patients, bedridden, unconscious = 0, 0, 0, 0 | ||
| local old_wounds_only, asleep = 0, 0 | ||
| local care = {} | ||
| for _, u in ipairs(citizens) do | ||
| if u.body and u.body.wounds and #u.body.wounds > 0 then wounded = wounded + 1 end | ||
| if u.counters and u.counters.unconscious and u.counters.unconscious > 0 then | ||
| local state = wound_state(u) | ||
| if state == 'active' then wounded = wounded + 1 | ||
| elseif state == 'resolved' then old_wounds_only = old_wounds_only + 1 end | ||
| local sleeping = is_asleep(u) | ||
| if sleeping then asleep = asleep + 1 end | ||
| if u.counters and u.counters.unconscious and u.counters.unconscious > 0 and not sleeping then | ||
| unconscious = unconscious + 1 | ||
@@ -61,7 +108,9 @@ end | ||
| wounded = wounded, | ||
| old_wounds_only = old_wounds_only, | ||
| patients = patients, | ||
| bedridden = bedridden, | ||
| unconscious = unconscious, | ||
| asleep = asleep, | ||
| care_needs = care_needs, | ||
| alerts = alerts, | ||
| }) |
@@ -202,14 +202,20 @@ local json = require('json') | ||
| local bo = df.global.world.buildings.other | ||
| local function restraint_free(r) | ||
| if r.assigned ~= nil or r.chained ~= nil then return false end | ||
| return true | ||
| local function is_constructed(b) | ||
| local ok, done = pcall(function() return b:getBuildStage() >= b:getMaxBuildStage() end) | ||
| return not ok or done | ||
| end | ||
| local restraints_built, restraints_free = 0, 0 | ||
| for _, r in ipairs(bo.CHAIN or {}) do | ||
| restraints_built = restraints_built + 1 | ||
| if restraint_free(r) then restraints_free = restraints_free + 1 end | ||
| local function is_assigned(b) | ||
| local ok, assigned = pcall(function() return b:isAssigned() end) | ||
| return not ok or assigned | ||
| end | ||
| for _, r in ipairs(bo.CAGE or {}) do | ||
| restraints_built = restraints_built + 1 | ||
| if restraint_free(r) then restraints_free = restraints_free + 1 end | ||
| local restraints_built, restraints_free, restraints_unbuilt = 0, 0, 0 | ||
| for _, vec in ipairs({ bo.CHAIN or {}, bo.CAGE or {} }) do | ||
| for _, r in ipairs(vec) do | ||
| if is_constructed(r) then | ||
| restraints_built = restraints_built + 1 | ||
| if not is_assigned(r) then restraints_free = restraints_free + 1 end | ||
| else | ||
| restraints_unbuilt = restraints_unbuilt + 1 | ||
| end | ||
| end | ||
| end | ||
@@ -226,2 +232,3 @@ | ||
| restraints_free = restraints_free, | ||
| restraints_unbuilt = restraints_unbuilt, | ||
| } | ||
@@ -228,0 +235,0 @@ |
@@ -141,3 +141,3 @@ local json = require('json') | ||
| if core and surface_z == nil then | ||
| alerts[#alerts + 1] = 'fort core column is not open to sky (surface_z unknown)' | ||
| alerts[#alerts + 1] = 'fort core column is not open to sky (surface_z_at_core unknown)' | ||
| end | ||
@@ -148,3 +148,3 @@ | ||
| fort_core = core, | ||
| surface_z = surface_z, | ||
| surface_z_at_core = surface_z, | ||
| activity = { | ||
@@ -151,0 +151,0 @@ z_levels = activity_z, |
@@ -9,2 +9,4 @@ local json = require('json') | ||
| local visibility = reqscript('mcp_unitVisibility') | ||
| local WORN_MODES = { | ||
@@ -190,3 +192,4 @@ [df.inv_item_role_type.Worn] = true, | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) | ||
| and not (u.flags1.caged or u.flags1.chained) then | ||
| and not (u.flags1.caged or u.flags1.chained) | ||
| and not visibility.is_hidden(u) then | ||
| hostiles = hostiles + 1 | ||
@@ -193,0 +196,0 @@ if dfhack.units.isGreatDanger(u) then great_danger = great_danger + 1 end |
@@ -21,2 +21,8 @@ --@ module = true | ||
| function legend_for(glyphs) | ||
| local out = {} | ||
| for ch in pairs(glyphs) do out[ch] = TERRAIN_LEGEND[ch] or 'unknown glyph' end | ||
| return out | ||
| end | ||
| local SHAPE = df.tiletype_shape | ||
@@ -97,3 +103,3 @@ | ||
| fortifications = fortifications, | ||
| legend = TERRAIN_LEGEND, distinct = distinct, grid = rows, | ||
| legend = legend_for(distinct), distinct = distinct, grid = rows, | ||
| } | ||
@@ -100,0 +106,0 @@ end |
+1
-1
| { | ||
| "name": "dfhack-mcp", | ||
| "version": "1.4.0", | ||
| "version": "1.4.1", | ||
| "description": "MCP server exposing a live Dwarf Fortress fort to an AI agent as curated, semantic tools", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.alexanderolvera/dfhack-mcp", |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
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.
576411
3.2%1701
1.25%1
-50%33
6.45%