🎩 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.0.1
to
1.1.0
+12
-80
dist/dfhack-queries/mcp_artifacts.lua

@@ -1,41 +0,1 @@

-- mcp_artifacts(limit, cursor): the fort's named artifacts (paginated) plus an
-- aggregated summary of the map's engravings (never itemized per tile).
--
-- FACTS ONLY — this senses the fort's art; it never advises. Read-only.
--
-- Field paths were probed live on DFHack 53.15 against "Fortress of Dreams"
-- (78 pop, year 7) and are VERSION-FRAGILE; every risky read is pcall-guarded so
-- a missing field yields a labeled fact, not a traceback. Confirmed paths:
-- * ARTIFACTS: df.global.world.artifacts.all -> artifact_record.
-- .id, .name (a language_name; translate via dfhack.translation.translateName
-- with english=false -> dwarven, english=true -> the readable form), .item
-- (the actual item object).
-- * The item: item:getType() (df.item_type), dfhack.items.getValue(item),
-- item.quality (df.item_quality; artifacts read "Masterful" = 5), item.maker
-- (a HISTFIG id, NOT a unit id — cross-reference to a living citizen), and the
-- POLYMORPHIC item.description field (present on slabs = the engraved text;
-- ABSENT on e.g. item_shoesst, so it must be read defensively).
-- * DECORATIONS: item.improvements[] -> itemimprovement_*. :getType()
-- (df.improvement_type: BANDS/COVERED/ART_IMAGE/RINGS_HANGING/SPIKES/...),
-- .mat_type/.mat_index (decode via dfhack.matinfo.decode). ART_IMAGE
-- improvements carry an .image ref whose art image is NOT loaded on this world
-- (see engravings note) so the depicted scene is reported as unresolved, never
-- fabricated.
-- * ENGRAVINGS: df.global.world.event.engravings (NOT world.engravings, which
-- does not exist on this build). Each engraving_data has .art_id/.art_subid (the
-- depicted art image), .quality (df.item_quality), .artist (a histfig), .tile.
-- The art image itself lives in df.global.world.art_image_chunks.all, which is
-- EMPTY on this fort (0 chunks) — so the human scene text ("X striking down Y")
-- is not resolvable here. We still aggregate BY SUBJECT: engravings sharing an
-- art image reference are one subject bucket; when the scene text can't be
-- resolved the bucket is keyed by its stable image reference and flagged
-- subject_resolved=false. Never itemized per tile.
--
-- Maker cross-reference: item.maker is a historical figure. We map it to a live
-- unit_id ONLY when that histfig is a living current citizen (built from
-- dfhack.units.getCitizens(true), guarded by isAlive); otherwise unit_id is null.
--
-- Parameters arrive as native argv (args[1]=limit, args[2]=cursor) so there is no
-- escaping. Invoked by name via DFHack RunCommand; prints ONE JSON object.
local args = {...}

@@ -51,15 +11,9 @@

-- Documented caps (all are hard facts surfaced in the payload's `caps`).
local DEFAULT_LIMIT = 25 -- artifacts per page when limit is unset
local MAX_LIMIT = 100 -- ceiling on artifacts per page
local DECORATION_CAP = 16 -- decorations listed per artifact
local ENGRAVING_SCAN_CAP = 20000 -- max engravings scanned for aggregation
local SUBJECT_CAP = 40 -- max subject buckets returned
local ENGRAVER_CAP = 10 -- max top engravers returned
local DEFAULT_LIMIT = 25
local MAX_LIMIT = 100
local DECORATION_CAP = 16
local ENGRAVING_SCAN_CAP = 20000
local SUBJECT_CAP = 40
local ENGRAVER_CAP = 10
-- ---- helpers -------------------------------------------------------------
-- Defensive field read: DFHack raises when a field is absent from a polymorphic
-- subclass (e.g. item.description on non-slab items), so read optional fields
-- through pcall and treat a miss as nil.
local function sget(obj, field)

@@ -71,4 +25,2 @@ local ok, v = pcall(function() return obj[field] end)

-- Strip CP437 control bytes (e.g. the 0x0F artifact "☼" markers getDescription
-- wraps around names) so labels are clean UTF-safe text.
local function clean(s)

@@ -83,3 +35,3 @@ if not s then return nil end

local ok, v = pcall(dfhack.translation.translateName, name, english)
if ok and v and v ~= '' then return clean(v) end -- run names through clean() too, for consistency
if ok and v and v ~= '' then return clean(v) end
return nil

@@ -110,3 +62,2 @@ end

-- Map maker histfig -> live unit_id, but only for LIVING current citizens.
local citizen_by_hf = {}

@@ -132,9 +83,6 @@ do

if hf then out.name = tname(hf.name, true) end
-- unit_id present ONLY when the maker is a living current citizen.
out.unit_id = citizen_by_hf[hf_id] -- nil otherwise
out.is_current_citizen = citizen_by_hf[hf_id] ~= nil
out.unit_id = citizen_by_hf[hf_id]
return out
end
-- ---- decorations (item improvements) -------------------------------------
local function decorations(it)

@@ -154,4 +102,2 @@ local out, total = {}, 0

}
-- ART_IMAGE improvements depict an art image; the scene isn't loaded on
-- this world, so report that the image is present but unresolved.
if entry.type == 'ART_IMAGE' then

@@ -166,3 +112,2 @@ entry.image_resolved = false

-- ---- ARTIFACTS -----------------------------------------------------------
local function artifact_record(ar)

@@ -189,4 +134,2 @@ local it = ar.item

end
-- Slabs (and any item carrying one) expose an engraved-text description — the
-- storytelling inscription. Absent on most item classes, so read defensively.
rec.inscription = clean(sget(it, 'description'))

@@ -216,7 +159,2 @@ end

-- ---- ENGRAVINGS (aggregated by subject, never per-tile) ------------------
-- Best-effort art-image subject resolver. On this world art_image_chunks.all is
-- empty so this returns nil and callers fall back to the reference key; kept so
-- worlds that DO load art images get a human subject. Fully pcall-guarded.
local function resolve_subject(art_id, art_subid)

