| -- 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 | ||
| emit({ error = 'no fort loaded' }) | ||
| return | ||
| end | ||
| local a = { ... } | ||
| local sub = a[1] | ||
| -- ---- shared facts: what a save would freeze -------------------------------- | ||
| local months = {'Granite','Slate','Felsite','Hematite','Malachite','Galena', | ||
| 'Limestone','Sandstone','Timber','Moonstone','Opal','Obsidian'} | ||
| local seasons = {'Spring','Summer','Autumn','Winter'} | ||
| local function game_date() | ||
| local tick = df.global.cur_year_tick | ||
| local midx = math.floor(tick / 33600) | ||
| local day = math.floor((tick % 33600) / 1200) + 1 | ||
| return { | ||
| year = df.global.cur_year, | ||
| year_tick = tick, | ||
| month = months[midx + 1], | ||
| season = seasons[math.floor(midx / 3) + 1], | ||
| day = day, | ||
| } | ||
| end | ||
| -- 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() | ||
| local ok_name, fname = pcall(function() | ||
| return dfhack.translation.translateName(df.global.world.world_data.active_site[0].name, true) | ||
| end) | ||
| return { | ||
| fort_name = (ok_name and fname ~= '') and fname or nil, | ||
| method = 'quicksave', | ||
| game_date = game_date(), | ||
| } | ||
| end | ||
| -- ============================ plan ============================ | ||
| if sub == 'plan' then | ||
| local preview = save_facts() | ||
| -- Facts the agent needs to understand what confirming does. | ||
| preview.reversible = false | ||
| preview.effect = 'triggers DFHack quicksave; DF writes a save asynchronously (over the next few frames) ' | ||
| .. 'via its autosave — the destination follows your DF autosave settings, typically a rotating "autosave" ' | ||
| .. 'folder rather than an overwrite of the loaded save' | ||
| emit({ | ||
| 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', | ||
| }) | ||
| return | ||
| end | ||
| -- ============================ 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') | ||
| if rc ~= 0 then | ||
| emit({ error = 'quicksave failed (command_result ' .. tostring(rc) .. '): ' .. tostring(out) }) | ||
| return | ||
| end | ||
| emit({ | ||
| changes = { | ||
| save_requested = true, | ||
| method = 'quicksave', | ||
| game_date = facts.game_date, | ||
| }, | ||
| undo = { | ||
| reversible = false, | ||
| note = 'no undo — once written, the save persists. To roll back, load the appropriate save/autosave from before this call in DF', | ||
| }, | ||
| -- 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 = { | ||
| dispatched = rc == 0, | ||
| command_result = rc, | ||
| write = 'asynchronous — DF commits the save over the next few frames via its autosave; this call cannot confirm the file has landed', | ||
| game_date = facts.game_date, | ||
| }, | ||
| }) | ||
| return | ||
| end | ||
| emit({ error = 'unknown subcommand: ' .. tostring(sub) }) |
| --@ 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). | ||
| -- 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 | ||
| 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 |
@@ -13,2 +13,6 @@ -- mcp_fortStatus: one-call situational overview of the loaded fort — name, date, | ||
| -- 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') | ||
| local months = {'Granite','Slate','Felsite','Hematite','Malachite','Galena', | ||
@@ -47,3 +51,4 @@ 'Limestone','Sandstone','Timber','Moonstone','Opal','Obsidian'} | ||
| if dfhack.units.isActive(u) and not dfhack.units.isDead(u) | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) then | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) | ||
| and not visibility.is_hidden(u) then | ||
| hostiles = hostiles + 1 | ||
@@ -50,0 +55,0 @@ end |
@@ -7,2 +7,5 @@ -- mcp_threats: enumerate dangerous units on the map, grouped by kind. | ||
| -- 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. | ||
@@ -18,2 +21,6 @@ | ||
| -- 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 | ||
@@ -101,3 +108,4 @@ -- line. Contained (caged/chained) threats are counted apart from active ones. | ||
| if dfhack.units.isActive(u) and not dfhack.units.isDead(u) | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) then | ||
| and dfhack.units.isDanger(u) and not dfhack.units.isCitizen(u) | ||
| and not visibility.is_hidden(u) then | ||
| local contained = u.flags1.caged or u.flags1.chained | ||
@@ -104,0 +112,0 @@ local name = dfhack.units.getReadableName(u) |
+1
-1
| { | ||
| "name": "dfhack-mcp", | ||
| "version": "1.0.0", | ||
| "version": "1.0.1", | ||
| "description": "MCP server exposing a live Dwarf Fortress fort to an AI agent as curated, semantic tools", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+7
-0
@@ -27,2 +27,5 @@ # dfhack-mcp | ||
| Every tool has a reference page — parameters, return shape, real example | ||
| output, caveats — in [docs/tools](docs/tools/README.md). | ||
| ## Quick start | ||
@@ -179,2 +182,6 @@ | ||
| **Saving the game** | ||
| - **`game_save()`** — checkpoint the fort with a quicksave before a large or risky change, so a bad batch can be rolled back by loading the save. Takes no arguments; the dry-run previews the fort and game date being frozen. Two facts to know: the write is **asynchronous** (DF commits it over the next few frames — the readback confirms the quicksave _dispatched_, not that the file landed) and it routes through DF's **autosave**, so it lands in a rotating "autosave" folder per your DF settings rather than overwriting the loaded save. Irreversible: to roll back, load the appropriate save/autosave in DF. Fortress mode only. | ||
| ## Configuration | ||
@@ -181,0 +188,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
1992998
0.66%33
6.45%15835
0.12%244
2.95%