@@ -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", |
+1
-1
@@ -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
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances
1849750
-7.19%15796
-0.25%17
21.43%