@@ -245,6 +183,6 @@ local ok, subj = pcall(function()

local buckets = {} -- key -> { subject, count, resolved, ref }
local order = {} -- insertion order of keys (stable tiebreak)
local quality = {} -- quality label -> count
local artists = {} -- histfig id -> count
local buckets = {}
local order = {}
local quality = {}
local artists = {}
local any_resolved = false

@@ -280,4 +218,2 @@

-- Sort subject buckets by count desc; ties broken deterministically by the
-- stable art-image ref key (art_id:art_subid).
local list = {}

@@ -293,3 +229,2 @@ for _, k in ipairs(order) do list[#list + 1] = buckets[k] end

-- Top engravers (which dwarves carved the most) — a small factual extra.
local eng_list = {}

@@ -315,4 +250,2 @@ for hf, n in pairs(artists) do eng_list[#eng_list + 1] = { histfig_id = hf, count = n } end

distinct_subjects = distinct,
-- Human scene text ("X striking down Y") is only available when art images are
-- loaded; false here means subjects are keyed by their stable image reference.
subjects_resolvable = any_resolved,

@@ -326,3 +259,2 @@ quality = quality,

-- ---- entry ---------------------------------------------------------------
local limit = tonumber(args[1] or '')

@@ -329,0 +261,0 @@ if not limit or limit < 1 then limit = DEFAULT_LIMIT end

@@ -1,28 +0,1 @@

-- mcp_blueprint: A2 actuator — quickfort blueprint designations. Backs two gated
-- MCP tools:
-- blueprint_apply ("plan_apply" / "apply_apply")
-- blueprint_undo ("plan_undo" / "apply_undo")
--
-- EXECUTE, NEVER DECIDE: the caller drafts the quickfort CSV, names the anchor and
-- the mode; this script designates exactly that and reports facts. No "you should
-- dig here" logic. The §A0 dry-run/confirm/undo loop lives in TS (src/actuator.ts);
-- this script answers plan_* (preview + signature, no mutation) and apply_* (mutate
-- + readback). Version-fragile struct access (tile designation flags, civzones)
-- stays here.
--
-- v1 scope: dig + zone only. build/place are rejected (blocked, no token) so nothing
-- partially applies. Quickfort is driven over RPC at EXPLICIT coords (no cursor):
-- the CSV is written to a UNIQUE temp file in the blueprints dir, run by basename
-- with `-c x,y,z`, then removed. The MALFORMED-CSV gate (spike #11): quickfort does
-- NOT error on a bad blueprint — it PARTIALLY applies and reports "Invalid key
-- sequences" / "could not be designated". So BOTH plan_apply and plan_undo run a
-- --dry-run (verified live: `quickfort undo ... --dry-run` completes without
-- mutating and reports the same stats), parse those stats, and BLOCK (no
-- confirm_token) when either is > 0. Per-cell diagnostic lines (e.g. `invalid key
-- sequence: "ZZZ" in cell B2`) are captured (bounded) as parse_errors so the
-- caller can locate the bad cell.
--
-- Invoked by name via DFHack RunCommand; args arrive UNESCAPED as `...` (multi-line
-- CSV survives intact — verified live). Prints ONE JSON object.
local json = require('json')

@@ -47,20 +20,8 @@ local function emit(t) print(json.encode(t)) end

local SUPPORTED = { dig = true, zone = true }
local FOG_CAP = 64 -- bounded fog-of-war sample list
local CONFLICT_CAP = 50 -- bounded conflicts list
local MSG_CAP = 20 -- bounded quickfort diagnostic-line capture
local MAX_FOOTPRINT = 10000 -- distinct-cell cap; a (WxH) bomb blocks, never expands
-- Distinct BYTE cap on the raw CSV. The footprint cap bounds occupied CELLS, but
-- blank/comment bytes add zero cells while still being written to the temp
-- blueprint file AND echoed verbatim into the preview/undo handle (memory + disk).
-- So an all-blank 100 KB CSV clears MAX_FOOTPRINT yet is unbounded payload. 64 KiB
-- is far above any legitimate hand-drafted blueprint; over it blocks (no token) in
-- validate(), shared by both blueprint_apply and blueprint_undo.
local FOG_CAP = 64
local CONFLICT_CAP = 50
local MSG_CAP = 20
local MAX_FOOTPRINT = 10000
local MAX_CSV_BYTES = 65536
-- ---- footprint parsing ----------------------------------------------------
-- Quote-aware CSV field split (RFC-4180-ish): spreadsheet-exported blueprints
-- quote cells with embedded commas ("#comment, note") and escape quotes by
-- doubling (""). Verified live: quickfort itself unquotes — `d,"#comment, x",d`
-- is a 3-cell row (footprint 2) — so a naive comma split would mis-place columns
-- and skew footprint/fog/readback/signature.
local function csv_fields(line)

@@ -96,14 +57,2 @@ local fields, buf, in_q = {}, {}, false

-- The occupied cells of the blueprint as distinct {dx,dy} offsets from the anchor.
-- The modeline is row -1; the first data row (directly below it) maps to the anchor
-- (verified live: `#dig`/`d,d` at -c X,Y,Z designates X,Y and X+1,Y). Blank cells
-- and comment (`#...`) cells are not occupied. Quickfort's (WxH) area-expansion
-- suffix is expanded down-and-right from the marked cell (verified: `n(2x2)` at
-- 84,40 covers 84-85,40-41), so the footprint matches what quickfort designates;
-- overlapping cells are de-duplicated so counts never double-report a tile.
--
-- Bounded: expansion happens BEFORE quickfort runs, so a hostile `d(9999x9999)`
-- cell would otherwise loop ~10^8 times here. Any single (WxH) whose area exceeds
-- MAX_FOOTPRINT, or a total distinct footprint past it, aborts with `err` set —
-- validate() turns that into a block (no token). Returns cells, err.
local function occupied_cells()

@@ -159,12 +108,4 @@ local cells, seen, err = {}, {}, nil

-- ---- validation (shared by every subcommand) ------------------------------
-- The mode arg is authoritative and must be dig|zone; the CSV's first non-blank
-- line must be a matching #dig / #zone modeline. A missing/malformed/mismatched
-- modeline is blocked here so quickfort's silent "bad modeline defaults to #dig"
-- behavior can never mis-designate. An over-budget footprint (see occupied_cells)
-- blocks here too, before any per-cell scan or quickfort run.
local function validate()
local blocked = {}
-- Byte cap first: cheapest gate, and it bounds the payload (temp file + echoed
-- handle) that the footprint cap alone leaves unbounded for blank/comment bytes.
if #csv > MAX_CSV_BYTES then

@@ -205,21 +146,2 @@ blocked[#blocked + 1] = string.format(

-- ---- per-cell live-state scan ----------------------------------------------
-- ONE pass over the footprint feeding both the preview facts and the signature:
-- fog fog-of-war count + bounded sample (a FACT, never a block — the
-- agent may intend to designate into the dark)
-- pre_existing cells ALREADY carrying this mode's designation (dig flag set /
-- civzone present) BEFORE this operation — quickfort's undo removes
-- designations on affected tiles regardless of who created them
-- (verified live: a manually-designated tile under the footprint is
-- cleared by undo), so this count drives faithful:false on the undo
-- handle
-- clipped / conflicts bounded structured conflict list [{x,y,reason}] with
-- reasons 'out of bounds' | 'already designated' | 'zone present' |
-- 'building present' (dig only; dfhack.buildings.findAtTile is
-- OOB-safe — verified live)
-- digest md5 over the SORTED per-cell "x,y,state,hidden" lines, where state
-- is the dig designation value (dig mode) or the sorted civzone id
-- list (zone mode). Aggregate counts alone can stay equal while the
-- underlying cells drift (two offsetting per-tile changes), so the
-- signature carries this per-cell digest.
local function scan_cells()

@@ -291,5 +213,2 @@ local cells = occupied_cells()

-- Readback: how many occupied cells currently carry the designation for this mode.
-- dig -> designation.dig set; zone -> a civzone covers the tile. Also the
-- BEFORE-apply pre-existing count (same question asked at a different moment).
local function readback()

@@ -311,9 +230,2 @@ local cells = occupied_cells()

-- The undo handle for apply_apply: quickfort's native `undo` faithfully reverts
-- dig/zone designations THIS apply created (verified live: dig flag
-- 0->apply->1->undo->0; zone 0->4->0) — but it clears the designation on EVERY
-- footprint tile, including ones the player had designated before this apply
-- (verified live). So faithful is true ONLY when no footprint tile carried a
-- pre-existing designation; otherwise not_reproduced names the loss as a fact
-- (mirrors the work-order faithful pattern).
local function undo_handle(pre_existing)

@@ -335,3 +247,2 @@ local h = {

-- ---- quickfort driver ------------------------------------------------------
local function bp_dir()

@@ -356,7 +267,2 @@ return dfhack.getDFPath() .. '/dfhack-config/blueprints/'

-- Run quickfort by temp-file basename at explicit coords; parse the printed stats.
-- Returns the parsed stat table (or nil + message). Always removes the temp file.
-- Diagnostic lines (everything quickfort prints BEFORE its "successfully
-- completed" marker, e.g. `invalid key sequence: "ZZZ" in cell B2`) are captured
-- bounded as `messages` so previews can locate the offending cell.
local function run_qf(command, dry)

@@ -396,5 +302,2 @@ local name, path, werr = write_temp()

-- MALFORMED / partial-apply gate shared by plan_apply and plan_undo: reasons
-- when the dry-run reports invalid keys or undesignatable tiles (spike #11 —
-- quickfort would PARTIALLY apply/undo otherwise).
local function gate_reasons(stats)

@@ -411,3 +314,2 @@ local gate = {}

-- Attach the bounded quickfort diagnostic lines to a preview (omitted when clean).
local function attach_parse_errors(preview, stats)

@@ -420,3 +322,2 @@ if #stats.messages > 0 then

-- ============================ apply ============================
if sub == 'plan_apply' or sub == 'apply_apply' then

@@ -426,3 +327,2 @@ local blocked = validate()

-- --------- plan_apply: dry-run, parse stats, gate, preview + signature -----
if sub == 'plan_apply' then

@@ -449,6 +349,2 @@ local scan = scan_cells()

attach_parse_errors(preview, stats)
-- Signature = target state: csv digest + anchor + mode + the dry-run stats +
-- the PER-CELL state digest (aggregate counts alone can stay equal while
-- individual tiles drift — e.g. one tile designated while another is
-- revealed — so the digest is what actually voids a stale token).
local signature = string.format('apply/%s/%d,%d,%d/%s/t=%d/cn=%d/ik=%d/cells=%s',

@@ -465,5 +361,2 @@ mode, x, y, z, md5(csv), tiles, stats.could_not, stats.invalid_keys, scan.digest)

-- --------- apply_apply: real run, changes + undo handle + readback ---------
-- Pre-existing designations are counted BEFORE mutating: they decide whether
-- the undo handle is faithful (undo would clear them too — see undo_handle).
local pre_existing = readback().designated_tiles

@@ -487,3 +380,2 @@ local stats, err = run_qf('run', false)

-- ============================ undo ============================
if sub == 'plan_undo' or sub == 'apply_undo' then

@@ -493,9 +385,4 @@ local blocked = validate()

-- --------- plan_undo: read state + VALIDATED dry-run (no mutation) ---------
-- `quickfort undo --dry-run` completes without touching designations and
-- reports the same "Invalid key sequences" stat (verified live), so a
-- malformed CSV with a valid modeline is gated here exactly like plan_apply —
-- otherwise apply_undo could partially revert.
if sub == 'plan_undo' then
local rb = readback() -- designated_tiles = what undo would clear right now
local rb = readback()
local scan = scan_cells()

@@ -511,5 +398,2 @@ local stats, err = run_qf('undo', true)

attach_parse_errors(preview, stats)
-- Signature = per-cell designation/zone identity + state (the digest), not
-- just the aggregate count: designating one tile while clearing another
-- leaves set=N unchanged but MUST void the token.
local signature = string.format('undo/%s/%d,%d,%d/%s/set=%d/cells=%s',

@@ -530,3 +414,2 @@ mode, x, y, z, md5(csv), rb.designated_tiles, scan.digest)

-- --------- apply_undo: real undo, changes + re-apply handle + readback -----
local stats, err = run_qf('undo', false)

@@ -544,3 +427,3 @@ if not stats then emit({ error = err }) return end

},
readback = readback(), -- designated_tiles should now be 0
readback = readback(),
})

@@ -547,0 +430,0 @@ return

@@ -1,35 +0,1 @@

-- mcp_chronicle(since, categories, limit): DF's announcement/report stream as
-- triaged, cursor-addressable events. Reads df.global.world.status.reports — a
-- rolling, front-pruned window (~3000 on this fort). Prints ONE JSON object.
--
-- Verified live on DFHack 53.15 against "Fortress of Dreams" (spike #9 de-risked
-- the contract; every path below was re-confirmed live). Version-fragile paths,
-- all read defensively:
-- * df.global.world.status.reports -> vector of `report`, id-ascending
-- * df.global.world.status.next_report_id -> PERSISTED monotonic counter
-- * report.{id,type,text,color,year,time,repeat_count,speaker_id,pos}
-- * report.flags.{continuation,announcement}
-- * df.announcement_type[report.type] -> stable token (the category key)
--
-- CURSOR: report.id is strictly monotonic, index-aligned and save/load-stable
-- (backed by the persisted next_report_id). `since` returns only id > since.
-- Omitted `since` -> most recent `limit`. Top-level `cursor` = highest RETAINED
-- id (we always scan up to the newest report), so the caller round-trips by
-- passing it back as `since`, even when the newest events were filtered/collapsed.
--
-- PRUNING: if `since` < the oldest retained id, the (since, oldest) gap was
-- front-pruned and is gone; we still return what we DO retain but set pruned=true
-- rather than imply completeness.
--
-- COMBAT-SPAM COLLAPSE: report.group_id / pool_id are NOT usable to group here
-- (group_id absent on 53.x; pool_id is 1:1 with index). Instead we (a) honor
-- repeat_count (native "(xN)"), (b) fold flags.continuation lines into the
-- preceding event, and (c) CAP consecutive runs of battle-category reports at
-- BATTLE_RUN_CAP, replacing the overflow with ONE collapsed marker carrying the
-- omitted count — so one siege cannot flood the window.
--
-- UNIT REFS: combat reports carry speaker_id/activity_id == -1 and no reliable
-- involved unit, so `speaker` is populated ONLY when speaker_id ~= -1 (resolved
-- via df.unit.find); otherwise omitted. pos is surfaced as a tile anchor when set.
local args = {...}

@@ -44,3 +10,2 @@ local json = require('json')

-- ---- args (native argv, all strings; '' == omitted) ----------------------
local since_arg = args[1]

@@ -62,5 +27,2 @@ local cats_arg = args[2] or ''

-- Requested category filter -> a set (empty == no filter). Unknown names are
-- kept in the set but simply never match, so a typo yields an empty result, not
-- an error.
local cat_filter, has_filter = {}, false

@@ -72,22 +34,11 @@ for tok in string.gmatch(cats_arg, '[^,]+') do

local BATTLE_RUN_CAP = 6 -- max consecutive battle events kept before collapsing
local BATTLE_RUN_CAP = 6
-- ---- category map: STATIC name->category over the FULL announcement_type enum
-- (authored, NOT a live snapshot — most categories have zero live samples on
-- this fort but must still be mapped). Prefix rules cover the big families;
-- EXACT overrides win first. Closed set:
-- death|birth|marriage|battle|siege|mood|artifact|migrants|diplomacy|
-- cave-in|megabeast ; everything else -> "other".
local EXACT = {
-- death
CITIZEN_DEATH='death', PET_DEATH='death', ADV_CREATURE_DEATH='death',
-- birth
BIRTH_CITIZEN='birth', BIRTH_ANIMAL='birth', BIRTH_WILD_ANIMAL='birth',
-- marriage
MARRIAGE='marriage', CITIZEN_MARRIED='marriage', NO_MARRIAGE_CELEBRATION='marriage',
EMBRACE='marriage',
-- mood
STRANGE_MOOD='mood', MOOD_BUILDING_CLAIMED='mood', ARTIFACT_BEGUN='mood',
POSSESSED_TANTRUM='mood',
-- artifact / masterwork
MADE_ARTIFACT='artifact', NAMED_ARTIFACT='artifact', MASTERPIECE_CRAFTED='artifact',

@@ -97,7 +48,5 @@ MASTERPIECE_CONSTRUCTION='artifact', MASTERPIECE_ENGRAVING='artifact',

COOKED_MASTERPIECE='artifact',
-- migrants
MIGRANT_ARRIVAL='migrants', MIGRANT_ARRIVAL_NAMED='migrants',
D_MIGRANTS_ARRIVAL='migrants', D_MIGRANT_ARRIVAL='migrants',
D_MIGRANT_ARRIVAL_DISCOURAGED='migrants', D_NO_MIGRANT_ARRIVAL='migrants',
-- diplomacy / trade / nobility
DIPLOMAT_ARRIVAL='diplomacy', LIAISON_ARRIVAL='diplomacy',

@@ -108,12 +57,8 @@ TRADE_DIPLOMAT_ARRIVAL='diplomacy', DIPLOMAT_LEFT_UNHAPPY='diplomacy',

SATISFIED_MONARCH='diplomacy', MOUNTAINHOME='diplomacy',
-- cave-in
CAVE_COLLAPSE='cave-in',
-- megabeast / semimegabeast / night creatures
MEGABEAST_ARRIVAL='megabeast', WEREBEAST_ARRIVAL='megabeast',
TITAN_ARRIVAL='megabeast', FORGOTTEN_BEAST_ARRIVAL='megabeast',
BEAST_AMBUSH='megabeast',
-- siege / infiltration
CITIZEN_SNATCHED='siege', CITIZEN_MISSING='siege', PET_MISSING='siege',
UNDEAD_ATTACK='siege', GHOST_ATTACK='siege',
-- battle (non-COMBAT_ prefixed mechanics)
STAND_UP='battle', NOT_STUNNED='battle', VERMIN_BITE='battle',

@@ -128,3 +73,2 @@ FALL_OVER='battle', CAUGHT_IN_FLAMES='battle', CAUGHT_IN_WEB='battle',

}
-- Prefix rules (checked after EXACT): family -> category.
local PREFIX = {

@@ -151,3 +95,2 @@ { 'COMBAT_', 'battle' },

-- ---- date formatting (report.time shares cur_year_tick's scale) -----------
local MONTHS = {'Granite','Slate','Felsite','Hematite','Malachite','Galena',

@@ -171,3 +114,2 @@ 'Limestone','Sandstone','Timber','Moonstone','Opal','Obsidian'}

-- ---- window bounds --------------------------------------------------------
local reports = df.global.world.status.reports

@@ -202,9 +144,4 @@ local n = #reports

local newest_id = reports[n - 1].id
-- pruned: the scan returns only ids > since, so the first id the caller still
-- wants is since+1. Events were actually lost only when that id falls below the
-- retained window (since+1 < oldest_id). At since == oldest_id-1 the next wanted
-- id IS oldest_id (retained), so nothing was pruned — don't over-warn.
local pruned = (since ~= nil) and (since + 1 < oldest_id)
-- ---- pass 1: build events (ascending), folding continuations & capping runs
local function pos_anchor(r)

@@ -230,4 +167,4 @@ local ok, p = pcall(function() return r.pos end)

local events = {}
local runlen = 0 -- consecutive battle events in the current run
local collapse_idx = nil -- index in `events` of the active battle collapse marker
local runlen = 0
local collapse_idx = nil
local battle_collapsed = 0

@@ -241,3 +178,2 @@

if is_cont and #events > 0 then
-- Fold a wrapped continuation line into the preceding real event.
local last = events[#events]

@@ -266,3 +202,2 @@ if not last.collapsed then

elseif runlen == BATTLE_RUN_CAP + 1 then
-- Open ONE collapse marker for the overflow of this run.
battle_collapsed = battle_collapsed + 1

@@ -278,3 +213,2 @@ local marker = {

else
-- Extend the active collapse marker.
battle_collapsed = battle_collapsed + 1

@@ -300,3 +234,2 @@ local marker = events[collapse_idx]

-- ---- pass 2: category filter (keep collapse markers only if battle wanted) --
if has_filter then

@@ -310,3 +243,2 @@ local kept = {}

-- ---- pass 3: most-recent `limit` (events are ascending; take the tail) ------
local total_after_filter = #events

@@ -319,7 +251,6 @@ if #events > limit then

-- Strip the internal max_id bookkeeping field from the emitted events.
for _, ev in ipairs(events) do ev.max_id = nil end
emit({
cursor = newest_id, -- highest retained id: pass back as `since`
cursor = newest_id,
oldest_retained_id = oldest_id,

@@ -336,8 +267,8 @@ newest_retained_id = newest_id,

count = #events,
more = total_after_filter > #events, -- older matching events exist beyond limit
more = total_after_filter > #events,
omitted_by_limit = math.max(0, total_after_filter - #events),
battle_collapsed = battle_collapsed, -- battle reports folded into collapse markers
battle_collapsed = battle_collapsed,
filtered_categories = has_filter and cats_arg or nil,
order = 'ascending', -- oldest -> newest
order = 'ascending',
events = events,
})

@@ -1,36 +0,1 @@

-- mcp_citizen(unit_id): a deep dossier on ONE citizen, chained by unit_id from
-- find_unit / chronicle. Facts only — labeled facts, never advice.
--
-- The query arrives as native argv (args[1] = unit_id, all digits), so there is
-- NO escaping. Returns ONE JSON object; a missing/invalid unit yields a labeled
-- {error}, never a traceback. Every version-fragile field is read through pcall
-- so a raws change on a future DF build degrades to a labeled fact, not a crash.
--
-- Field paths probed live on DFHack 53.15-r2 against "Fortress of Dreams":
-- * unit.status.current_soul.personality.traits[i] (0..100, indexed by
-- df.personality_facet_type) — notable extremes only (<=24 low, >=76 high).
-- * .personality.emotions[] — {type=df.emotion_type, thought=df.unit_thought_type,
-- subthought, severity, year, year_tick}. Thought text = the game's own
-- df.unit_thought_type.attrs[thought].caption. Entries with thought<0 are
-- stress-decay artifacts and are skipped. Chronological; we take the tail.
-- * .personality.stress / .longterm_stress; dfhack.units.getStressCategory.
-- * soul.skills[] — {id=df.job_skill, rating (df.skill_rating), rusty}.
-- * soul.preferences[] — df.unitpref_type; HateCreature = detest, else like.
-- Targets resolved via matinfo / raws.creatures / raws.descriptors / plants.
-- * RELATIONSHIPS (the walkable social graph):
-- - spouse: unit.relationship_ids.Spouse is a LIVE unit_id (primary); the
-- histfig SPOUSE link is the fallback for an off-map/absent spouse.
-- - parents/children: hf.histfig_links MOTHER/FATHER/CHILD -> target_hf;
-- df.historical_figure.find(hf).unit_id maps a hf to a live unit (or nil).
-- - friends/grudges: hf.info.relationships.hf_visual[] carries per-figure
-- core scores {love,trust,respect,loyalty,fear} + meet_count. In this save
-- the only negative dimension observed is trust (min -25); love/respect
-- never go negative. So: love>0 with no negative dim = friend; ANY negative
-- dim (trust/love/respect/loyalty < 0) = grudge. Family figures are excluded
-- from both lists. Both lists are capped; *_total reports the full count.
-- * worship: hf.histfig_links DEITY -> target_hf name + link_strength (0..100).
-- * physical: unit.body.size_info.size_cur (cm^3) + appearance.size_modifier
-- (100 = average build).
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local args = {...}

@@ -65,3 +30,2 @@ local query = args[1] or ''

-- Safe read: return fn() or a default if the field path is absent on this build.
local function safe(fn, default)

@@ -73,3 +37,2 @@ local ok, v = pcall(fn)

-- Humanize an ENUM_TOKEN into "enum token" for glanceable prose.
local function humanize(tok)

@@ -79,3 +42,2 @@ return (tostring(tok):gsub('_', ' '):lower())

-- Readable name for a historical figure id (translated name), or nil.
local function hf_name(hfid)

@@ -88,3 +50,2 @@ if not hfid or hfid < 0 then return nil end

-- Live unit_id backing a historical figure, or nil (dead / off-map / non-hf).
local function hf_unit_id(hfid)

@@ -101,3 +62,2 @@ if not hfid or hfid < 0 then return nil end

-- ---- identity ------------------------------------------------------------
local name = safe(function() return dfhack.units.getReadableName(u) end, 'unit ' .. query)

@@ -118,3 +78,2 @@ local profession = safe(function() return dfhack.units.getProfessionName(u) end, '')

-- ---- stress --------------------------------------------------------------
out.stress = {

@@ -126,3 +85,2 @@ level = sc and (STRESS[sc] or tostring(sc)) or 'unknown',

-- ---- personality: notable extreme facets only ----------------------------
local function facet_level(v)

@@ -150,3 +108,2 @@ if v <= 9 then return 'very low'

table.sort(extremes, function(a, b)
-- most-extreme first (distance from the midpoint 50)
return math.abs(a.value - 50) > math.abs(b.value - 50)

@@ -156,7 +113,5 @@ end)

-- ---- relationships (the walkable social graph) ---------------------------
local rel = { children = {}, parents = {}, friends = {}, grudges = {} }
local family_hf = {} -- hfids to exclude from friend/grudge lists
local family_hf = {}
-- spouse: prefer the live unit_id in relationship_ids.
local spouse_uid = safe(function() return u.relationship_ids.Spouse end, -1)

@@ -173,3 +128,2 @@ if spouse_uid and spouse_uid >= 0 then

-- family + deity via histfig links.
local worship = {}

@@ -202,3 +156,2 @@ safe(function()

-- friends / grudges from the fort acquaintance store (hf_visual).
local friends_all, grudges_all = {}, {}

@@ -218,3 +171,2 @@ safe(function()

local meet = safe(function() return e.meet_count end, nil)
-- Which bond dimensions are actually negative (the discriminating facts).
local neg_dims = {}

@@ -225,6 +177,2 @@ if love < 0 then neg_dims[#neg_dims+1] = 'love' end

if loyalty < 0 then neg_dims[#neg_dims+1] = 'loyalty' end
-- A grudge is an outright negative bond: negative love, or a negative
-- feeling with no positive love to offset it. A relationship the dwarf
-- still LOVES (love > 0) is not a grudge even if e.g. trust is negative —
-- it falls through to friends, which carries the raw scores anyway.
local is_grudge = (love < 0) or (#neg_dims > 0 and love <= 0)

@@ -246,3 +194,2 @@ if is_grudge then

end)
-- friends: strongest affection first; grudges: most-distrusted first.
table.sort(friends_all, function(a, b) return a.affection > b.affection end)

@@ -256,3 +203,2 @@ table.sort(grudges_all, function(a, b) return a.trust < b.trust end)

-- ---- skills of note ------------------------------------------------------
local skills = {}

@@ -277,3 +223,2 @@ safe(function()

-- ---- preferences (likes / detests) ---------------------------------------
local PT = df.unitpref_type

@@ -321,3 +266,2 @@ local function pref_target(pr)

-- ---- physical highlights -------------------------------------------------
local physical = {}

@@ -334,3 +278,2 @@ physical.body_size_cm3 = safe(function() return u.body.size_info.size_cur end, nil)

-- ---- recent thoughts (capped; the game's own phrasing) -------------------
local thoughts = {}

@@ -344,3 +287,2 @@ safe(function()

end
-- emotions are appended chronologically; take the tail (most recent), newest first.
local startv = math.max(1, #real - THOUGHT_CAP + 1)

@@ -347,0 +289,0 @@ for i = #real, startv, -1 do

@@ -1,37 +0,1 @@

-- mcp_defenses: where the threats are vs. what you have to fight them with.
--
-- FACTS ONLY. This script extracts positions, structures, and the RELATIVE
-- geometry between them (tile distance (Chebyshev, since DF movement is
-- 8-directional), z-level delta, 8-way compass bearing). It deliberately does
-- NOT decide what to DO about it -- no "atom-smash them" advice, no per-trait
-- caveats. Tactical judgment is the agent's job (and creature-trait facts live
-- in identify()); doctrine baked into this version-fragile boundary would
-- proliferate and drift. Interpretation stays out; only ground truth ships.
--
-- LEVEL 2 (terrain-aware, issue #4): each threat is classified inside/outside the
-- fort's WALLED PERIMETER, defined concretely as "shares a walkability group with
-- the fort's citizens" -- DF precomputes a walk group per walkable tile (3D:
-- stairs/ramps included), and two tiles are mutually walk-reachable iff they share
-- one nonzero group. So a threat is `inside` when a hostile could path to your
-- population through connected, open, walkable space without breaching a wall.
-- (This realizes the ticket's "flood-fill from core over walkable non-wall tiles"
-- using DF's own walk groups, anchored on citizens rather than the core centroid,
-- which can land inside rock.) Plus a `perimeter_terrain` readout of the primary
-- fort level via the shared mcp_readTerrain helper -- walls, fortifications,
-- open-to-sky vs covered, fog of war.
--
-- LIMITATIONS (facts, not advice, so the agent knows the edges): walk-group
-- connectivity is walking-only -- a FLIER or BUILDING_DESTROYER can reach you
-- while classified `outside` (cross-reference the trait facts in threats()/
-- identify()). `perimeter_terrain` is a single z-level (the busiest citizen
-- level); it does not synthesize a multi-z approach vector. Undiscovered tiles
-- are fog of war ('?') and never leak their real type. A threat on non-walkable
-- footing (flying, open pit) has walk_group 0 and reads `outside`.
--
-- Verified live on 53.15-r2: world.buildings.all + b:getType() (df.building_type
-- Bridge/Trap/Door/Floodgate/Hatch); bridge x1/y1/x2/y2/z/centerx/centery/
-- direction; trap b.trap_type (df.trap_type CageTrap/Lever/...); door
-- door_flags.forbidden; unit u.pos; dfhack.maps.getWalkableGroup(xyz2pos(...)).
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -47,5 +11,2 @@ local function emit(t) print(json.encode(t)) end

-- Terrain at a unit's own tile, honoring fog of war: an undiscovered tile reports
-- only { discovered = false } and NEVER leaks its real shape (the substrate rule),
-- even though the unit's position itself is known from the unit list.
local function footing(p)

@@ -61,6 +22,5 @@ local blk = dfhack.maps.getTileBlock(p.x, p.y, p.z)

-- 8-directional tile distance + z delta + compass bearing between two points.
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 -- +x east, +y south
local dx, dy = tox - fromx, toy - fromy
local tol = 2

@@ -73,10 +33,4 @@ local s = ''

-- One pass over citizens builds three references:
-- * fort core = 3D centroid (geometry/bearing anchor, as before)
-- * interior = the walkability groups citizens stand in (the "walled
-- perimeter": inside == a threat shares one of these groups)
-- * primary lvl = the z with the most citizens + its xy centroid (where the
-- perimeter_terrain window is read)
local cx, cy, cz, n = 0, 0, 0, 0
local interior_groups = {} -- walk_group -> citizen count
local interior_groups = {}
local z_count, z_sx, z_sy = {}, {}, {}

@@ -96,9 +50,5 @@ for _, u in ipairs(dfhack.units.getCitizens(true)) do

-- Primary fort level: the busiest citizen z.
local primary_z, primary_n = nil, -1
for z, cnt in pairs(z_count) do if cnt > primary_n then primary_n = cnt; primary_z = z end end
-- Encode interior groups as an explicit list (an integer-keyed Lua table would
-- JSON-encode as a null-padded array). Walk-group ids are per-snapshot, not
-- stable across frames -- computed fresh every call, never persisted.
local interior = { groups = {}, primary_group = nil, citizens = n }

@@ -114,3 +64,2 @@ do

-- Structures.
local bt = df.building_type

@@ -141,3 +90,2 @@ local bridges = {}

-- Active hostiles with positions + geometry to the fort core and nearest bridge.
local threats = {}

@@ -153,4 +101,2 @@ for _, u in ipairs(df.global.world.units.active) do

pos = { x = p.x, y = p.y, z = p.z } }
-- inside/outside the walled perimeter: does this tile share a citizen
-- walk group? (0 = no walkable footing, e.g. a flier over open space.)
local g = dfhack.maps.getWalkableGroup(xyz2pos(p.x, p.y, p.z))

@@ -164,3 +110,2 @@ th.walk_group = g

end
-- nearest bridge to this threat
local best, bi = nil, nil

@@ -179,5 +124,2 @@ for _, br in ipairs(bridges) do

-- Perimeter terrain: one bounded window on the busiest citizen level, read via
-- the shared mcp_readTerrain helper (walls, fortifications, open-to-sky vs
-- covered, fog of war). Centered on that level's citizen centroid, clamped to map.
local perimeter_terrain = nil

@@ -184,0 +126,0 @@ if primary_z then

@@ -1,43 +0,1 @@

-- mcp_environment: the fort's ambient conditions RIGHT NOW -- season, weather,
-- surface temperature (is exposed water frozen?), the alignment of the biomes the
-- player knew at embark, and, for each cavern the fort has ALREADY breached,
-- whether it is currently open to fort pathing or sealed off.
--
-- FACTS ONLY: labeled current-state readings, never advice. Threshold restatements
-- (e.g. "surface water is frozen") go in `alerts`, which mirrors what the game
-- itself would surface -- no "dig deeper" / "wall it off" counsel.
--
-- FOG-OF-WAR HONEST (a HARD invariant): this reports NOTHING about undiscovered
-- cavern layers. A cavern appears in `caverns` only if the game's own Discovered
-- flag is set (the player has breached it); the open/sealed pathing test then
-- considers ONLY revealed (non-hidden) tiles. A world with three caverns none of
-- which the fort has reached emits `caverns: []` and never leaks their existence.
--
-- Small, FIXED-size payload: season/weather/temperature are scalars, biome is three
-- booleans, and caverns is capped at the (<=3) layers actually breached. Nothing in
-- here grows with fort age or map size.
--
-- Data model (verified live on 53.15 vs the frozen 78-pop fixture):
-- * Season : df.global.cur_season (0..3 = spring/summer/autumn/winter).
-- * Weather : df.global.current_weather is a 5x5 grid of df.weather_type
-- (0 None / 1 Rain / 2 Snow); the dominant cell is the fort weather.
-- * Temp : block.temperature_1[lx][ly] at a surface tile, in DF units where
-- 10000 == the melting/freezing point of water. <=10000 => exposed
-- water is ice. plotinfo.hi_temp/lo_temp read a 60001 sentinel here
-- and are NOT used.
-- * Biome : the SURFACE biome per column resolves via
-- dfhack.maps.getTileBiomeRgn(pos) -> (world_x, world_y) THEN the
-- world_region whose region_coords contains it, carrying evil / good
-- / reanimating booleans -- exactly the surroundings shown at embark.
-- (getTileBiomeRgn underground collapses to the site region, so the
-- sample MUST be taken at each column's real surface tile.)
-- * Caverns : block.global_feature -> dfhack.maps.getGlobalInitFeature(idx); a
-- feature_init_subterranean_from_layerst is a cavern, start_depth+1
-- its number (1..3). f.flags.Discovered gates disclosure. Open ==
-- a revealed cavern tile shares a citizen walkability group (DF's own
-- 3D reachability, as in mcp_defenses); else sealed.
-- NOTE: per-tile SAVAGERY lives in world_data.region_map, which HARD-CRASHES this
-- DFHack build on any access, so `savage` is not reported (facts-only: no guess).
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -51,4 +9,4 @@ local function emit(t) print(json.encode(t)) end

local WATER_FREEZE = 10000 -- DF temperature units: melting point of water
local BIOME_STEP = 11 -- surface-sample grid stride (tiles)
local WATER_FREEZE = 10000
local BIOME_STEP = 11
local SEASONS = { [0] = 'spring', [1] = 'summer', [2] = 'autumn', [3] = 'winter' }

@@ -60,7 +18,5 @@ local WEATHER = { [0] = 'none', [1] = 'rain', [2] = 'snow' }

-- ---- season ----
local season = df.global.cur_season
local season_name = SEASONS[season] or tostring(season)
-- ---- weather: dominant cell over the 5x5 grid ----
local weather = 'none'

@@ -83,6 +39,2 @@ local raining, snowing = false, false

-- ---- citizen walkability groups (the "reachable by the fort" set) ----
-- Mirrors mcp_defenses: DF precomputes a 3D walk group per walkable tile; two tiles
-- are mutually reachable iff they share one nonzero group. A cavern is "open" when a
-- revealed cavern tile lands in a group a citizen also stands in.
local citizen_groups = {}

@@ -97,6 +49,2 @@ for _, u in ipairs(dfhack.units.getCitizens(true)) do

-- ---- surface pass: per-column true-surface biome + ambient temperature ----
-- One downward scan per sampled column to the first DISCOVERED, OUTSIDE, solid tile
-- (the real surface); there we read the tile temperature and the surface biome
-- region. Fog of war stays honest: hidden columns contribute nothing.
local function region_at(rx, ry)

@@ -112,3 +60,3 @@ for i = 0, #wd.regions - 1 do

local rgn_seen = {} -- world-region index -> true (dedup)
local rgn_seen = {}
local evil, good, reanimating = false, false, false

@@ -149,9 +97,3 @@ local temps = {}

-- representative surface temperature = median of the samples (robust to a stray
-- sun-warmed construction tile). No samples (fully roofed/hidden) => UNKNOWN: we
-- leave surface_temp AND water_frozen nil rather than fabricating a `false`, so we
-- never claim water is liquid without having read a temperature. This encoder can't
-- emit JSON null, so nil keys are simply omitted here and the TS wrapper normalizes
-- them to explicit null (keeping the fixed key set + number|null contract).
local surface_temp -- nil if no surface sample
local surface_temp
if #temps > 0 then

@@ -161,15 +103,8 @@ table.sort(temps)

end
local water_frozen -- nil (=> unknown) unless a temperature was actually read
local temperature_band = 'unknown'
local water_frozen
if surface_temp ~= nil then
water_frozen = surface_temp <= WATER_FREEZE
temperature_band = water_frozen and 'freezing' or 'above_freezing'
end
-- ---- caverns: only those the fort has BREACHED (Discovered), open vs sealed ----
-- Collect the distinct global-feature ids referenced by loaded blocks, resolve each
-- to its init feature, and keep the DISCOVERED subterranean (cavern) layers. Then a
-- single block pass tests, per discovered cavern, whether a REVEALED tile shares a
-- citizen walk group (open) or none do (sealed).
local cavern_of_gf = {} -- global_feature id -> cavern number (1..3)
local cavern_of_gf = {}
local seen_gf = {}

@@ -192,3 +127,3 @@ for _, b in ipairs(m.map_blocks) do

local cavern_open = {} -- cavern number -> bool (open to fort)
local cavern_open = {}
for _, num in pairs(cavern_of_gf) do cavern_open[num] = false end

@@ -203,7 +138,2 @@ local any_discovered = next(cavern_of_gf) ~= nil

for ly = 0, 15 do
-- Only tiles that ACTUALLY belong to the cavern count: the block-level
-- global_feature says "this 16x16 has cavern tiles", but the per-tile
-- designation.feature_global flag says WHICH ones. Without it a stray
-- revealed, citizen-reachable tunnel tile sharing the block would mark a
-- SEALED cavern "open". Gate on both, plus revealed + a shared walk group.
local des = b.designation[lx][ly]

@@ -227,3 +157,2 @@ if des.feature_global and not des.hidden then

-- ---- alerts: factual restatements the game would nag about ----
local alerts = {}

@@ -244,3 +173,2 @@ if water_frozen then alerts[#alerts + 1] = 'surface water is frozen' end

temperature = surface_temp,
temperature_band = temperature_band,
water_frozen = water_frozen,

@@ -247,0 +175,0 @@ weather = weather,

@@ -1,14 +0,1 @@

-- mcp_findUnit(query): a dossier on citizens matching a name or profession.
--
-- The one parameterized query. The search term arrives as native argv (args[1]),
-- so there is NO escaping — an apostrophe or backslash in the term is just data.
-- Matches case-insensitively against the readable name AND the profession, so
-- "medical" finds the chief medical dwarf and a partial name finds the dwarf.
-- Returns a compact profile per match: profession, age, stress, current job,
-- squad, and a health summary.
--
-- Verified live on 53.15-r2: getReadableName, getProfessionName, getAge,
-- getStressCategory all present; squad lookup via squads.all by id.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local args = {...}

@@ -29,3 +16,2 @@ local query = args[1] or ''

-- Pre-index fort squads by id for name lookup.
local squad_name = {}

@@ -52,3 +38,3 @@ local fort = df.global.plotinfo.main.fortress_entity

matches[#matches+1] = {
unit_id = u.id, -- the live id to chain into citizen()/identify()
unit_id = u.id,
name = name,

@@ -55,0 +41,0 @@ profession = prof,

@@ -1,5 +0,1 @@

-- mcp_fortStatus: one-call situational overview of the loaded fort — name, date,
-- season, population, wealth, a happiness breakdown, and a pre-triaged alerts list.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -13,4 +9,2 @@ local function emit(t) print(json.encode(t)) end

-- Fog-of-war gate: mirrors mcp_threats — a hostile the fort hasn't discovered
-- must never contribute to the count/alert here either.
local visibility = reqscript('mcp_unitVisibility')

@@ -60,13 +54,6 @@

-- Unhappy dwarves scale with population: a handful stressed at any moment is
-- normal churn, not news. 'miserable' (stress category <= 0) is different — one
-- can tantrum or go insane, so it's notable at any count. Gate 'unhappy' on BOTH
-- a share of population AND a minimum head count, so it means a fort-wide morale
-- driver — and so a share alone can't cry wolf on a tiny fort (1 unhappy on a
-- 7-dwarf embark is 14% but not news).
local UNHAPPY_FRACTION_ALERT = 0.10 -- tunable: unhappy share over this ...
local UNHAPPY_MIN_ALERT = 3 -- ... AND at least this many unhappy -> alert
local UNHAPPY_FRACTION_ALERT = 0.10
local UNHAPPY_MIN_ALERT = 3
local alerts = {}
if hap.miserable > 0 then alerts[#alerts+1] = hap.miserable .. ' dwarves miserable' end
if hap.unhappy >= UNHAPPY_MIN_ALERT and #citizens > 0

@@ -73,0 +60,0 @@ and (hap.unhappy / #citizens) >= UNHAPPY_FRACTION_ALERT then

@@ -1,71 +0,1 @@

-- mcp_gameData(query, kind): look up the LOADED WORLD's raws (df.global.world.raws.*)
-- — ground truth for THIS world, the only source for procedural creatures
-- (demons/forgotten beasts/titans, which are never on the wiki).
--
-- One unified query with a per-kind dispatch. Implemented kinds: CREATURE,
-- MATERIAL, PLANT, REACTION, ITEM, BUILDING. Every kind mirrors the creature
-- matching contract: a single strong (exact) hit -> a curated dossier; several
-- -> a disambiguation list (cap 8); none -> {match_count:0, matches:[]}.
--
-- Field paths for the non-creature kinds were probed live on DFHack 53.15-r2
-- against the Dreamfort fort and are version-fragile. Confirmed paths:
-- * MATERIAL: dfhack.matinfo (find(token) / decode(0, inorganic_index)); the
-- searchable universe is df.global.world.raws.inorganics.all (metals, stones,
-- gems, ores). mi:getToken(), mi.material.state_name.{Solid,Liquid,Gas},
-- .heat.{melting_point,boiling_point,ignite_point} (60001 == none),
-- .solid_density/.liquid_density (-1 == n/a), .flags (bitfield of stable
-- token keys). DF temperature urists convert to Fahrenheit via (urist-9968).
-- * PLANT: df.global.world.raws.plants.all -> plant_raw. .id/.name/.name_plural,
-- .flags (TREE/GRASS decide type, SPRING..WINTER seasons, BIOME_* biomes),
-- .underground_depth_min (0 == surface), .material_defs.type[df.plant_material_def]
-- (>=0 means that yield exists: drink/seed/thread/mill/extract_*), .growths[],
-- .material[] (produced materials).
-- * REACTION: df.global.world.raws.reactions.reactions -> reaction. .code/.name,
-- .skill (df.job_skill), .building.{type,subtype,custom} are PARALLEL vectors
-- (a reaction can run at several buildings — iterate all i, not just [0]):
-- type[i] (df.building_type) + subtype[i] (df.workshop_type / df.furnace_type)
-- + custom[i] (links to a custom building_def by its .id). .reagents[]/.products[]
-- (item_type via df.item_type, reaction_class carries a material class); both
-- reagents and products are POLYMORPHIC — read fields via sget (improvement
-- products lack item_type/count).
-- * ITEM: df.global.world.raws.itemdefs.all -> itemdef_*st. Class from the type
-- name (itemdef_<class>st). .id/.name/.name_plural/.adjective/.value + a
-- per-class stat whitelist read defensively (missing fields pcall-skipped).
-- * BUILDING: df.global.world.raws.buildings.all -> building_def_workshopst
-- (custom, raws-defined workshops only; built-in shops are hardcoded, not in
-- raws). .code/.name/.building_type/.labor_description/.dim_x/.dim_y, plus the
-- reactions where ANY .building.custom[i] == this def's .id (capped at 8).
--
-- Parameters arrive as native argv (args[1]=query, args[2]=kind), so there is NO
-- escaping — the search term is just data.
--
-- CREATURE matching contract:
-- * query is all digits -> treat as a live unit_id (fusion shortcut):
-- df.unit.find(id).race indexes
-- raws.creatures.all -> that unit's race.
-- * exact creature_id token match -> single strong hit (dossier).
-- * exact name/caste_name match -> single strong hit (dossier).
-- * otherwise case-insensitive substring against creature_id + the name tuple
-- (singular/plural/adjective) + every caste_name. Exactly one match ->
-- dossier; several -> a disambiguation list (cap 8), mirroring find_unit;
-- none -> {match_count:0, matches:[]}.
--
-- Verified live on DFHack 53.15-r2 against the two "Flame Phantom" demons
-- (DEMON_4, unit_id 18393, race 1661). Confirmed version-fragile field paths:
-- * df.global.world.raws.creatures.all[race] -> creature_raw
-- * cr.creature_id (token), cr.name[0..2] (singular/plural/adjective)
-- * cr.adultsize (body volume, cm^3), cr.caste (vector; the field is `caste`,
-- NOT `castes`), caste.caste_name[0..2], caste.description (a ready blurb),
-- caste.flags (a bitfield whose TRUE keys are stable token names — iterate
-- pairs(); NOT indexed by df.caste_raw_flags, so we never index it by token)
-- * caste.body_info.attacks[].{name,verb_3rd} (dup per left/right bp -> dedupe)
-- * caste.body_info.interactions[].interaction.adv_name (breath weapon label,
-- e.g. "Hurl fireball"/"Spray jet of fire"/"Emit dust") + material_str0..2
-- (the emitted material token, e.g. CREATURE_MAT:DEMON_4:POISON — the dust's
-- syndrome material). The syndrome vector on the resolved material reads 0 in
-- this build, so we surface the emission material token rather than traverse
-- a fragile/empty syndrome path.
-- * df.unit.find(id).race for the unit_id shortcut.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local args = {...}

@@ -97,3 +27,2 @@ local query = args[1] or ''

-- DF body volume (cm^3) -> a glanceable size bucket.
local function size_label(v)

@@ -108,3 +37,2 @@ if v < 1000 then return 'tiny'

-- First sentence of a caste description -> a short human blurb.
local function first_sentence(desc)

@@ -117,4 +45,2 @@ if not desc or desc == '' then return nil end

-- Curated advisor flags, unioned across all castes (a bitfield of stable token
-- keys; we only keep TRUE keys that are whitelisted).
local function creature_flags(cr)

@@ -127,6 +53,2 @@ local set = {}

end
-- Building destroyer is NOT a caste.flags bit in this build; it's a numeric
-- at caste.misc.buildingdestroyer (confirmed: DEMON_4 = 2, TROLL = 2). Surface
-- it as a synthetic BUILDINGDESTROYER flag so the whitelisted token isn't dead
-- and consumers (identify's tactics) see it alongside the real flags.
local bd = 0

@@ -142,3 +64,2 @@ pcall(function() bd = caste.misc.buildingdestroyer or 0 end)

-- Melee attacks, deduped by name (raws list one per left/right body part).
local function creature_attacks(caste)

@@ -155,4 +76,2 @@ local seen, out = {}, {}

-- Breath weapons / creature interactions: the human adv_name plus the emitted
-- material token (which carries the syndrome, e.g. dust) when present.
local function creature_interactions(caste)

@@ -181,3 +100,2 @@ local out = {}

-- Full curated dossier for one creature_raw.
local function dossier(cr, unit_id, unit_name)

@@ -198,3 +116,2 @@ local caste = best_caste(cr)

description = (desc ~= '' and desc) or nil,
blurb = first_sentence(desc),
unit_id = unit_id,

@@ -205,3 +122,2 @@ unit_name = unit_name,

-- Compact entry for a disambiguation list.
local function stub(cr)

@@ -219,5 +135,3 @@ local blurb = first_sentence(best_caste(cr).description)

-- ---- CREATURE kind -------------------------------------------------------
local function find_creature(q)
-- Fusion shortcut: an all-digits query is a live unit_id.
if string.match(q, '^%d+$') then

@@ -244,3 +158,2 @@ local u = df.unit.find(tonumber(q))

local token = tostring(cr.creature_id)
-- gather candidate names: creature name tuple + every caste_name
local hit, is_exact = false, false

@@ -271,7 +184,5 @@ if lc(token) == ql then is_exact = true; hit = true

-- One strong (exact) hit, or a single overall hit -> a full dossier.
if #exact == 1 then emit(dossier(exact[1])); return end
if #all == 1 then emit(dossier(all[1])); return end
-- Otherwise a disambiguation list (cap MAX), mirroring find_unit.
local matches = {}

@@ -287,7 +198,2 @@ for i = 1, math.min(#all, MAX) do matches[#matches+1] = stub(all[i]) end

-- Generic exact-then-substring searcher shared by every non-creature kind.
-- `entries` is a list of records; `keys(rec)` returns that record's searchable
-- strings; `dossier1(rec)` builds a full dossier; `stub1(rec)` a compact entry.
-- Mirrors the creature contract: one exact hit, or one overall hit -> dossier;
-- else a capped disambiguation list.
local function search(q, entries, keys, dossier1, stub1)

@@ -312,4 +218,2 @@ local ql = lc(q)

if #all == 0 then return false end
-- Cap the disambiguation list at MAX, but list exact matches first so the
-- record the caller most likely meant is never truncated out of view.
local matches, seen = {}, {}

@@ -332,5 +236,2 @@ for _, rec in ipairs(exact) do

-- Safe field read: DFHack raises when a field is absent from a polymorphic
-- subclass (e.g. a non-item reaction product), so read subclass-specific or
-- optional fields through pcall and treat a miss as nil.
local function sget(obj, field)

@@ -342,5 +243,2 @@ local ok, v = pcall(function() return obj[field] end)

-- Assemble an ITEM_TYPE[:SUBTYPE] token from a reagent or product. item_type /
-- item_str live only on the *_itemst subclasses, so read them defensively — a
-- non-item reagent/product (e.g. an improvement) yields nil instead of raising.
local function item_token(obj)

@@ -355,5 +253,2 @@ local parts = {}

-- ---- MATERIAL kind -------------------------------------------------------
-- DF temperature is stored in "urists": degF = urist - 9968. 60001 is the
-- sentinel for "no such point" (won't melt / boil / ignite); a real 60000 is kept.
local function temp_fact(urist)

@@ -411,4 +306,2 @@ if not urist or urist > 60000 then return nil end

local function find_material(q)
-- A fully-qualified token (has a ':') is a direct matinfo lookup — this reaches
-- non-inorganic materials (PLANT/CREATURE tissues) the inorganic index misses.
if string.find(q, ':', 1, true) then

@@ -428,4 +321,2 @@ local ok, mi = pcall(dfhack.matinfo.find, q)

if handled then return end
-- No inorganic matched: try a bare-token matinfo lookup (builtin materials
-- like WATER / COAL), else report no matches.
local ok, mi = pcall(dfhack.matinfo.find, q)

@@ -436,5 +327,3 @@ if ok and mi then emit(material_dossier(mi)); return end

-- ---- PLANT kind ----------------------------------------------------------
local PLANT_SEASONS = { 'SPRING', 'SUMMER', 'AUTUMN', 'WINTER' }
-- df.plant_material_def index -> yield label (0 basic_mat / 1 tree omitted).
local PLANT_YIELDS = {

@@ -453,8 +342,2 @@ { idx = 2, label = 'drink' }, { idx = 3, label = 'seed' },

-- Farm-plot eligibility, DF's own rule as a labeled fact: a plant is plantable in
-- a farm plot iff it carries the SEED flag (has a plantable seed) and is neither a
-- tree nor a grass. Verified live on 53.15: this yields exactly the vanilla crop
-- roster (110 plants) and excludes precisely the gather-only wild shrubs that have
-- no seed (kobold bulb, valley herb) and the 47 seeded trees you cannot farm. Not
-- advice — the same classification the game uses to build a plot's planting list.
local function plant_farm_plantable(p)

@@ -519,3 +402,2 @@ return (not p.flags.TREE and not p.flags.GRASS and p.flags.SEED) and true or false

surface = p.underground_depth_min == 0,
subterranean = p.underground_depth_min > 0,
depth_min = p.underground_depth_min,

@@ -547,6 +429,2 @@ depth_max = p.underground_depth_max,

-- ---- REACTION kind -------------------------------------------------------
-- A reaction can list SEVERAL buildings (parallel type/subtype/custom vectors) —
-- e.g. MAKE_PEARLASH runs at both the Kiln and the Magma Kiln, and ~35% of raws
-- reactions do. Return every aligned entry, not just index 0.
local function reaction_buildings(r)

@@ -574,3 +452,2 @@ local b = r.building

-- reaction_reagent is polymorphic too; item_token reads its fields defensively.
local function reagent_item(rg)

@@ -605,6 +482,2 @@ return item_token(rg)

-- reaction.products is polymorphic: reaction_product_itemst carries
-- item_type/count, but improvement products (glaze/encrust/stud/sew-image) do
-- NOT — reading those fields on them raises. Read everything defensively and,
-- for a non-item product, report the improvement kind as a labeled fact.
local function reaction_products(r)

@@ -666,5 +539,2 @@ local out = {}

-- ---- ITEM kind (itemdefs) ------------------------------------------------
-- Class-defining stat fields; read defensively (a field absent on a class is
-- pcall-skipped) so one reader serves every itemdef_*st.
local ITEM_STAT_FIELDS = { 'size', 'armorlevel', 'ammo_class', 'container_capacity',

@@ -674,4 +544,2 @@ 'hits', 'two_handed', 'minimum_size', 'material_size', 'ubstep', 'lbstep',

-- Not every itemdef_*st carries the same fields (foodst has no value /
-- name_plural / adjective), so read optional fields defensively via sget (above).
local function item_class(it)

@@ -758,6 +626,2 @@ local t = tostring(it._type)

-- ---- BUILDING kind (custom raws-defined workshops) -----------------------
-- Match ANY of a reaction's custom-building entries (a reaction can run at
-- several), and cap the list at MAX so a modded workshop with dozens of
-- reactions stays glanceable — reporting the full count when truncated.
local function building_reactions(bd)

@@ -764,0 +628,0 @@ local out, total = {}, 0

@@ -1,34 +0,4 @@

-- mcp_gameSave: A4 actuator — checkpoint the fort with a quicksave. Backs one tool:
-- game_save (gated actuator; subcommands "plan" preview / "apply" trigger)
--
-- EXECUTE, NEVER DECIDE: the caller asks to save; this script triggers a save and
-- reports facts — no "you should save now" logic. The §A0 dry-run/confirm loop lives
-- in TS (src/actuator.ts); this script answers plan (preview + a CONSTANT signature)
-- and apply (trigger + readback).
--
-- Departures from the other actuators, surfaced as facts (all verified live on
-- 53.15 against a Dreamfort container):
-- * The save is ASYNCHRONOUS. `quicksave` requests DF's autosave; DF writes the
-- save over the NEXT FEW FRAMES. apply() can confirm the quicksave command was
-- DISPATCHED (command_result), NOT that the file has landed — that's async.
-- * It routes through DF's AUTOSAVE: the write lands in a rotating "autosave N"
-- folder governed by the player's DF autosave settings — it does NOT overwrite
-- the loaded region save. So we report the game DATE being frozen (reliable),
-- never an authoritative destination folder (cur_savegame.save_dir lags a save
-- behind and is config-dependent — reporting it would mislead).
-- * IRREVERSIBLE: once written, a save can't be un-written from here; roll back by
-- loading the appropriate save/autosave in DF.
-- We DELEGATE to the stock, maintained `quicksave` script (via run_command_silent, so
-- its print stays out of our JSON stdout) instead of poking the version-fragile
-- save_progress.* fields ourselves — a field rename is then DFHack's problem, not ours.
-- (Stock quicksave defers the actual autosave_request set to a next-frame overlay
-- render, so reading that flag synchronously here would always see false — we don't.)
--
-- Invoked by name via DFHack RunCommand with a subcommand as arg 1; prints ONE JSON.
local json = require('json')
local function emit(t) print(json.encode(t)) end
-- quicksave itself only runs in fortress mode with a loaded map; mirror the codebase
-- no-fort guard so game_save honors the same contract as every other tool.
if df.global.gamemode ~= df.game_mode.DWARF then

@@ -42,3 +12,2 @@ emit({ error = 'no fort loaded' })

-- ---- shared facts: what a save would freeze --------------------------------
local months = {'Granite','Slate','Felsite','Hematite','Malachite','Galena',

@@ -61,7 +30,2 @@ 'Limestone','Sandstone','Timber','Moonstone','Opal','Obsidian'}

-- fort_name + game_date identify WHAT is being frozen — the reliable facts. We do
-- NOT report a destination folder: DF's autosave picks a rotating "autosave N" dir
-- per the player's settings, and cur_savegame.save_dir lags a save behind, so any
-- folder we named would mislead. fort_name is pcall'd (the JSON encoder can't emit
-- null, so an unavailable field is simply omitted).
local function save_facts()

@@ -78,6 +42,4 @@ local ok_name, fname = pcall(function()

-- ============================ plan ============================
if sub == 'plan' then
local preview = save_facts()
-- Facts the agent needs to understand what confirming does.
preview.reversible = false

@@ -89,6 +51,2 @@ preview.effect = 'triggers DFHack quicksave; DF writes a save asynchronously (over the next few frames) '

preview = preview,
-- CONSTANT signature: a save always freezes the CURRENT state, whatever it is, so
-- no sub-target exists whose drift should void the confirm token (contrast the
-- work-order / blueprint signatures, which sign their specific target). Single-use
-- is the only guard that applies here.
signature = 'game_save',

@@ -99,9 +57,4 @@ })

-- ============================ apply ============================
if sub == 'apply' then
local facts = save_facts()
-- Delegate to the stock quicksave script. run_command_silent returns (output,
-- command_result); it keeps quicksave's 'The game should autosave now.' print out
-- of our stdout. quicksave requests DF's autosave; DF performs the write on later
-- frames (verified live: the save lands in a rotating "autosave N" folder).
local out, rc = dfhack.run_command_silent('quicksave')

@@ -122,5 +75,2 @@ if rc ~= 0 then

},
-- Readback confirms the quicksave command was DISPATCHED (command_result 0 =
-- CR_OK). It does NOT and CANNOT confirm the file finished writing — DF commits
-- the save asynchronously over the next few frames.
readback = {

@@ -127,0 +77,0 @@ dispatched = rc == 0,

@@ -1,42 +0,2 @@

-- mcp_geology: a one-call geological survey of the embark. REVEALED-INFO ONLY by
-- default — the geological substrate a player has actually exposed, plus the two
-- survey facts known from embark (aquifer, surface water). The deep secrets
-- (caverns, the magma sea) are FOG-OF-WAR gated: an undiscovered cavern or an
-- unreached magma sea is ABSENT from the payload, never leaked as a z-range the
-- player has not earned. Pass reveal_hidden=true to bypass that gate (a debug /
-- spoiler switch), which surfaces every cavern + the magma sea with z-ranges
-- regardless of discovery.
--
-- FACTS ONLY: labeled layers, materials, depths, presence/absence. No "dig here",
-- no "smooth the aquifer" — the pairing (light aquifer at z125-128) is the fact;
-- the agent draws the conclusion. alerts[] only RESTATES facts that crossed a line.
--
-- Data model (verified live on DFHack 53.15, fort at 127.0.0.1:5002):
-- * Local layers: each tile's designation.geolayer_index indexes the tile's
-- geo biome's layer stack. The geo biome for a tile is
-- getRegionBiome(getTileBiomeRgn(pos)).geo_index -> world_data.geo_biomes[gi].
-- layers[geolayer_index] carries {type=geo_layer_type, mat_index=inorganic}.
-- Material name via dfhack.matinfo.decode(0, mat_index). L.top_height/
-- bottom_height are WORLD elevations (0 here), NOT local z — so bands are
-- reconstructed by grouping consecutive z-levels with the same material set.
-- * Aquifer: block.flags.has_aquifer gates a per-tile designation.water_table;
-- occupancy.heavy_aquifer marks the heavy variant. Reported from full map data
-- (a survey fact known at embark), not fog-gated, filtered by the block flag.
-- * Caverns / magma sea / underworld: world.features.map_features holds the
-- LOCAL feature layers (type 7 = subterranean cavern, 8 = magma_core,
-- 9 = underworld). flags.Discovered is the authoritative fog-of-war gate;
-- feature.min_map_z/max_map_z give the LOCAL z-range. start_depth orders the
-- caverns (0=first cavern). Caverns are GLOBAL features: block.global_feature
-- resolves (getGlobalInitFeature) to the map_features entry, and per-tile
-- designation.feature_global marks its tiles — so a cavern's water is counted
-- only on ITS OWN tiles (a cistern/aquifer seep in the same z-band is not
-- miscredited), hidden tiles skipped unless reveal_hidden. magma_reached is
-- the magma-SEA discovery flag ALONE — never a volcano / pool / hauled magma.
-- * Surface water: brook tiletype material, RIVER/RIVER_SOURCE tiletype special
-- and block has_river_* flags, murky pools as connected stagnant-water bodies
-- on revealed outside tiles. permanent_freeze is biome-base-temperature
-- derived (year-round ice; NOT a seasonal winter claim — see below).
--
-- Bounded: layer bands and cavern rows are O(depth); the per-tile scan is one pass.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
-- mcp_geology: see docs/tools/geology.md for the data model and field paths.

@@ -61,14 +21,5 @@ local json = require('json')

-- Region biome temperature is stored in Fahrenheit-scaled units; 32F is the
-- freeze point of water. A biome whose BASE temperature is at or below freezing
-- has permanently frozen surface water (glacier / tundra) — that is what
-- surface_water.permanent_freeze reports. It is deliberately NOT a seasonal
-- "freezes in winter" claim: DF 53.15 does not reliably expose a per-biome winter
-- minimum (plotinfo hi/lo temp read back as sentinels), so seasonal freezing is
-- not computed and a warm biome that freezes only in deep winter is not flagged.
-- (This fixture's biomes are 77-81F; the true/frozen path is untested here.)
-- Fahrenheit-scaled; see docs/tools/geology.md for the permanent_freeze derivation.
local FREEZE_F = 32
-- geo_layer_type enum -> readable kind. All SOIL* variants collapse to 'soil'
-- (the material name already distinguishes sand/clay/etc.).
local function kind_of(t)

@@ -81,3 +32,2 @@ local n = GLT[t]

-- cache: inorganic mat_index -> solid-state display name (what wiki/game_data resolve)
local mat_name_cache = {}

@@ -93,9 +43,2 @@ local function mat_name(mi)

-- cache: region-biome key -> geo_index. A tile's geo biome is resolved from THAT
-- TILE's own biome (getTileBiomeRgn honours the per-tile designation.biome), not
-- the block centre — a block that straddles a biome boundary has tiles indexing
-- different geo layer tables, and the block centre would mislabel the boundary
-- tiles. A per-block cache keyed by the tile's designation.biome (0-8, the 3x3
-- neighbour selector) collapses getTileBiomeRgn to ~one call per distinct biome
-- per block, so per-tile correctness costs no measurable throughput.
local geo_cache = {}

@@ -114,3 +57,2 @@ local function geo_index_at(x, y, z)

-- ---- single pass over the map -------------------------------------------------
local surface_z = -1

@@ -120,6 +62,5 @@ local revealed_zmin, revealed_zmax = math.huge, -1

local brook, river = false, false
local pool_tiles = {} -- "x,y,z" -> true (revealed stagnant surface water)
local perZ = {} -- z -> { ["kind\tmaterial"] = {kind=, material=} }
local pool_tiles = {}
local perZ = {}
-- fort biome base temperatures (for the freeze fact), keyed by region tile
local biome_temp_min = math.huge

@@ -134,4 +75,2 @@

end
-- per-block cache: this tile's designation.biome -> geo_index, so a boundary
-- block resolves each tile against its OWN biome (not the block centre).
local biome_geo = {}

@@ -153,7 +92,5 @@

end
-- surface water tiletypes
if TMAT[a.material] == 'BROOK' then brook = true end
local sp = TSPECIAL[a.special]
if sp == 'RIVER_SOURCE' then river = true end
-- murky pool: stagnant fresh water on an outside tile
if des.outside and des.flow_size > 0 and not des.liquid_type

@@ -164,3 +101,2 @@ and des.water_stagnant then

end
-- aquifer: survey fact, read from full map data (not fog-gated), block-filtered
if has_aq and des.water_table then

@@ -172,7 +108,2 @@ aq_present = true

end
-- layer sampling: the geological stack is embark-survey knowledge (the
-- layers a player reads off the embark screen), so it is sampled from FULL
-- map data — deterministically, not from whichever columns happen to be dug
-- — on a coarse stride. The DEPTH shown is fog-gated at build time (default
-- stops at the deepest revealed z; reveal_hidden shows the full column).
if lx % 4 == 0 and ly % 4 == 0 then

@@ -198,3 +129,2 @@ local b = des.biome

-- fort biome temperatures for the freeze fact (sample the surface region tiles)
if surface_z >= 0 then

@@ -214,8 +144,2 @@ for _, blk in ipairs(blocks) do

-- ---- build layer bands from perZ (group consecutive z with the same set) ------
-- Walk z from surface downward; a band runs while the (kind,material) set is
-- unchanged. Each band reports its kind (single, or 'mixed' across biomes) and
-- the sorted unique material names (in-game names wiki/game_data resolve). The
-- window is fog-gated: the top is the surface; the bottom is the deepest revealed
-- z by default (what the fort has exposed), or the map bottom with reveal_hidden.
local z_floor = reveal_hidden and 0 or ((revealed_zmin ~= math.huge) and revealed_zmin or 0)

@@ -243,3 +167,2 @@ local zs = {}

if cur then layers[#layers + 1] = cur end
-- materialize a fresh band
local kinds, mats = {}, {}

@@ -259,5 +182,4 @@ for _, e in pairs(perZ[z]) do

if cur then layers[#layers + 1] = cur end
for _, b in ipairs(layers) do b.sig = nil end -- drop the internal grouping key
for _, b in ipairs(layers) do b.sig = nil end
-- ---- aquifer block ------------------------------------------------------------
local aquifer

@@ -270,5 +192,2 @@ if aq_present then

-- ---- caverns + magma sea (fog-of-war gated) -----------------------------------
-- Read the local feature layers. A cavern/magma is DISCOVERED per its
-- flags.Discovered bit; undiscovered ones are OMITTED unless reveal_hidden.
local mf = w.features.map_features

@@ -286,10 +205,3 @@

-- Cavern water, SCOPED to each cavern's own tiles. A cavern is a GLOBAL feature:
-- block.global_feature resolves (via getGlobalInitFeature) to the map_features
-- entry, and designation.feature_global marks the tiles that belong to it. We
-- count water only on those tiles, so a cistern or an aquifer seep sharing the
-- cavern's z-band does NOT get miscredited as cavern water. Built in one pass and
-- keyed by map_features index; hidden tiles are skipped unless reveal_hidden, so
-- the water fact obeys the same fog-of-war rule as the cavern itself.
local gfeat_to_mf = {} -- block.global_feature index -> map_features index (cached)
local gfeat_to_mf = {}
local function mf_index_of_gfeat(gf)

@@ -309,3 +221,3 @@ local cached = gfeat_to_mf[gf]

local feature_water = {} -- map_features index -> true if that cavern holds water
local feature_water = {}
for _, blk in ipairs(blocks) do

@@ -331,5 +243,4 @@ local gf = blk.global_feature

local caverns_hidden = {}
local magma_reached = false -- true ONLY when the magma-SEA layer is discovered,
-- never for a volcano / magma pool / hauled magma
local magma_hidden -- z-range of the (undiscovered) magma sea, only when reveal_hidden
local magma_reached = false
local magma_hidden

@@ -342,3 +253,3 @@ for i = 0, #mf - 1 do

local row = {
layer = feat.start_depth + 1, -- 1 = first cavern
layer = feat.start_depth + 1,
z_top = zmax,

@@ -364,5 +275,2 @@ z_bottom = zmin,

-- ---- surface water ------------------------------------------------------------
-- murky pools: count connected components (4-neighbour, same z) of the collected
-- stagnant surface-water tiles, so overlapping tiles read as one pool.
local function count_pools()

@@ -374,3 +282,2 @@ local seen = {}

n = n + 1
-- flood the component
local stack = { key }

@@ -392,7 +299,2 @@ seen[key] = true

-- permanent_freeze: the biome's base temperature is at or below freezing, so its
-- surface water is ice YEAR-ROUND (glacier / tundra). This is NOT seasonal winter
-- freezing: DF 53.15 exposes only the biome base temperature (plotinfo hi/lo temp
-- read back as sentinels on this build), so a warm biome that freezes only in deep
-- winter is honestly NOT flagged — the field claims permanent freeze, not seasonal.
local permanent_freeze = (biome_temp_min ~= math.huge) and (biome_temp_min <= FREEZE_F) or false

@@ -406,12 +308,2 @@ local surface_water = {

-- ---- alerts: factual restatements only ----------------------------------------
local alerts = {}
if aq_present then
alerts[#alerts + 1] = (aq_heavy and 'heavy' or 'light') .. ' aquifer at z' .. aq_zmin .. '-' .. aq_zmax
end
if magma_reached then
alerts[#alerts + 1] = 'magma sea reached'
end
-- ---- emit ---------------------------------------------------------------------
local out = {

@@ -424,6 +316,3 @@ surface_z = surface_z,

surface_water = surface_water,
alerts = alerts,
}
-- fog-piercing extras only appear under the documented spoiler switch, so the
-- default payload never carries a tell about undiscovered depths.
if reveal_hidden then

@@ -430,0 +319,0 @@ out.reveal_hidden = true

@@ -1,14 +0,1 @@

-- mcp_injuriesAndHealth: the fort's medical picture — who needs care and what.
--
-- unit.health is always present (not nil for the healthy). The actionable
-- signals live in unit.health.flags: needs_healthcare (in the care queue),
-- should_not_move (bedridden), and the rq_* care requests that say exactly what
-- the hospital must do. Reporting the rq_ breakdown tells the player whether
-- they're missing a diagnostician, surgeon, or supplies. body.wounds counts the
-- wounded; counters.unconscious catches the knocked-out.
--
-- Verified live on 53.15-r2: the health.flags field set below is the real one
-- (rq_recover does NOT exist; don't reintroduce it).
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -22,3 +9,2 @@ local function emit(t) print(json.encode(t)) end

-- rq_* care requests, mapped to plain labels for the breakdown.
local CARE = {

@@ -38,3 +24,3 @@ rq_diagnosis = 'diagnosis',

local wounded, patients, bedridden, unconscious = 0, 0, 0, 0
local care = {} -- label -> count
local care = {}

@@ -56,3 +42,2 @@ for _, u in ipairs(citizens) do

-- Flatten care needs, most-common first.
local care_needs = {}

@@ -65,18 +50,6 @@ for label, n in pairs(care) do care_needs[#care_needs+1] = { care = label, count = n } end

-- 'patients' (needs_healthcare = in the care queue) is a discrete, doctor-
-- requiring event that does NOT scale with population: a well-run fort of any
-- size sits at 0. So >0 is a real medical fact crossing a line, not a big-fort
-- artifact — keep it firing at any count. 'unconscious' is the opposite: mostly
-- transient (sparring KOs, fainting from exhaustion, resting after a wound) and
-- one or two out cold is routine. Gate it on BOTH a share AND a minimum head
-- count so the alert means a mass event (gas, cave-in, combat rout), not a couple
-- of nappers — and so a share alone can't fire on a tiny fort (1 KO on a 7-dwarf
-- embark is 14% but not a mass event).
local UNCONSCIOUS_FRACTION_ALERT = 0.10 -- tunable: unconscious share over this ...
local UNCONSCIOUS_MIN_ALERT = 3 -- ... AND at least this many out cold -> alert
local UNCONSCIOUS_FRACTION_ALERT = 0.10
local UNCONSCIOUS_MIN_ALERT = 3
local alerts = {}
if patients > 0 then
alerts[#alerts+1] = patients .. ' dwarves need medical care'
end
if unconscious >= UNCONSCIOUS_MIN_ALERT and #citizens > 0

@@ -87,5 +60,2 @@ and (unconscious / #citizens) >= UNCONSCIOUS_FRACTION_ALERT then

end
if care_needs[1] then
alerts[#alerts+1] = 'top care need: ' .. care_needs[1].care .. ' (' .. care_needs[1].count .. ')'
end

@@ -92,0 +62,0 @@ emit({

@@ -1,13 +0,1 @@

-- mcp_jobsAndLabor: workforce utilization — who's busy, who's idle, doing what.
--
-- Derives everything from the citizens themselves (not world.jobs.list, which is
-- a linked list, not an array). Children and babies are split out of the labor
-- pool: an idle ADULT is wasted labor; an idle child is just a child. For
-- working adults we tally current_job.job_type so the player sees what the fort
-- is actually spending its hands on.
--
-- Verified live on 53.15-r2: u.job.current_job truthy for busy dwarves;
-- df.job_type[id] yields readable tokens; isChild/isBaby present.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -21,6 +9,3 @@ local function emit(t) print(json.encode(t)) end

-- A fort always runs some idle churn (dwarves between tasks, ~10-20%); a third
-- of the workforce standing around is surplus/misallocated labor worth naming.
-- Validated against the live fort (27/77 = 35% idle -> fires correctly).
local IDLE_FRACTION_ALERT = 0.30 -- tunable: idle adults over this share -> alert
local IDLE_FRACTION_ALERT = 0.30

@@ -50,3 +35,2 @@ local citizens = dfhack.units.getCitizens(true)

-- Rank active job types (desc) so the top lines are where labor is going.
local jobs = {}

@@ -53,0 +37,0 @@ for name, n in pairs(job_counts) do jobs[#jobs+1] = { job = name, count = n } end

@@ -1,31 +0,1 @@

-- mcp_mandatesAndJustice: the fort's nobility overhead — what the nobles are
-- forcing on the fort right now, and the state of its justice system.
--
-- Three things a player reads off the nobles/justice screens, as facts:
-- * MANDATES: active production quotas (make N of an item by a deadline) and
-- export bans a noble has imposed. mode is df.mandate_type — verified live on
-- 53.15-r2: {0=Export (ban), 1=Make (production quota), 2=Guild demand}.
-- * DEMANDS: appointed nobles carry room requirements (office/bedroom/dining/
-- tomb). A demand is UNMET when the noble holds no room zone of that type.
-- We test room-TYPE ownership (does the mayor own an office zone?), not the
-- room-VALUE threshold (required_office=500 is emitted as a fact, but "met"
-- means a zone of that type is assigned to them, which is what's robust to
-- read). owned_buildings are civzones; df.civzone_type gives Office/Bedroom/
-- DiningHall/Tomb.
-- * JUSTICE: open criminal cases (df.global.world.crimes.all), convictions
-- awaiting punishment (df.global.plotinfo.punishments — each carries
-- prison_counter/hammer_strikes/beating), and restraint capacity (built
-- chains + cages vs. how many are free) so the reader can see whether a
-- sentence can actually be served.
--
-- FACTS ONLY: quotas, deadlines, counts, capacity. The pairing (2 prison
-- sentences pending, 0 free restraints) is the fact; "build a jail" is the
-- agent's conclusion. Threshold restatements live in `alerts`, mirroring the
-- game's own nagging.
--
-- Bounded: mandates/export_bans/demands are capped (a fort's noble overhead is
-- small, but age can't be trusted to keep it so); justice is emitted as scalar
-- counts, never an itemized case list, so payload stays flat on an old fort.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -39,7 +9,7 @@ local function emit(t) print(json.encode(t)) end

local MANDATE_CAP = 50 -- active mandates listed individually; excess flagged
local BAN_CAP = 50 -- export bans listed individually; excess flagged
local DEMAND_CAP = 50 -- unmet room demands listed individually; excess flagged
local MANDATE_CAP = 50
local BAN_CAP = 50
local DEMAND_CAP = 50
local TICKS_PER_DAY = 1200
local DEADLINE_SOON = 7 -- alert: a make-quota this many days out, still unmet
local DEADLINE_SOON = 7

@@ -50,3 +20,2 @@ local plotinfo = df.global.plotinfo

-- ---- names --------------------------------------------------------------
local function unit_name(u)

@@ -68,7 +37,3 @@ if not u then return 'unknown' end

-- ---- appointed nobles ---------------------------------------------------
-- Map each held position to its holder unit + the expectations it carries.
-- Only positions that actually demand something of the fort (can mandate,
-- can demand, or require a room) are the "nobility overhead" this tool tracks.
local nobles = {} -- position_id -> { name, position, unit, hf, pos }
local nobles = {}
if ent then

@@ -110,3 +75,2 @@ for _, pos in ipairs(ent.positions.own) do

-- ---- mandates + export bans --------------------------------------------
local function mandate_item_name(m)

@@ -148,3 +112,3 @@ local parts = {}

guild_demands[#guild_demands + 1] = { noble = noble, item = item }
else -- Make (production quota)
else
mandates[#mandates + 1] = {

@@ -185,4 +149,2 @@ noble = noble,

-- ---- unmet noble room demands ------------------------------------------
-- A required room type is UNMET when the noble owns no civzone of that type.
local ROOM_ZONE = { office = 'Office', bedroom = 'Bedroom', dining = 'DiningHall', tomb = 'Tomb' }

@@ -192,3 +154,3 @@ local demands = {}

if n.unit then
local owned = {} -- civzone type -> true
local owned = {}
for _, b in ipairs(n.unit.owned_buildings) do

@@ -225,3 +187,2 @@ if df.building_type[b:getType()] == 'Civzone' then

-- ---- justice ------------------------------------------------------------
local ja = plotinfo.justice_active

@@ -248,4 +209,2 @@ local justice_active = (ja == true) or (ja == 1)

-- restraint capacity: built chains + cages, and how many are free (a chain is
-- in use when it has an assigned or chained unit).
local bo = df.global.world.buildings.other

@@ -277,3 +236,2 @@ local function restraint_free(r)

-- ---- alerts: facts that crossed a line (mirrors the game's own nagging) --
local alerts = {}

@@ -280,0 +238,0 @@ for _, m in ipairs(mandates) do

@@ -1,32 +0,1 @@

-- mcp_mapOverview: cheap spatial orientation before any tile_region read. Answers
-- "how big is this map, where is the fort, and which z-levels is the player
-- actually working on?" so an agent can aim its expensive per-tile terrain reads
-- instead of sweeping 147 z-levels blind.
--
-- FACTS ONLY: dimensions, one anchor coordinate, the set of z-levels with player
-- activity, and stair columns as vertical runs. No "dig here" / "wall that off"
-- advice — the agent decides where to look; this just says where the map and the
-- work are.
--
-- FIXED-SIZE PAYLOAD regardless of fort size: activity is reported as a SET OF
-- Z-LEVELS (bounded by z_count), never per-tile; stair columns are collapsed to
-- (x,y,z_top,z_bottom) vertical RUNS and capped. A mega-fort payload stays flat.
--
-- FOG OF WAR: the surface probe and the stair scan skip undiscovered
-- (designation.hidden) tiles, so nothing the player hasn't found leaks. Stair
-- tiletypes only ever exist where the player carved or built (DF has no natural
-- stairs), so every reported column is discovered space by construction. Pending
-- DIG designations are counted regardless of the hidden flag: they are the
-- player's own markers, not sensed terrain, so reporting "digging at z=111"
-- reveals nothing the player didn't place there.
--
-- Fort-core anchor: the SAME 3D citizen centroid defenses() uses (getCitizens ->
-- mean x,y,z), so map_overview().fort_core == defenses().fort_core for one fort.
--
-- Verified live on 53.15 (fort, 78 pop): world.map.x_count/y_count/z_count;
-- df.construction.get_vector() (each .pos; global list; world.constructions is
-- GONE on this build); block.flags.designated gates the dig scan (54 of 11907
-- blocks); tiletype shape STAIR_UP/DOWN/UPDOWN + material CONSTRUCTION via
-- df.tiletype.attrs. Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -40,3 +9,3 @@ local function emit(t) print(json.encode(t)) end

local COLUMNS_CAP = 40 -- stair columns listed individually; excess summarized
local COLUMNS_CAP = 40
local m = df.global.world.map

@@ -47,3 +16,2 @@ local SHAPE = df.tiletype_shape

-- ---- fort core: 3D citizen centroid (byte-for-byte the anchor defenses() uses) ----
local cx, cy, cz, n = 0, 0, 0, 0

@@ -58,5 +26,2 @@ for _, u in ipairs(dfhack.units.getCitizens(true)) do

-- ---- surface z at the fort center: highest non-hidden, open-to-sky ground tile
-- at the anchor (x,y). Skips open air above the map (EMPTY) and roofed-over tiles
-- (outside=false); null when the core column is never open to sky. ----
local function is_ground(s)

@@ -81,8 +46,3 @@ return s == 'FLOOR' or s == 'RAMP' or s == 'RAMP_TOP' or s == 'BOULDER' or s == 'PEBBLES'

-- ---- precompute per-tiletype flags ONCE (avoids 3M live attrs lookups) ----
local STAIR = {}
-- STAIR[tt] holds the tile's stair role: 'U' offers up-access, 'D' offers
-- down-access, 'X' (up/down) offers both. This is what decides whether two
-- vertically-adjacent stair tiles actually CONNECT (see the run-grouping below),
-- so we keep the role, not just a boolean.
for tt = df.tiletype._first_item, df.tiletype._last_item do

@@ -98,6 +58,4 @@ local a = attrs[tt]

-- ---- one pass over map blocks: stair tiles (everywhere) + dig z (designated
-- blocks only). Constructions come from the global vector below, not this scan. ----
local col = {} -- "x,y" -> { x=, y=, tiles = { {z=,s=}, ... } }
local dig_zset = {} -- z -> true, pending player dig designations
local col = {}
local dig_zset = {}
for _, b in ipairs(m.map_blocks) do

@@ -124,4 +82,2 @@ local z = b.map_pos.z

end
-- dig designations: only blocks flagged as carrying designations, so this stays
-- ~54 blocks not 11907. Counted regardless of hidden (player's own markers).
if b.flags.designated then

@@ -137,3 +93,2 @@ for lx = 0, 15 do

-- ---- constructions: distinct z-levels straight from the global vector ----
local con_zset = {}

@@ -144,3 +99,2 @@ for _, c in ipairs(df.construction.get_vector()) do

-- ---- activity z-levels: sorted lists + their union ----
local function sorted_keys(set)

@@ -159,11 +113,3 @@ local a = {}

-- ---- stair columns: group each (x,y)'s stair tiles into TRAVERSABLE vertical
-- runs. Two vertically-adjacent stair tiles connect only when the lower one
-- offers up-access (U or X) AND the one above offers down-access (D or X) --
-- DF's real stair rule. So a STAIR_UP under a STAIR_UP does NOT connect (nothing
-- to descend into), and this fort's helical shafts (D/U alternating per column,
-- descent hopping between adjacent columns) split into their genuinely-climbable
-- single-column segments instead of one bogus straight shaft. A z gap also closes
-- a run. z_top is the highest z of a run, z_bottom the lowest. ----
local function connects(lo, hi) -- lo, hi are {z=,s=} with hi.z == lo.z+1 expected
local function connects(lo, hi)
return hi.z == lo.z + 1

@@ -185,7 +131,2 @@ and (lo.s == 'U' or lo.s == 'X')

end
-- Rank by run HEIGHT (z_top - z_bottom + 1) DESCENDING before capping, so when a
-- fort exceeds the cap the tallest, most orientation-salient shafts (a deep main
-- stairwell, surface stairs) survive and only trivial 2-level helix fragments get
-- dropped. Tiebreak x ASC, y ASC, z_top ASC keeps it fully deterministic for the
-- golden. The emitted list stays in this height-ranked order (tallest first).
local function height(c) return c.z_top - c.z_bottom + 1 end

@@ -208,3 +149,2 @@ table.sort(columns, function(a, b)

-- ---- alerts: honest facts only ----
local alerts = {}

@@ -211,0 +151,0 @@ if core and surface_z == nil then

@@ -1,15 +0,1 @@

-- mcp_military: squads, soldier headcount, and readiness against live threats.
--
-- Two different counts on purpose, because they can disagree and the gap is the
-- point: `soldiers` is living, present citizens actually in a squad
-- (unit.military.squad_id), while `assigned_positions` is filled squad slots —
-- a slot can still hold a member who is dead, off-map, or otherwise not in the
-- citizen list. Leading with `soldiers` avoids overstating fighting strength.
-- Inlines the same hostile predicate as threats() so readiness reads against
-- what's actually on the map.
--
-- Verified live on 53.15-r2: squads.all filtered by entity_id == fortress
-- entity; translateName(sq.name); unit.military.squad_id.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -41,3 +27,2 @@ local function emit(t) print(json.encode(t)) end

-- Living, present citizens actually enlisted right now.
local citizens = dfhack.units.getCitizens(true)

@@ -54,3 +39,2 @@ local soldiers, adults = 0, 0

-- Hostiles on the map (same predicate as threats(); great-danger split out).
local hostiles, great_danger = 0, 0

@@ -70,10 +54,5 @@ for _, u in ipairs(df.global.world.units.active) do

end
if hostiles > 0 then
local msg = hostiles .. ' hostile' .. (hostiles > 1 and 's' or '') .. ' on map vs ' ..
soldiers .. ' soldier' .. (soldiers == 1 and '' or 's') ..
' in ' .. #squads .. ' squad' .. (#squads == 1 and '' or 's')
if great_danger > 0 and soldiers == 0 then
msg = msg .. ' — NO defenders against a great-danger creature'
end
alerts[#alerts+1] = msg
if hostiles > 0 and great_danger > 0 and soldiers == 0 then
alerts[#alerts+1] = 'NO defenders against a great-danger creature (' .. great_danger ..
' on map, 0 soldiers)'
end

@@ -80,0 +59,0 @@

@@ -1,32 +0,1 @@

-- mcp_moods: any active STRANGE mood and its material countdown.
--
-- A strange mood (fey/secretive/possessed/macabre/fell) seizes one dwarf, who
-- claims a workshop and demands specific materials; if they cannot gather them
-- they eventually go insane. This reports each such dwarf, the mood type and
-- driving skill, the workshop claimed (or that none is yet), and every demanded
-- material cross-referenced against fort stock. The whole early warning is
-- "demands bones / fort has zero": `have` is the stock count that reveals it.
--
-- FACTS ONLY: it reports what is demanded and what the fort holds, never "go
-- hunt for bones". The `have`=0 restatement in alerts mirrors the game's own
-- mood announcements; it is not advice.
--
-- Data model (probed live on 53.15; the POPULATED path is unverified — this
-- fixture has no active mood, so only the empty path was exercised live):
-- * A moody dwarf has u.mood in {Fey,Secretive,Possessed,Macabre,Fell}. The
-- insanity states (Melancholy/Raving/Berserk/Traumatized) are NOT strange
-- moods and are excluded.
-- * u.job.mood_skill (df.job_skill) is the artifact skill; u.job.mood_timeout
-- is the game's raw mood countdown (reported verbatim, -1 when inactive).
-- * Before a workshop is claimed u.job.current_job is nil. Once claimed it is
-- the mood job, held by a workshop building (dfhack.job.getHolder). Its
-- job_items are the demands (quantity each); job.items are what's gathered so
-- far (each element's job_item_idx says which demand it fills).
-- * A demand is matched against stock with DFHack's own suitability predicates
-- (dfhack.job.isSuitableItem / isSuitableMaterial), so material-category
-- demands (bones/cloth/shell/...) resolve the same way DF itself resolves them.
--
-- Bounded: moods are rare (usually one), but active and demands are both capped.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -40,4 +9,4 @@ local function emit(t) print(json.encode(t)) end

local ACTIVE_CAP = 16 -- moods are near-unique; cap defends a pathological save
local DEMANDS_CAP = 20 -- a single mood demands a handful of materials
local ACTIVE_CAP = 16
local DEMANDS_CAP = 20

@@ -52,4 +21,2 @@ local STRANGE = {

-- Generic material-category demands live in the job_item flag bitfields (there is
-- no material_category field on job_item). {bitfield, flag, label}, probed live.
local MAT_FLAGS = {

@@ -67,3 +34,2 @@ { 'flags2', 'bone', 'bone' }, { 'flags2', 'shell', 'shell' },

-- Category labels an item_type already implies, so we don't say "rough gems gem".
local REDUNDANT = {

@@ -74,3 +40,2 @@ [df.item_type.ROUGH] = { gem = true }, [df.item_type.SMALLGEM] = { gem = true },

-- A few item-type tokens read better spelled out; otherwise lower-case the token.
local ITEM_LABEL = {

@@ -88,5 +53,2 @@ ROUGH = 'rough gems', SMALLGEM = 'cut gems', BOULDER = 'stone', BAR = 'bars',

-- Human description of one demand (job_item): specific material + generic
-- category flags + item type, with word-level (singularized) de-duplication so
-- redundant pairs collapse. Facts only.
local function describe(ji)

@@ -117,7 +79,2 @@ local parts = {}

-- Fort stock matching a demand, mirroring mcp_stocks' skip set so `have` is
-- comparable to stocks() counts. The item type/subtype are matched explicitly
-- (reliable), and the MATERIAL — including generic category demands like bone or
-- silk — is tested with DFHack's own isSuitableMaterial, so a category demand
-- counts exactly the materials DF would accept.
local function stock_have(ji)

@@ -140,6 +97,5 @@ local ok, total = pcall(function()

end)
return ok and total or -1 -- -1: could not evaluate suitability for this demand
return ok and total or -1
end
-- Count items already gathered into the mood job for each demand index.
local function gathered_by_index(job)

@@ -209,3 +165,2 @@ local g = {}

row.demands_truncated = total_demands > #row.demands
-- claimed-and-gathering vs. construction-begun: all materials in hand.
row.workshop_status = (total_demands > 0 and all_filled) and 'working' or 'gathering'

@@ -227,9 +182,4 @@ end

-- Alerts: restate the facts the game itself would nag about. No advice.
local alerts = {}
for _, row in ipairs(active) do
if row.workshop_status == 'unclaimed' then
alerts[#alerts + 1] = row.name .. ' has taken a ' .. row.mood ..
' mood and has not yet claimed a workshop'
end
for _, d in ipairs(row.demands) do

@@ -236,0 +186,0 @@ if d.have == 0 and d.gathered < d.needed then

--@ module = true
-- mcp_readTerrain: the fog-of-war-safe terrain substrate for spatial tools.
--
-- SPIKE #10 deliverable. Reads tile shape for a single z-level window and emits a
-- compact per-row symbol grid. UNDISCOVERED tiles (designation.hidden) are ALWAYS
-- rendered as '?' and their real tiletype is NEVER serialized — the fog-of-war
-- invariant is enforced at the source, in Lua, so no caller can leak the map the
-- player hasn't found. (RFR's GetBlockList, by contrast, ships real tiletypes for
-- hidden tiles and a ~50x larger raw payload; see the spike report.)
--
-- FACTS ONLY: tile shapes + discovery state. No pathing advice, no "safe route"
-- interpretation — the agent reasons over the grid.
--
-- Two ways in:
-- * Directly (this file): `mcp_readTerrain X0 Y0 Z [W] [H]` -> prints ONE JSON
-- object {origin,w,h,visible_tiles,hidden_tiles,exposure,fortifications,
-- legend,distinct,grid}. exposure = {open_to_sky,covered,undiscovered} from the
-- designation.outside/hidden flags; fortifications = [{x,y}] firing tiles.
-- * As a module for the five dependent spatial tools:
-- local rt = reqscript('mcp_readTerrain')
-- local win = rt.read_window(x0, y0, z, w, h) -- returns the same table
-- local ch = rt.sym(tiletype_id, hidden)
-- so the symbol table, the '?' convention, and the block-cached read live in
-- ONE place. Version-fragile field access (designation.hidden, tiletype attrs)
-- stays here, out of the individual tools.
--
-- Verified live on 53.15-r2 vs fort Bustlanterns: coords match defenses()/df tile
-- space (+x east, +y south); a 100x100 window is ~10 KB (~2.6k tokens); a
-- block-cached read of 10k tiles is ~65 ms (vs ~1.7 s reading per tile).
-- mcp_readTerrain: see CONTRIBUTING.md "Shared internals: fog-of-war safety".
local json = require('json')
-- Symbol convention. '?' is reserved for undiscovered tiles and MUST NOT be
-- reused for any real terrain. Shapes collapse to one glyph each; the goal is a
-- legible ASCII map an agent can reason over, not a lossless dump.
TERRAIN_LEGEND = {

@@ -53,3 +23,2 @@ ['?'] = 'undiscovered (fog of war)',

-- tiletype id (+ hidden flag) -> one grid glyph. hidden always wins.
function sym(tt, hidden)

@@ -73,5 +42,2 @@ if hidden then return '?' end

-- Read a w*h window at (x0,y0) on z-level z. Fetches each 16x16 map block ONCE
-- and indexes into it (26x faster than dfhack.maps.getTileType per tile). Returns
-- the emit-ready table. Out-of-map tiles read as open space.
function read_window(x0, y0, z, w, h)

@@ -89,6 +55,2 @@ local m = df.global.world.map

-- exposure = the designation.outside flag (open to sky vs under a roof), the
-- tile-level "inside/outside" DF itself tracks; fortifications = firing tiles,
-- collected as positions because they are sparse and defensively salient. All
-- computed in the SAME block-cached pass — no extra reads for consumers.
local rows, hidden_n, visible_n, distinct = {}, 0, 0, {}

@@ -132,3 +94,2 @@ local open_to_sky, covered = 0, 0

-- When loaded via reqscript, stop here: the caller just wanted the functions.
if dfhack_flags and dfhack_flags.module then

@@ -138,3 +99,2 @@ return

-- Direct invocation: `mcp_readTerrain X0 Y0 Z [W] [H]`.
if df.global.gamemode ~= df.game_mode.DWARF then

@@ -141,0 +101,0 @@ print(json.encode({ error = 'no fort loaded' }))

@@ -1,28 +0,1 @@

-- mcp_roomsAndZones: the fort's facility inventory, each count paired with its
-- demand-side number where one exists (bedrooms<->adults, coffins<->unburied,
-- temples<->deities worshipped). Supply-side companion to unmet_needs(): that
-- says WHO is unfulfilled, this says WHAT the fort has built for them.
--
-- FACTS ONLY: counts and pairings. No "build more bedrooms" advice — the pairing
-- (12 adults, 0 free rooms) is the fact; the agent draws the conclusion.
--
-- Data model (verified live on 53.15-r2, fort Bustlanterns):
-- * Civzones: world.buildings.other.ACTIVITY_ZONE; df.civzone_type[z.type] gives
-- readable kinds (Bedroom, Dormitory, DiningHall, Tomb, ...). z.assigned_unit_id
-- ~= -1 means the room is owned. z.location_id links a zone to a location.
-- * Locations (temples/taverns/libraries/hospitals/guildhalls): the abstract
-- buildings on world_data.active_site[0].buildings, keyed by
-- df.abstract_building_type. TEMPLE carries deity_data.Deity (a deity histfig
-- id) or deity_type == -1 for an all-inclusive temple.
-- * Deity worship: each citizen's historical_figure carries DEITY histfig_links
-- (target_hf = the deity). An all-inclusive temple satisfies every worshipper.
-- * Wells / coffins are plain buildings; a well is complete when
-- getBuildStage()==getMaxBuildStage(); a coffin is occupied when it contains a
-- corpse/body item. Water source is read by scanning downward from the well,
-- stopping at undiscovered tiles (fog of war stays honest).
--
-- Bounded: wells capped; bedrooms/coffins aggregated to counts, never itemized, so
-- a mega-fort payload stays flat.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -36,4 +9,4 @@ local function emit(t) print(json.encode(t)) end

local WELLS_CAP = 20 -- wells listed individually; excess summarized
local DEITY_WORSHIP_MIN = 1 -- a deity "needs" a temple if >= this many citizens worship it
local WELLS_CAP = 20
local DEITY_WORSHIP_MIN = 1
local CIVZONE = df.global.world.buildings.other.ACTIVITY_ZONE

@@ -43,6 +16,5 @@ local SITE = df.global.world.world_data.active_site[0]

-- ---- citizens: adult tally + deity worship demand ----
local citizens = dfhack.units.getCitizens(true)
local adults = 0
local worship = {} -- deity_hf -> #worshippers
local worship = {}
for _, u in ipairs(citizens) do

@@ -60,7 +32,2 @@ if dfhack.units.isAdult(u) then adults = adults + 1 end

-- ---- bedrooms + dining from civzones ----
-- Bedrooms are PRIVATE assignable rooms; dormitories are communal (shared, and
-- usually unassigned), so they are counted separately — folding them in would
-- inflate "unassigned private rooms" and leave adults_without unreduced by the
-- communal sleeping a dormitory actually provides. Kept as distinct facts.
local bed_assigned, bed_unassigned, dormitories = 0, 0, 0

@@ -86,6 +53,5 @@ local dining_halls, dining_seats = 0, 0

-- ---- locations: temples / taverns / libraries / guildhalls / hospital ----
local taverns, libraries, guildhalls = 0, 0, 0
local dedicated = {} -- deity names with a dedicated temple
local dedicated_hf = {} -- set of deity hf ids with a dedicated temple
local dedicated = {}
local dedicated_hf = {}
local has_all_inclusive = false

@@ -117,5 +83,2 @@ local hospital_ab

-- temples needed by worshippers: an all-inclusive temple satisfies everyone;
-- otherwise, deities worshipped by >= DEITY_WORSHIP_MIN citizens with no
-- dedicated temple of their own.
local needed = {}

@@ -132,3 +95,2 @@ if not has_all_inclusive then

-- ---- hospital: beds, traction, well-in-zone, supplies physically present ----
local hospital = { zoned = false }

@@ -149,10 +111,6 @@ if hospital_ab then

end
-- medical supplies actually in the hospital footprint (a fact, not a target)
local function level(n) if n == 0 then return 'none' elseif n < 5 then return 'low' else return 'ok' end end
local x1, x2, y1, y2, hzz = hz.x1, hz.x2, hz.y1, hz.y2, hz.z
-- getPosition resolves an item's true location THROUGH its container (thread
-- and cloth normally live in a coffer/bag on a hospital tile); it.pos alone is
-- stale for contained items and would under-count a stocked hospital.
local function in_zone(it)
local x, y, z = dfhack.items.getPosition(it) -- returns x,y,z; nil if nowhere
local x, y, z = dfhack.items.getPosition(it)
return x ~= nil and z == hzz and x >= x1 and x <= x2 and y >= y1 and y <= y2

@@ -186,3 +144,2 @@ end

-- ---- wells: working + water source (fog-of-war-safe downward scan) ----
local function well_source(w)

@@ -195,3 +152,3 @@ local x, y = w.centerx, w.centery

local des = blk.designation[lx][ly]
if des.hidden then return 'unknown' end -- fog of war: don't peer below
if des.hidden then return 'unknown' end
if des.flow_size > 0 then

@@ -226,3 +183,2 @@ return des.liquid_type and 'magma' or 'water'

-- ---- coffins + unburied dead ----
local coffins = df.global.world.buildings.other.COFFIN or {}

@@ -240,4 +196,2 @@ local coffins_free, coffins_used = 0, 0

-- dead awaiting burial: loose dwarf corpses on the map (not interred in a coffin,
-- not marked for dumping) of the fort's own race.
local fort_race = df.global.plotinfo.race_id

@@ -252,3 +206,2 @@ local dead_unburied = 0

-- ---- alerts: facts that crossed a line (mirrors the game's own nagging) ----
local adults_without = math.max(0, adults - bed_assigned)

@@ -255,0 +208,0 @@ local alerts = {}

@@ -1,38 +0,1 @@

-- mcp_siteHistory: this fort's entry in the PERMANENT world saga — founding
-- (year + date + owning civ), the fort name in Dwarven and English with a word
-- etymology, prior sieges/battles fought AT this site (with outcomes/generals),
-- and the notable historical figures who died here. Reads the durable event log
-- (df.global.world.history.events), NOT the pruned live report stream, so it
-- survives across seasons. Scoped STRICTLY to the loaded site_id — never a
-- world-gen data dump. Invoked by name via DFHack RunCommand; prints ONE JSON object.
--
-- Verified live on DFHack 53.15 against "Fortress of Dreams" (site_id 25, civ 10,
-- year 7). Confirmed, version-fragile field paths (all read through pcall):
-- * CURRENT SITE: df.global.plotinfo.site_id; the record is the entry in
-- df.global.world.world_data.sites with .id == site_id. Per-site: .name
-- (language_name), .type (df.world_site_type; fort = PlayerFortress),
-- .created_year, .created_tick, .pos.{x,y}. A player fort's own .civ_id is
-- -1, so the OWNING civ comes from df.global.plotinfo.civ_id, matched in
-- df.global.world.entities.all by .id.
-- * NAMES: dfhack.translation.translateName(name) = Dwarven ("Geshud Nåzom");
-- (name, true) = English ("Fortress of Dreams"). Wrapped in dfhack.df2utf so
-- CP437 accents (ö, ä in proper nouns) become valid UTF-8 in the JSON.
-- * ETYMOLOGY: name.words[0..6] index df.global.world.raws.language.words
-- (.word = the English root, e.g. FORTRESS/DREAM); name.parts_of_speech[i]
-- indexes df.part_of_speech.
-- * BATTLES: history events carrying a .site field equal to this site_id and of
-- a war type (WAR_ATTACKED_SITE / WAR_DESTROYED_SITE / WAR_SITE_NEW_LEADER).
-- Fields .year, .attacker_civ, .defender_civ, .attacker_general_hf,
-- .defender_general_hf resolve to civ/figure names. WAR_FIELD_BATTLE is
-- region-scoped (no .site) so it is intentionally NOT included — battles here
-- means sieges fought AT this site. A young player fort typically has NONE,
-- so this degrades to an empty list (verified: site 25 has zero), while the
-- formatting path was verified against a besieged site (WAR_ATTACKED_SITE).
-- * NOTABLE DEATHS: HIST_FIGURE_DIED events with .site == site_id and a NAMED
-- victim (unnamed butchered livestock is excluded — a "figure" has a name).
-- .victim_hf -> df.historical_figure.find; .death_cause -> df.death_type;
-- .slayer_hf -> the killer's name when >= 0.
-- Battles and deaths are each sorted most-recent-first and capped (see caps
-- below); a truncated list reports its full total.
local json = require('json')

@@ -49,3 +12,2 @@ local function emit(t) print(json.encode(t)) end

-- CP437 -> UTF-8 so accented proper nouns are valid JSON; ASCII passes through.
local function u(s)

@@ -61,3 +23,2 @@ if s == nil then return nil end

-- Translate a language_name to its display string (Dwarven or English), UTF-8 safe.
local function name_str(name, english)

@@ -69,3 +30,2 @@ local ok, s = pcall(dfhack.translation.translateName, name, english)

-- Resolve a civ/entity id to its English display name (nil if not found).
local function civ_name(id)

@@ -79,3 +39,2 @@ if not id or id < 0 then return nil end

-- Resolve a civ/entity id to BOTH name forms (story-writers want each).
local function civ_names(id)

@@ -89,3 +48,2 @@ if not id or id < 0 then return nil, nil end

-- Resolve a historical-figure id to its English display name.
local function hf_name(id)

@@ -98,3 +56,2 @@ if not id or id < 0 then return nil end

-- A historical figure's creature token (e.g. "DWARF"), read defensively.
local function hf_race(id)

@@ -109,3 +66,2 @@ if not id or id < 0 then return nil end

-- DF calendar: 33600 ticks/month, 1200 ticks/day. A within-year tick -> "Nth Month".
local MONTHS = { 'Granite', 'Slate', 'Felsite', 'Hematite', 'Malachite', 'Galena',

@@ -130,3 +86,2 @@ 'Limestone', 'Sandstone', 'Timber', 'Moonstone', 'Opal', 'Obsidian' }

-- ---- locate the loaded site -------------------------------------------------
local SITE = df.global.plotinfo.site_id

@@ -145,3 +100,2 @@ local site

-- ---- name + etymology -------------------------------------------------------
local function name_etymology(name)

@@ -167,7 +121,5 @@ local out = {}

-- ---- founding ---------------------------------------------------------------
local created_year, created_tick, builder_hf
pcall(function() created_year = site.created_year end)
pcall(function() created_tick = site.created_tick end)
-- Corroborate founding + capture the builder from the CREATED_SITE saga event.
do

@@ -192,3 +144,2 @@ local events = df.global.world.history.events

-- ---- battles + notable deaths (single pass over the saga) -------------------
local WAR_TYPES = { WAR_ATTACKED_SITE = true, WAR_DESTROYED_SITE = true,

@@ -216,3 +167,3 @@ WAR_SITE_NEW_LEADER = true }

if tname == 'WAR_DESTROYED_SITE' then b.outcome = 'site destroyed' end
b._ord = #battles + 1 -- saga (ascending) insertion order, for a stable tie-break
b._ord = #battles + 1
battles[#battles + 1] = b

@@ -222,3 +173,2 @@ elseif tname == 'HIST_FIGURE_DIED' then

local nm = hf_name(vic)
-- A "notable figure" has a name; unnamed butchered livestock is skipped.
if nm then

@@ -233,3 +183,3 @@ deaths_total = deaths_total + 1

if sn then d.slain_by = sn end
d._ord = #deaths + 1 -- saga (ascending) insertion order, for a stable tie-break
d._ord = #deaths + 1
deaths[#deaths + 1] = d

@@ -243,5 +193,2 @@ end

-- Most-recent-first; ties broken by saga insertion order (_ord) so the result is
-- deterministic (Lua's table.sort is NOT stable, so an explicit tie-break is
-- required to actually preserve saga order for same-year events).
local function by_year_desc(a, b)

@@ -262,3 +209,2 @@ local ay, by = a.year or 0, b.year or 0

deaths = cap(deaths, DEATH_CAP)
-- Drop the internal saga-order bookkeeping field before emitting.
for _, b in ipairs(battles) do b._ord = nil end

@@ -279,5 +225,5 @@ for _, d in ipairs(deaths) do d._ord = nil end

civ_id = civ_id,
civ = civ_dwarven, -- Dwarven form, e.g. "Uzoledzul"
civ_english = civ_english, -- English form, e.g. "The Oily Vestibule"
builder = hf_name(builder_hf), -- nil when no founder is recorded (builder_hf == -1)
civ = civ_dwarven,
civ_english = civ_english,
builder = hf_name(builder_hf),
},

@@ -284,0 +230,0 @@ name_etymology = name_etymology(site.name),

@@ -1,14 +0,1 @@

-- mcp_stocks: food/drink as days-of-supply plus a few critical materials.
--
-- Item counting follows DFHack's own dfstatus (iterate world.items.other.IN_PLAY,
-- skip rotten/dump/forbid/construction/trader, sum stack sizes by type) but
-- counts ALL edible food, not just prepared meals, and derives days-of-supply.
--
-- Consumption rate (DF wiki, DF2014 Food): a dwarf eats ~2 food and drinks ~5
-- units per season; a season is 3 months x 28 days = 84 ticks-days. So
-- food_days = food_total * 84 / (pop * 2)
-- drink_days = drink_total * 84 / (pop * 5)
-- These are documented estimates; the raw counts in `counts` are exact.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -22,11 +9,2 @@ local function emit(t) print(json.encode(t)) end

-- Tunables (days-of-supply and material floors below which we flag "low").
-- Reviewed under #5 and KEPT deliberately: unlike the raw-count happiness alerts,
-- food/drink here are already POPULATION-NORMALIZED — days-of-supply divides the
-- stock by pop*per-capita-rate, so LOW_DAYS=14 (under ~2 weeks of buffer) is a
-- proportional line that means the same on a 7-dwarf and a 200-dwarf fort. The
-- material figures are intentional ABSOLUTE working-buffers, not pop-shares: a
-- fort needs a baseline reserve to keep its forges/looms fed regardless of size,
-- and these are reported as a factual notable_low/high classification (not an
-- alert), so a large fort seeing them is a true low reserve, not statistical noise.
local SEASON_DAYS = 84

@@ -33,0 +11,0 @@ local FOOD_PER_SEASON, DRINK_PER_SEASON = 2, 5

@@ -1,12 +0,1 @@

-- mcp_threats: enumerate dangerous units on the map, grouped by kind.
--
-- Builds on fort_status's hostile predicate (active && !dead && isDanger &&
-- !citizen) but classifies each threat and separates ACTIVE hostiles from
-- CONTAINED ones (caged/chained — a captured beast is a hazard-in-waiting, not
-- a live attack). Groups identical creatures so "12 goblins" reads as one line.
-- FOG OF WAR: a unit on an undiscovered tile is filtered via mcp_unitVisibility
-- before it ever reaches a group/count/alert — the player must have actually
-- found it (see issue: "threats & fort_status expose undiscovered hostiles").
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -20,10 +9,6 @@ local function emit(t) print(json.encode(t)) end

-- Fog-of-war gate: a unit standing on an undiscovered tile must never surface
-- here, loose OR caged/chained -- see mcp_unitVisibility for the rationale.
local visibility = reqscript('mcp_unitVisibility')
-- Group dangerous units by a stable key so identical creatures collapse to one
-- line. Contained (caged/chained) threats are counted apart from active ones.
local groups = {} -- key -> aggregate
local order = {} -- preserve first-seen order for stable output
local groups = {}
local order = {}
local active_total, contained_total = 0, 0

@@ -40,18 +25,2 @@

-- Tactical intel for a group's representative unit: the creature token, a small
-- CURATED set of decisive traits, and the ranged/breath attack labels. This is
-- the hook an advisor needs BEFORE recommending a counter (e.g. cage traps are
-- useless vs. TRAPAVOID). Confirmed field paths on DFHack 53.15-r2:
-- * unit's creature: df.global.world.raws.creatures.all[u.race] (creature_raw);
-- .creature_id is the token (e.g. "DEMON_4").
-- * caste vector is the 'caste' field (NOT 'castes'); representative is caste[0].
-- * caste.flags is a bitfield whose TRUE keys are stable token names — read via
-- pairs(); never index by a token (an undefined bit throws "not found").
-- * ranged/breath: caste.body_info.interactions[].interaction.adv_name.
-- * building destroyer: caste.misc.buildingdestroyer (numeric; >0 means it can
-- smash buildings). There is NO BUILDINGDESTROYER flag bit in this build and
-- no caste.building_destroyer field — verified live against TROLL (=2) and the
-- Flame Phantom demons (=0).
-- Degrades gracefully: a unit whose race/caste can't resolve yields empty intel
-- (nil token, empty traits/ranged_attacks) rather than crashing.
local function unit_intel(u)

@@ -65,3 +34,2 @@ local out = { token = nil, traits = {}, ranged_attacks = {} }

-- TRUE flag tokens as a lookup set (iterate the bitfield; never index by token).
local flag = {}

@@ -72,3 +40,2 @@ for k, v in pairs(caste.flags) do

-- Ranged/breath interactions -> adv_name labels; note fire/web attacks.
local ranged = {}

@@ -92,7 +59,5 @@ local fire_attack, web_attack = false, false

-- Building destroyer level (0 = none) at the confirmed numeric path.
local bd = 0
pcall(function() bd = caste.misc.buildingdestroyer or 0 end)
-- Only the tactically-DECISIVE traits, in a stable, advice-first order.
local traits = {}

@@ -116,9 +81,5 @@ if flag.TRAPAVOID then traits[#traits+1] = 'trapavoid' end

local flags = classify(u)
-- Distinct groups per (name, containment) so a caged beast never masks a
-- loose one of the same kind.
local key = name .. (contained and ' [contained]' or '')
local g = groups[key]
if not g then
-- All units in a group share a creature, so pull intel once from the
-- first-seen (representative) unit.
local intel = unit_intel(u)

@@ -142,7 +103,5 @@ g = { name = name, count = 0, contained = contained,

-- Alerts: lead with great-danger creatures, then invaders, then a catch-all for
-- any remaining active hostiles. Contained threats get a quieter mention.
local alerts = {}
local great, invaders, other = 0, 0, 0
local great_traits, seen_trait = {}, {} -- unioned traits across active great-danger groups
local great_traits, seen_trait = {}, {}
for _, g in ipairs(group_list) do

@@ -161,3 +120,2 @@ if not g.contained then

local line = great .. ' great-danger creature' .. (great > 1 and 's' or '') .. ' loose (megabeast/titan/demon/FB)'
-- Traits are what the advisor reads first — surface them on the lead alert.
if #great_traits > 0 then

@@ -174,5 +132,2 @@ line = line .. '; traits: ' .. table.concat(great_traits, ', ')

end
if contained_total > 0 then
alerts[#alerts+1] = contained_total .. ' dangerous creature' .. (contained_total > 1 and 's' or '') .. ' caged/chained'
end

@@ -179,0 +134,0 @@ emit({

@@ -1,36 +0,1 @@

-- mcp_tileRegion: a bounded window of ONE z-level rendered as a character grid +
-- self-describing legend. The "earthworks" map (issue #23): dug/undug, soil vs
-- stone, ramps and stairs, constructions, liquids, trees, and building footprints
-- collapsed to FOUR CLASSES (workshop / stockpile / machine / furniture). The
-- agent drafts layouts as annotations over this grid; the tool NEVER designs
-- anything and NEVER writes game state.
--
-- Composes on the shared fog-of-war substrate (spike #10): the base terrain grid
-- comes from mcp_readTerrain.read_window (walls '#', floor '.', ramps 'r'/'v',
-- stairs '<'/'>'/'x', trees 'T', fortifications 'F', brook '~', and — crucially —
-- undiscovered tiles as '?', with their real tiletype NEVER serialized). This
-- script then COMPOSES overlays ON TOP, and NEVER paints over a '?' tile: fog of
-- war stays honest, so the '?' count in the grid always equals hidden_tiles.
--
-- FACTS ONLY: it renders what is there. Building detail is collapsed to a class
-- glyph (a workshop is 'W', not "Craftsdwarf's Workshop") — the map is coarse on
-- purpose. Per-hostile / per-structure detail lives in defenses(); the fort's
-- facility inventory lives in rooms_and_zones().
--
-- ARGS (all optional; this is the first parameterized MCP tool): Z X0 Y0 X1 Y1.
-- * No args -> the DEFAULT window: a 60x40 rectangle centered on the fort core
-- (busiest citizen z-level + that level's citizen centroid, the same anchor
-- mcp_defenses uses), so the no-arg golden is reproducible.
-- * Z alone (or a partial rectangle) -> the default-centered window at that z.
-- * Z X0 Y0 X1 Y1 -> that explicit rectangle. Window is hard-capped at 100x100;
-- an oversized request is CLAMPED (never errored) with truncated=true and the
-- original requested size echoed back.
-- Emits ONE json.encode(obj): { z, origin:[x,y], size:[w,h], legend, grid,
-- hidden_tiles, truncated, requested? }.
--
-- Verified live on 53.15-r2, fort on :5005: buildings.all + b:getType()
-- (df.building_type); dfhack.buildings.containsTile(b,x,y) honors irregular
-- stockpile footprints; construction via tiletype material == CONSTRUCTION;
-- liquids via designation.flow_size + liquid_type.
local json = require('json')

@@ -44,9 +9,5 @@ local function emit(t) print(json.encode(t)) end

local CAP = 100 -- hard window cap per side (documented)
local CAP = 100
local DEFAULT_W, DEFAULT_H = 60, 40
-- The fixed master legend. Terrain glyphs mirror mcp_readTerrain; the overlay
-- glyphs below are chosen to NOT collide with it (readTerrain already owns
-- F/T/~/</>/x). Water reuses '~' (brook) — same "watery" meaning. The response
-- ships only the glyphs actually present, but this is the full documented set.
local GLYPHS = {

@@ -74,5 +35,2 @@ ['?'] = 'undiscovered (fog of war)',

-- df.building_type name -> class glyph. Anything not listed (bridges, floodgates,
-- traps, farm plots, wells, trade depot) renders as its underlying terrain, not a
-- building glyph: the tool commits to exactly the four documented classes.
local CLASS = {}

@@ -90,3 +48,2 @@ CLASS['Workshop'] = 'W'; CLASS['Furnace'] = 'W'

-- ---- fort-core anchor: busiest citizen z + that level's xy centroid ----------
local z_count, z_sx, z_sy = {}, {}, {}

@@ -104,3 +61,2 @@ for _, u in ipairs(dfhack.units.getCitizens(true)) do

-- ---- args -> window (clamped, never errored) --------------------------------
local a = { ... }

@@ -115,3 +71,2 @@ local az, ax0, ay0, ax1, ay1 =

if ax0 and ay0 and ax1 and ay1 then
-- explicit rectangle; normalize corner order
local lox, hix = math.min(ax0, ax1), math.max(ax0, ax1)

@@ -125,7 +80,2 @@ local loy, hiy = math.min(ay0, ay1), math.max(ay0, ay1)

else
-- default (or partial-arg) window: centered on the citizen centroid of the
-- LEVEL BEING RENDERED. If a z was requested and that level itself has
-- citizens, anchor on that level's own centroid (so `tile_region({z: L})`
-- recenters at L, not at the busiest level); otherwise fall back to the
-- busiest level's centroid, then the map center.
w, h = DEFAULT_W, DEFAULT_H

@@ -144,3 +94,2 @@ local anchor = (az and z_count[az] and z_count[az] > 0) and az or pz

-- a window can never be larger than the map, then clamp the origin so it fits
if w > m.x_count then w = m.x_count end

@@ -153,7 +102,5 @@ if h > m.y_count then h = m.y_count end

-- ---- base terrain grid (fog of war already enforced) ------------------------
local rt = reqscript('mcp_readTerrain')
local win = rt.read_window(x0, y0, z, w, h)
-- split each row string into a mutable char array for overlay stamping
local rows = {}

@@ -166,3 +113,2 @@ for i, s in ipairs(win.grid) do

-- one block-cached read plane at this z (26x faster than per-tile getTileType)
local cache = {}

@@ -178,7 +124,3 @@ local function block(x, y)

-- ---- overlay 1: liquids, soil walls, constructed floor (NEVER over '?') ------
-- Also collects a SPARSE liquid-depth list: the grid glyph collapses flow_size
-- 1..7 to one '~'/'%' for legibility, so per-tile depth is exposed separately.
-- Fog-honest: hidden tiles are skipped, never read for depth.
local LIQUIDS_CAP = 400 -- sparse depth list bound (window is <= 100x100)
local LIQUIDS_CAP = 400
local liquids, liquids_truncated = {}, false

@@ -197,3 +139,3 @@ for yy = 0, h - 1 do

local is_magma = des.liquid_type
row[xx + 1] = is_magma and '%' or '~' -- magma vs water
row[xx + 1] = is_magma and '%' or '~'
if #liquids < LIQUIDS_CAP then

@@ -206,4 +148,2 @@ liquids[#liquids + 1] =

elseif base == '#' then
-- soil vs stone: readTerrain collapses every wall to '#'; distinguish
-- an undug SOIL wall (diggable-by-hand, sand/clay/loam) as ','.
local tt = blk.tiletype[gx % 16][gy % 16]

@@ -224,3 +164,2 @@ if df.tiletype_material[df.tiletype.attrs[tt].material] == 'SOIL' then

-- ---- overlay 2: building footprints by class (stamped last, wins) -----------
local BT = df.building_type

@@ -250,3 +189,2 @@ local function contains(b, x, y)

-- ---- flatten + build the present-glyph legend -------------------------------
local grid, seen = {}, {}

@@ -253,0 +191,0 @@ for i, r in ipairs(rows) do

@@ -1,32 +0,1 @@

-- mcp_trade: the caravan lifecycle and the trade depot, as facts.
--
-- Answers "can I trade right now, and with whom?" the way a player reads the
-- depot screen: does a depot exist and can a wagon actually reach it, is a
-- caravan none/incoming/at-depot/leaving, is a broker assigned and is he at the
-- depot, and what is staged in the depot with a rough value. FACTS ONLY — it
-- reports the state, never "go trade" or "assign a broker".
--
-- Data model (verified live on 53.15, fort with a depot, NO caravan present):
-- * Depot: world.buildings.other.TRADE_DEPOT (building_tradedepotst). It carries
-- `accessible` — DF's OWN wagon-pathable flag, the exact thing the game checks
-- before routing a wagon, not a mere "is it built" test. construction_stage vs
-- getMaxBuildStage() gives completeness; trade_flags.trader_requested is the
-- "bring goods to depot" request. contained_items are the items physically
-- staged in the depot footprint (fort goods brought to trade AND, during a
-- visit, merchant goods unloaded) — counted with an approximate value.
-- * Caravans: df.global.plotinfo.caravans is a vector of caravan_state. Empty =>
-- no caravan (state "none"). Each has trade_state (None/Approaching/AtDepot/
-- Leaving/Stuck), time_remaining (ticks; /1200 = days), and entity (the civ).
-- * Broker: the fort entity's BROKER position (responsibility TRADE). Its
-- assignment.histfig resolves to a live unit -> readable name + current_job;
-- "at depot" = the unit standing within the depot footprint.
--
-- CAVEAT: the fixture used to author this had NO caravan visiting, so the active-
-- caravan fields (per-caravan state Approaching/AtDepot/Leaving, leaving_in_days,
-- merchant goods) are coded from the caravan_state struct but were not observed
-- live. The quiet path (state "none", depot + broker) is fully verified.
--
-- Bounded: caravans list capped; depot goods aggregated to a count + value, never
-- itemized. Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -41,5 +10,4 @@ local function emit(t) print(json.encode(t)) end

local TICKS_PER_DAY = 1200
local CARAVANS_CAP = 8 -- multiple civs can visit at once; cap the emitted list
local CARAVANS_CAP = 8
-- ---- depot: existence, DF's own accessibility, completeness, staged goods ----
local depots = df.global.world.buildings.other.TRADE_DEPOT or {}

@@ -50,4 +18,2 @@ local depot = { exists = false, accessible = false, complete = false, trader_requested = false }

if #depots > 0 then
-- If more than one depot exists, prefer a complete + accessible one so the
-- summary reflects the depot actually usable for trade.
for _, d in ipairs(depots) do

@@ -62,3 +28,2 @@ depot_bld = depot_bld or d

depot.trader_requested = depot_bld.trade_flags.trader_requested and true or false
-- items physically staged in the depot (a fact; not merchant-vs-fort split)
local total_value = 0

@@ -76,4 +41,3 @@ for _, ci in ipairs(depot_bld.contained_items) do

-- ---- caravans: the state machine (none / approaching / at depot / leaving) ----
local TS = df.caravan_state.T_trade_state -- 0 None,1 Approaching,2 AtDepot,3 Leaving,4 Stuck
local TS = df.caravan_state.T_trade_state
local function civ_of(eid)

@@ -97,3 +61,2 @@ if not eid or eid == -1 then return nil end

if civ then row.civ = civ end
-- time_remaining is a countdown in ticks; only meaningful once here/leaving.
if (state == 'AtDepot' or state == 'Leaving') and c.time_remaining and c.time_remaining > 0 then

@@ -104,3 +67,2 @@ row.leaving_in_days = math.floor(c.time_remaining / TICKS_PER_DAY)

end
-- Canonicalize: sort by state then civ race so goldens don't flap on list order.
table.sort(caravans, function(a, b)

@@ -121,7 +83,5 @@ if a.state ~= b.state then return a.state < b.state end

-- ---- broker: none / assigned-elsewhere / at depot ----
local broker = { assigned = false, at_depot = false }
local fort = df.global.plotinfo.main.fortress_entity
if fort then
-- find the BROKER position id (responsibility TRADE)
local broker_pos_id

@@ -155,3 +115,2 @@ for _, p in ipairs(fort.positions.own) do

else
-- assigned on paper but no live unit on the map (dead/absent noble)
broker.present = false

@@ -164,3 +123,2 @@ end

-- ---- alerts: facts that crossed a line (mirror the game's own nagging) ----
local alerts = {}

@@ -167,0 +125,0 @@ if depot.exists and not depot.accessible then

--@ module = true
-- mcp_unitVisibility: the fog-of-war gate for UNIT enumeration.
--
-- Companion to mcp_readTerrain (which gates TERRAIN reads). Every terrain tool
-- honors designation.hidden; unit-listing tools must too, or the fort's fog of
-- war leaks through the back door -- an undiscovered cavern's wraiths are
-- reported as "on the map" even though no dwarf has ever seen them. That is an
-- X-ray: the agent learns the existence and count of hostiles the player
-- cannot see, breaking the facts-only doctrine as much as leaking real terrain
-- would.
--
-- is_hidden(u) is the SINGLE source of truth for "has the fort discovered the
-- tile this unit stands on". Any current or future sensor that enumerates
-- units (threats, fort_status, and any wildlife/animal-economy tool to come)
-- must reqscript this module and filter through it, rather than re-deriving
-- the designation.hidden check inline -- that duplication is exactly how the
-- original leak happened (isDanger/isCitizen predicate copy-pasted into two
-- files with no visibility gate at all).
--
-- A caged/chained beast is gated the same as a loose one: if its tile has
-- never been uncovered, the fort has not actually seen it (e.g. a forgotten
-- beast trapped in an undiscovered cavern), so it stays hidden. Off-map/
-- unloaded blocks are treated as unseen (fail closed, never leak).
-- mcp_unitVisibility: see CONTRIBUTING.md "Shared internals: fog-of-war safety".
-- Returns true when the unit's current tile is undiscovered (designation.hidden)
-- or off-map/unloaded. Mirrors mcp_readTerrain.read_window's per-tile check.
function is_hidden(u)
local p = u.pos
local blk = dfhack.maps.getTileBlock(p.x, p.y, p.z)
if not blk then return true end -- off-map / unloaded => treat as unseen
if not blk then return true end
return blk.designation[p.x % 16][p.y % 16].hidden
end
-- When loaded via reqscript, stop here: the caller just wanted the functions.
if dfhack_flags and dfhack_flags.module then
return
end

@@ -1,20 +0,1 @@

-- mcp_unmetNeeds: why the fort is stressed — the needs system, aggregated.
--
-- Companion to fort_status's happiness buckets: those say HOW MANY dwarves are
-- unhappy; this says WHICH needs are starving them and how badly. Facts only:
-- it reports the need types and severities, not what to build about them (that's
-- game knowledge the agent looks up). Each citizen soul carries
-- personality.needs (df.need_type). focus_level is the signal: >= 0 met/neutral,
-- negative = distracted, magnitude = how starved. A dwarf can hold several needs
-- of one type (e.g. PrayOrMeditate per deity) — we count each DWARF at most once
-- per need type, using their worst focus for that type.
--
-- Verified live on 53.15-r2: soul.personality.needs iterates; df.need_type[id]
-- yields readable tokens (PrayOrMeditate, DrinkAlcohol, Socialize, ...).
--
-- DISTRACTED_BELOW is a heuristic cut (a tunable): below it a dwarf is
-- meaningfully distracted, not merely slightly unfulfilled. Ranked by how many
-- dwarves are distracted, so the top line is the highest-leverage fix.
-- Invoked by name via DFHack RunCommand; prints ONE JSON object.
local json = require('json')

@@ -28,3 +9,3 @@ local function emit(t) print(json.encode(t)) end

local DISTRACTED_BELOW = -1000 -- tunable: focus_level under this = distracted
local DISTRACTED_BELOW = -1000

@@ -34,5 +15,4 @@ local citizens = dfhack.units.getCitizens(true)

-- agg[type] = { distracted = <#dwarves>, worst = <most negative focus> }
local agg = {}
local any_unmet = {} -- set of unit ids with >=1 distracted need
local any_unmet = {}

@@ -42,3 +22,3 @@ for _, u in ipairs(citizens) do

if soul and soul.personality and soul.personality.needs then
local worst_by_type = {} -- per-dwarf: type -> worst focus this dwarf has
local worst_by_type = {}
for _, need in ipairs(soul.personality.needs) do

@@ -62,3 +42,2 @@ if need.focus_level < DISTRACTED_BELOW then

-- Flatten and sort by #dwarves distracted (desc), then severity.
local rows = {}

@@ -73,3 +52,2 @@ for t, a in pairs(agg) do

-- Keep the top offenders; a long tail of 1-2 dwarf needs isn't actionable.
local top = {}

@@ -81,9 +59,3 @@ for i = 1, math.min(#rows, 8) do top[i] = rows[i] end

-- Almost every dwarf always carries at least one distracted need, so
-- "n_affected > 0" crosses no line — it's the baseline, not news (77 of 78 here).
-- The signal is REACH: a single need distracting a large SHARE of the fort is a
-- systemic, nameable gap the player can act on. Gate the top-need alert on that
-- share; drop the near-universal aggregate line (dwarves_with_unmet_need stays a
-- queryable output fact, just not an alert).
local NEED_SHARE_ALERT = 0.25 -- tunable: top need distracting >= this share -> alert
local NEED_SHARE_ALERT = 0.25

@@ -90,0 +62,0 @@ local alerts = {}

@@ -1,32 +0,1 @@

-- mcp_workDetail: A3 — labor via work details. Backs two MCP tools:
-- work_details (read-only sensor; subcommand "list")
-- assign_work_detail (gated actuator; "plan_assign" / "apply_assign")
--
-- EXECUTE, NEVER DECIDE: the caller names the unit, the detail, and the desired
-- membership; this script toggles it and reports facts. No "you should assign X".
-- The §A0 dry-run/confirm/undo loop lives in TS (src/actuator.ts); this script
-- answers plan_assign (preview + signature) and apply_assign (mutate + readback).
--
-- Work details live in df.global.plotinfo.labor_info.work_details
-- (vector<work_detail*>). Each work_detail has: .name (string), .assigned_units
-- (vector<int32_t> of unit ids), .allowed_labors (bool[] indexed by df.unit_labor —
-- index i true = that labor is enabled by the detail), .flags (with .mode, a
-- df.work_detail_mode: Default|EverybodyDoesThis|NobodyDoesThis|OnlySelectedDoesThis),
-- and .icon. These are the SAME structures the in-game Labor -> Work Details screen
-- reads, so a membership change appears in-game (spike #11, verified live on 53.15).
--
-- LABOR PROPAGATION (the residual risk #26 flagged, RESOLVED here): editing
-- assigned_units alone does NOT immediately update a unit's status.labors — the game
-- reconciles them only on a frame advance, via its automatic-professions system
-- (gated by df.global.game.external_flag.automatic_professions_disabled; false on the
-- fixture = enabled). assigned_units is therefore the DURABLE source of truth, and
-- status.labors is a derived cache. So apply_assign edits assigned_units AND mirrors
-- the affected labors onto unit.status.labors NOW — recomputing each as the union
-- across ALL details (granted()), exactly what the game reconciles to — so the change
-- is visible immediately even on a paused fort. Verified live: assign 111 -> Miners
-- => MINE true; remove => MINE false. (spike #11 + #26 residual-risk check.)
--
-- Invoked by name via DFHack RunCommand with a subcommand as arg 1; prints ONE JSON
-- object. Args arrive unescaped as `...`.
local json = require('json')

@@ -50,3 +19,2 @@ local function emit(t) print(json.encode(t)) end

-- The labor names a detail enables (allowed_labors bool[] -> df.unit_labor names).
local function labor_names(d)

@@ -60,6 +28,2 @@ local out = {}

-- Does ANY detail grant labor L to unit uid? This is the union the game itself
-- computes: an EverybodyDoesThis detail grants L to everyone; an OnlySelectedDoesThis
-- (or Default) detail grants L only to its assigned members; NobodyDoesThis grants
-- nothing. Used to mirror the affected labors onto the unit after a membership edit.
local function granted(uid, L)

@@ -83,3 +47,2 @@ local wds = work_details()

-- Index of uid within a detail's assigned_units, or nil if absent.
local function member_index(d, uid)

@@ -92,9 +55,2 @@ for j = 0, #d.assigned_units - 1 do

-- Facts for one detail: labors it enables + a bounded, id-sorted member list (with
-- parallel readable names), the full member_count, and a truncation flag. Members are
-- SORTED by id so the payload is deterministic regardless of the vector's own order.
-- `after` (optional) is the members_after cursor: only ids > after are listed, so a
-- capped list can be paged. member_count stays the FULL count regardless of cursor.
-- members_cursor (the last listed id, to pass back as members_after) is emitted ONLY
-- when this detail's list was cap-truncated — an untruncated payload is unchanged.
local function detail_facts(d, after)

@@ -132,3 +88,2 @@ local ids = {}

-- Find a detail by exact name (first match). Returns index, detail or nil.
local function find_detail(name)

@@ -142,7 +97,2 @@ local wds = work_details()

-- ============================ list ============================
-- work_details(): every work detail with its labors + bounded membership. READ-ONLY.
-- Narrowing args (both optional, empty = unset): [2] = exact detail name (return
-- ONLY that detail), [3] = members_after unit-id cursor (member lists start after
-- that id — the paging path past MEMBER_CAP). With neither, output is unchanged.
if sub == 'list' then

@@ -159,4 +109,2 @@ local fname = a[2]

end
-- count = details LISTED (the fort total when unfiltered). members_after is
-- echoed only when a cursor was passed, so the no-arg payload is unchanged.
emit({ count = #out, details = out, members_after = after })

@@ -166,4 +114,2 @@ return

-- ============================ assign ============================
-- args: [2]=unit_id, [3]=detail name, [4]=enabled ("true"/"false"/"1"/"0")
local function parse_assign()

@@ -202,4 +148,2 @@ local uid = tonumber(a[2])

-- Digest of the labor set a detail enables: the enabled df.unit_labor indices,
-- comma-joined in index order (stable). Changes exactly when the set changes.
local function labor_digest(d)

@@ -213,6 +157,2 @@ local idx = {}

-- Digest of a detail's FULL membership: its assigned unit ids sorted ascending and
-- comma-joined. Changes whenever the SET of members changes — including a swap that
-- replaces one member with another and so leaves the count (and any single unit's
-- own membership) untouched. The count alone can't see such a swap; this digest can.
local function member_digest(d)

@@ -225,10 +165,2 @@ local ids = {}

-- Signature captures the detail identity + THIS unit's current membership state +
-- the detail's member count + the detail's MODE and allowed-labor set + a digest of
-- its FULL membership. The last is what makes a swap (replace member A with member B,
-- count unchanged, this unit still a non-member) void the token: this-unit-membership
-- and count would both be unchanged, but the membership SET differs. Any change to the
-- previewed target state (the unit joining/leaving, another unit added/removed/swapped,
-- the detail vanishing, its mode or labor set edited in-game between preview and
-- confirm) voids the token. `enabled` is part of the op args (opDigest), not this.
local function assign_signature(p, currently_member, count)

@@ -259,6 +191,2 @@ return string.format(

if sub == 'plan_assign' then
-- The unit's RESULTING memberships (#26 AC): every detail the unit would be a
-- member of AFTER the change — its current memberships adjusted for the pending
-- add/remove (matched by detail INDEX, not name, so duplicates can't confuse
-- it). Bounded, though forts only ever have ~a dozen details.
local RESULTING_CAP = 50

@@ -288,5 +216,2 @@ local wds = work_details()

resulting_members_count = resulting,
-- Unconditional boolean fact: true ONLY when this op removes the detail's sole
-- member. Emitted as false (not omitted) so "not the sole member" is a stated
-- fact, never conflated with an older payload that lacked the field.
only_member = only_member,

@@ -303,10 +228,2 @@ allowed_labors = labor_names(d),

-- apply_assign. BEFORE editing, snapshot each affected labor's CURRENT cache value
-- and compare it to the union under the PRE-edit membership (granted() still sees
-- the original membership here). Undo reverses the membership edit and recomputes
-- the union, which reproduces exactly that pre-edit union — so if a labor's prior
-- cache already DIFFERED from it (a stale cache: the paused / automatic-professions-
-- disabled case this mirror exists for), undo would CORRECT the cache rather than
-- restore its exact prior byte. We record the prior values and flag which labors
-- were stale so the reversal's faithfulness is reported honestly, not overstated.
local prior_labors, stale_labors = {}, {}

@@ -322,3 +239,2 @@ for i = 0, #d.allowed_labors - 1 do

-- toggle membership
local now_member = currently_member

@@ -333,4 +249,2 @@ if p.enabled and not currently_member then

-- Propagate: recompute each labor this detail governs to the union across ALL
-- details, matching what the game reconciles to (see the LABOR PROPAGATION note).
local labors_now = {}

@@ -358,9 +272,3 @@ for i = 0, #d.allowed_labors - 1 do

prior_member = currently_member,
-- The exact prior cache values for the affected labors, so a caller could
-- restore them byte-for-byte even when the inverse call would recompute them.
prior_labors = prior_labors,
-- faithful=true is the normal case: the prior cache matched the pre-edit union,
-- so the inverse call's recompute restores it exactly. faithful=false ONLY when
-- some affected labor's cache was stale — then undo restores membership but
-- recomputes (corrects) the cache instead of reproducing its prior byte.
faithful = #stale_labors == 0,

@@ -367,0 +275,0 @@ not_reproduced = (#stale_labors > 0) and {

@@ -1,19 +0,1 @@

-- mcp_workOrder: A1 actuator — manager (work) orders. Backs three MCP tools:
-- work_order_list (read-only sensor; subcommand "list")
-- work_order_create (gated actuator; "plan_create" / "apply_create")
-- work_order_cancel (gated actuator; "apply"/"plan" via "plan_cancel"/"apply_cancel")
--
-- EXECUTE, NEVER DECIDE: every field of the order is supplied by the caller; this
-- script builds/lists/removes the struct and reports facts. No "you should queue
-- X" logic. The §A0 dry-run/confirm/undo loop lives in TS (src/actuator.ts); this
-- script just answers plan_* (preview + signature) and apply_* (mutate + readback).
--
-- Manager orders live in df.global.world.manager_orders.all (vector<manager_order*>)
-- with .manager_order_next_id for fresh ids — the SAME structures the in-game
-- manager screen reads, so a created order appears in-game (spike #11, verified
-- live on 53.15: create 226->227, cancel ->226; :new()/insert/erase+delete).
--
-- Invoked by name via DFHack RunCommand with a subcommand as arg 1; prints ONE
-- JSON object. Args arrive unescaped as `...`.
local json = require('json')

@@ -34,4 +16,2 @@ local function emit(t) print(json.encode(t)) end

-- Is a citizen assigned to a MANAGE_PRODUCTION position? A manager order can be
-- created without one, but won't be validated/processed — reported as a fact.
local function manager_present()

@@ -53,7 +33,2 @@ local ent = df.historical_entity.find(df.global.plotinfo.group_id)

-- Facts for one order. material/item_type are decoded to tokens; the KEY IS OMITTED
-- when unset (this DFHack JSON encoder can't emit null — the TS type marks them
-- optional to match). status.active / status.validated are the per-order validation
-- state: validated=false on an active order means it cannot currently be fulfilled
-- (e.g. required materials unavailable) — a fact, for the agent to interpret.
local function order_facts(o)

@@ -82,3 +57,2 @@ local mat

-- Identity of an order's output spec — for duplicate detection and cancel stability.
local function identity(job_type, item_type, item_subtype, mat_type, mat_index)

@@ -88,9 +62,4 @@ return string.format('%d/%d/%d/%d/%d', job_type, item_type, item_subtype, mat_type, mat_index)

-- ============================ list ============================
-- args: [2] = after_id (optional pagination cursor). Returns active orders with
-- id > after_id, sorted by id, capped at LIST_CAP. `count` is the TOTAL number of
-- active orders in the fort (unfiltered); when the page is capped, `truncated` is
-- true and `next_cursor` is the id to pass back as after_id for the next page.
if sub == 'list' then
local after = tonumber(a[2]) -- nil => from the start
local after = tonumber(a[2])
local all = df.global.world.manager_orders.all

@@ -120,5 +89,2 @@ local out = {}

-- ============================ create ============================
-- args: [2]=job_type name, [3]=amount, [4]=frequency, [5]=material token,
-- [6]=item_type name, [7]=conditions JSON (advanced prerequisites; rejected)
local function parse_create()

@@ -204,4 +170,2 @@ local jt_name = a[2]

},
-- target signature: the op's output identity + amount/frequency + whether a
-- duplicate already exists (the only target-state that matters here).
signature = string.format('create/%s/%d/%d/dup=%s',

@@ -213,3 +177,2 @@ identity(p.jt, p.item_type, p.item_subtype, p.mat_type, p.mat_index),

end
-- apply_create
local mo = df.global.world.manager_orders

@@ -245,3 +208,2 @@ local o = df.manager_order:new()

-- ============================ cancel ============================
local function find_order(id)

@@ -262,4 +224,2 @@ local all = df.global.world.manager_orders.all

local nconds = #o.item_conditions + #o.order_conditions
-- Signature captures EVERYTHING the preview shows, so any change to the previewed
-- order (progress, frequency, workshop, conditions, validation) voids the token.
local signature = string.format('cancel/%d/%s/tot=%d/left=%d/%s/ws=%d/cond=%d/sub=%d/val=%s',

@@ -273,10 +233,6 @@ id, identity(o.job_type, o.item_type, o.item_subtype, o.mat_type, o.mat_index),

end
-- apply_cancel: capture the recreate spec (undo handle) BEFORE removing. The spec
-- holds only what work_order_create can reproduce; `faithful` is true ONLY when the
-- order carries nothing create would drop. When false, `not_reproduced` names the
-- lost features as facts, so the agent knows the undo is approximate.
local faithful = (o.workshop_id == -1) and (nconds == 0) and (o.item_subtype == -1)
local recreate = {
job_type = facts.job_type,
amount = o.amount_left, -- the REMAINING work, not the original total
amount = o.amount_left,
frequency = facts.frequency,

@@ -283,0 +239,0 @@ material = facts.material,

+3
-1
{
"name": "dfhack-mcp",
"version": "1.0.1",
"version": "1.1.0",
"description": "MCP server exposing a live Dwarf Fortress fort to an AI agent as curated, semantic tools",

@@ -37,2 +37,4 @@ "type": "module",

"verify:update": "node scripts/verify.mjs --tier=2 --update",
"verify:game-data": "node scripts/verify-game-data.mjs",
"verify:wiki": "node scripts/verify-wiki.mjs",
"bootstrap": "node scripts/bootstrap.mjs",

@@ -39,0 +41,0 @@ "worktree": "node scripts/new-worktree.mjs",

@@ -125,3 +125,3 @@ # dfhack-mcp

- **`game_data(query, kind?)`** — your world's raws across six kinds (`creature`, `material`, `plant`, `reaction`, `item`, `building`; default `creature`). Ground truth for procedural creatures (demons, forgotten beasts, titans) that never reach the wiki. `query` is a token (`DEMON_4`, `INORGANIC:IRON`), a name (`"plump helmet"`), or — for creatures — a live `unit_id`. One strong hit → a full dossier; several → a disambiguation list; none → `{"match_count":0,"matches":[]}`.
- **`identify(query)`** — _"what is this creature and how do I handle it"_ in one call: fuses `game_data` (your world's raws) with `wiki_lookup` (strategy). Returns the dossier, a `tactics` list pairing each decisive trait with a hard-fact implication (e.g. _TRAPAVOID → mechanical traps don't work_), and 1–2 trimmed wiki excerpts. Reach for it when a threat appears.
- **`identify(query)`** — _"what is this creature and how do I handle it"_ in one call: fuses `game_data` (your world's raws) with `wiki_lookup` (strategy). Returns the dossier (its `flags[]`/`interactions[]` carry facts like _TRAPAVOID → mechanical traps don't work_) plus 1–2 trimmed wiki excerpts. Reach for it when a threat appears.
- **`wiki_search(query)`** — search the DF wiki for candidate titles + cleaned snippets (biased to the `DF2014` namespace).

@@ -128,0 +128,0 @@ - **`wiki_lookup(title, section?, refresh?)`** — fetch a wiki article as clean text, pinned to `DF2014`; follows redirects, honors section fragments, cached ~30 days.

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

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