diff --git a/armoks-blessing.lua b/armoks-blessing.lua index 5f930f985f..9a64c5bc96 100644 --- a/armoks-blessing.lua +++ b/armoks-blessing.lua @@ -1,24 +1,9 @@ -- Adjust all attributes of all dwarves to an ideal -- by vjek +local rejuvenate = reqscript('rejuvenate') local utils = require('utils') -function rejuvenate(unit) - if unit==nil then - print ("No unit available! Aborting with extreme prejudice.") - return - end - - local current_year=df.global.cur_year - local newbirthyear=current_year - 20 - if unit.birth_year < newbirthyear then - unit.birth_year=newbirthyear - end - if unit.old_year < current_year+100 then - unit.old_year=current_year+100 - end - -end -- --------------------------------------------------------------------------- function brainwash_unit(unit) if unit==nil then @@ -251,7 +236,7 @@ function adjust_all_dwarves(skillname) print("Adjusting "..dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(v)))) brainwash_unit(v) elevate_attributes(v) - rejuvenate(v) + rejuvenate.rejuvenate(v, true) if skillname then if df.job_skill_class[skillname] then LegendaryByClass(skillname,v) diff --git a/assign-minecarts.lua b/assign-minecarts.lua index 3cff2cd726..53e498d2b7 100644 --- a/assign-minecarts.lua +++ b/assign-minecarts.lua @@ -2,6 +2,7 @@ --@ module = true local argparse = require('argparse') +local utils = require('utils') function get_free_vehicles() local free_vehicles = {} @@ -13,16 +14,12 @@ function get_free_vehicles() return free_vehicles end -local function has_minecart(route) - return #route.vehicle_ids > 0 -end - local function has_stops(route) return #route.stops > 0 end local function get_minecart(route) - if not has_minecart(route) then return end + if #route.vehicle_ids == 0 then return end local vehicle = utils.binsearch(df.global.world.vehicles.active, route.vehicle_ids[0], 'id') if not vehicle then return end return df.item.find(vehicle.item_id) @@ -37,8 +34,9 @@ local function get_id_and_name(route) end local function assign_minecart_to_route(route, quiet, minecart) - if has_minecart(route) then - return get_minecart(route) + local assigned_minecart = get_minecart(route) + if assigned_minecart then + return assigned_minecart end if not has_stops(route) then if not quiet then @@ -57,6 +55,12 @@ local function assign_minecart_to_route(route, quiet, minecart) return false end end + for _,vehicle_id in ipairs(route.vehicle_ids) do + local vehicle = utils.binsearch(df.global.world.vehicles.all, vehicle_id, 'id') + if vehicle then vehicle.route_id = -1 end + end + route.vehicle_ids:resize(0) + route.vehicle_stops:resize(0) route.vehicle_ids:insert('#', minecart.id) route.vehicle_stops:insert('#', 0) minecart.route_id = route.id @@ -99,7 +103,7 @@ local function list() for _,route in ipairs(routes) do print(('%-8d %-9s %-9s %s') :format(route.id, - has_minecart(route) and 'yes' or 'NO', + get_minecart(route) and 'yes' or 'NO', has_stops(route) and 'yes' or 'NO', get_name(route))) end @@ -113,7 +117,7 @@ local function all(quiet) local minecarts, idx = get_free_vehicles(), 1 local routes = df.global.plotinfo.hauling.routes for _,route in ipairs(routes) do - if has_minecart(route) then + if get_minecart(route) then goto continue end if not assign_minecart_to_route(route, quiet, minecarts[idx]) then @@ -148,7 +152,7 @@ local function main(args) local route = get_route_by_id(requested_route_id) if not route then dfhack.printerr('route id not found: '..requested_route_id) - elseif has_minecart(route) then + elseif get_minecart(route) then if not quiet then print(('Route %s already has a minecart assigned.') :format(get_id_and_name(route))) diff --git a/changelog.txt b/changelog.txt index dc2e423dbb..90278e7137 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,33 +27,66 @@ Template for new versions: # Future ## New Tools -- `embark-anyone`: allows you to embark as any civilization, including dead and non-dwarven ones +- `fix/wildlife`: prevent wildlife from getting stuck when trying to exit the map. This fix needs to be enabled manually in `gui/control-panel` on the Bug Fixes tab since not all players want this bug to be fixed. + +## New Features +- `force`: support the ``Wildlife`` event to allow additional wildlife to enter the map + +## Fixes +- `gui/quickfort`: only print a help blueprint's text once even if the repeat setting is enabled +- `makeown`: quell any active enemy relationships with the converted creature +- `fix/loyaltycascade`: allow the fix to work on non-dwarven citizens +- `control-panel`: fix setting numeric preferences from the commandline + +## Misc Improvements +- `control-panel`: Add realistic-melting tweak to control-panel registry +- `idle-crafting`: also support making shell crafts for workshops with linked input stockpiles +- `gui/gm-editor`: automatic display of semantic values for language_name fields +- `fix/stuck-worship`: reduced console output by default. Added ``--verbose`` and ``--quiet`` options. + +## Removed +- `modtools/force`: merged into `force` + +# 50.13-r5 + +## New Tools +- `embark-anyone`: allows you to embark as any civilization, including dead and non-dwarven civs - `idle-crafting`: allow dwarves to independently satisfy their need to craft objects - `gui/family-affairs`: (reinstated) inspect or meddle with pregnancies, marriages, or lover relationships +- `notes`: attach notes to locations on a fort map ## New Features - `caravan`: DFHack dialogs for trade screens (both ``Bring goods to depot`` and the ``Trade`` barter screen) can now filter by item origins (foreign vs. fort-made) and can filter bins by whether they have a mix of ethically acceptable and unacceptable items in them - `caravan`: If you have managed to select an item that is ethically unacceptable to the merchant, an "Ethics warning" badge will now appear next to the "Trade" button. Clicking on the badge will show you which items that you have selected are problematic. The dialog has a button that you can click to deselect the problematic items in the trade list. - `confirm`: If you have ethically unacceptable items selected for trade, the "Are you sure you want to trade" confirmation will warn you about them - `quickfort`: ``#zone`` blueprints now integrated with `preserve-rooms` so you can create a zone and automatically assign it to a noble or administrative role -- `position`: option to copy cursor position to clipboard +- `exportlegends`: option to filter by race on historical figures page ## Fixes -- `timestream`: ensure child growth events (e.g. becoming an adult) are not skipped +- `timestream`: ensure child growth events (that is, a child's transition to adulthood) are not skipped; existing "overage" children will be automatically fixed within a year - `empty-bin`: ``--liquids`` option now correctly empties containers filled with LIQUID_MISC (like lye) - `gui/design`: don't overcount "affected tiles" for Line & Freeform drawing tools +- `deep-embark`: fix error when embarking where there is no land to stand on (e.g. when embarking in the ocean with `gui/embark-anywhere`) +- `deep-embark`: fix failure to transport units and items when embarking where there is no room to spawn the starting wagon +- `gui/create-item`, `modtools/create-item`: items of type "VERMIN", "PET", "REMANS", "FISH", "RAW FISH", and "EGG" no longer spawn creature item "nothing" and will now stack correctly +- `rejuvenate`: don't set a lifespan limit for creatures that are immortal (e.g. elves, goblins) +- `rejuvenate`: properly disconnect babies from mothers when aging babies up to adults ## Misc Improvements - `gui/sitemap`: show whether a unit is friendly, hostile, or wild - `gui/sitemap`: show whether a unit is caged - `gui/control-panel`: include option for turning off dumping of old clothes for `tailor`, for players who have magma pit dumps and want to save old clothes from being dumped into the magma -- `position`: report current historical era (e.g., "Age of Myth") +- `position`: report current historical era (e.g., "Age of Myth"), site/adventurer world coords, and mouse map tile coords +- `position`: option to copy keyboard cursor position to the clipboard +- `assign-minecarts`: reassign vehicles to routes where the vehicle has been destroyed (or has otherwise gone missing) +- `fix/dry-buckets`: prompt DF to recheck requests for aid (e.g. "bring water" jobs) when a bucket is unclogged and becomes available for use +- `exterminate`: show descriptive names for the listed races in addition to their IDs +- `exterminate`: show actual names for unique creatures such as forgotten beasts and titans +- `fix/ownership`: now also checks and fixes room ownership links ## Documentation - `gui/embark-anywhere`: add information about how the game determines world tile pathability and instructions for bridging two landmasses -## Removed - # 50.13-r4 ## New Features diff --git a/deep-embark.lua b/deep-embark.lua index 2e5f141283..1745774a9b 100644 --- a/deep-embark.lua +++ b/deep-embark.lua @@ -1,348 +1,360 @@ --@ module = true -local utils = require 'utils' +local dlg = require('gui.dialogs') +local utils = require('utils') function getFeatureID(cavernType) - local features = df.global.world.features - local map_features = features.map_features - if cavernType == 'CAVERN_1' then - for i, feature in ipairs(map_features) do - if feature._type == df.feature_init_subterranean_from_layerst - and feature.start_depth == 0 then - return features.feature_global_idx[i] - end - end - elseif cavernType == 'CAVERN_2' then - for i, feature in ipairs(map_features) do - if feature._type == df.feature_init_subterranean_from_layerst - and feature.start_depth == 1 then - return features.feature_global_idx[i] - end - end - elseif cavernType == 'CAVERN_3' then - for i, feature in ipairs(map_features) do - if feature._type == df.feature_init_subterranean_from_layerst - and feature.start_depth == 2 then - return features.feature_global_idx[i] - end - end - elseif cavernType == 'UNDERWORLD' then - for i, feature in ipairs(map_features) do - if feature._type == df.feature_init_underworld_from_layerst - and feature.start_depth == 4 then - return features.feature_global_idx[i] - end + local features = df.global.world.features + local map_features = features.map_features + if cavernType == 'CAVERN_1' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 0 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'CAVERN_2' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 1 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'CAVERN_3' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 2 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'UNDERWORLD' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_underworld_from_layerst and feature.start_depth == 4 then + return features.feature_global_idx[i] + end + end end - end end function getFeatureBlocks(featureID) - local featureBlocks = {} --as:number[] - for i,block in ipairs(df.global.world.map.map_blocks) do - if block.global_feature == featureID and block.local_feature == -1 then - table.insert(featureBlocks, i) + local featureBlocks = {} --as:number[] + for i, block in ipairs(df.global.world.map.map_blocks) do + if block.global_feature == featureID and block.local_feature == -1 then + table.insert(featureBlocks, i) + end end - end - return featureBlocks + return featureBlocks end function isValidTiletype(tiletype) - local tt = df.tiletype[tiletype] - local tiletypeAttrs = df.tiletype.attrs[tt] - local material = tiletypeAttrs.material - local forbiddenMaterials = { - df.tiletype_material.TREE, -- so as not to embark stranded on top of a tree - df.tiletype_material.MUSHROOM, - df.tiletype_material.FIRE, - df.tiletype_material.CAMPFIRE - } - for _,forbidden in ipairs(forbiddenMaterials) do - if material == forbidden then - return false - end - end - local shapeAttrs = df.tiletype_shape.attrs[tiletypeAttrs.shape] - return shapeAttrs.walkable + local tt = df.tiletype[tiletype] + local tiletypeAttrs = df.tiletype.attrs[tt] + local material = tiletypeAttrs.material + local forbiddenMaterials = utils.invert{ + df.tiletype_material.TREE, -- so as not to embark stranded on top of a tree + df.tiletype_material.MUSHROOM, + df.tiletype_material.FIRE, + df.tiletype_material.CAMPFIRE + } + if forbiddenMaterials[material] then return false end + local shapeAttrs = df.tiletype_shape.attrs[tiletypeAttrs.shape] + return shapeAttrs.walkable end function getValidEmbarkTiles(block) - local validTiles = {} --as:{_type:table,x:number,y:number,z:number}[] - for xi = 0,15 do - for yi = 0,15 do - if block.designation[xi][yi].flow_size == 0 - and isValidTiletype(block.tiletype[xi][yi]) then - table.insert(validTiles, {x = block.map_pos.x + xi, y = block.map_pos.y + yi, z = block.map_pos.z}) - end + local validTiles = {} --as:{_type:table,x:number,y:number,z:number}[] + for xi = 0, 15 do + for yi = 0, 15 do + if block.designation[xi][yi].flow_size == 0 + and isValidTiletype(block.tiletype[xi][yi]) + then + table.insert(validTiles, { x = block.map_pos.x + xi, y = block.map_pos.y + yi, z = block.map_pos.z }) + end + end end - end - return validTiles + return validTiles end function blockGlowingBarrierAnnouncements(recenter) --- temporarily disables the "glowing barrier has disappeared" announcement --- announcement settings are restored after 1 tick --- setting recenter to true enables recentering of game view to the announcement position - local announcementFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1 -- glowing barrier disappearance announcement - local oldFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1:new() -- backup announcement settings - announcementFlags.DO_MEGA = false - announcementFlags.PAUSE = false - announcementFlags.RECENTER = recenter and true or false - announcementFlags.A_DISPLAY = false - announcementFlags.D_DISPLAY = recenter and true or false -- an actual announcement is required for recentering to occur - dfhack.timeout(1,'ticks', function() -- barrier disappears after 1 tick - announcementFlags:assign(oldFlags) -- restore announcement settings - if recenter then - -- Remove glowing barrier notifications: - local status = df.global.world.status - local announcements = status.announcements - for i = #announcements-1, 0, -1 do - if string.find(announcements[i].text,"glowing barrier has disappeared") then - announcements:erase(i) - break - end - end - local reports = status.reports - for i = #reports-1, 0, -1 do - if string.find(reports[i].text,"glowing barrier has disappeared") then - reports:erase(i) - break + -- temporarily disables the "glowing barrier has disappeared" announcement + -- announcement settings are restored after 1 tick + -- setting recenter to true enables recentering of game view to the announcement position + -- glowing barrier disappearance announcement + local announcementFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1 + local oldFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1:new() -- backup announcement settings + announcementFlags.DO_MEGA = false + announcementFlags.PAUSE = false + announcementFlags.RECENTER = recenter and true or false + announcementFlags.A_DISPLAY = false + announcementFlags.D_DISPLAY = recenter and true or false -- an actual announcement is required for recentering to occur + dfhack.timeout(1, 'ticks', function() -- barrier disappears after 1 tick + announcementFlags:assign(oldFlags) -- restore announcement settings + if recenter then + -- Remove glowing barrier notifications: + local status = df.global.world.status + local announcements = status.announcements + for i = #announcements - 1, 0, -1 do + if string.find(announcements[i].text, "glowing barrier has disappeared") then + announcements:erase(i) + break + end + end + local reports = status.reports + for i = #reports - 1, 0, -1 do + if string.find(reports[i].text, "glowing barrier has disappeared") then + reports:erase(i) + break + end + end + status.display_timer = 0 -- to avoid displaying other announcements end - end - status.display_timer = 0 -- to avoid displaying other announcements - end - end) + end) end function reveal(pos) --- creates an unbound glowing barrier at the target location --- so as to trigger tile revelation when it disappears 1 tick later (fortress mode only) --- should be run in conjunction with blockGlowingBarrierAnnouncements() - local x,y,z = pos2xyz(pos) - local block = dfhack.maps.getTileBlock(x,y,z) - local tiletype = block.tiletype[x%16][y%16] - if tiletype ~= df.tiletype.GlowingBarrier then -- to avoid multiple instances - block.tiletype[x%16][y%16] = df.tiletype.GlowingBarrier + -- creates an unbound glowing barrier at the target location + -- so as to trigger tile revelation when it disappears 1 tick later (fortress mode only) + -- should be run in conjunction with blockGlowingBarrierAnnouncements() + local x, y, z = pos2xyz(pos) + local block = dfhack.maps.getTileBlock(x, y, z) + local tiletype = block.tiletype[x % 16][y % 16] + if tiletype == df.tiletype.GlowingBarrier then -- to avoid multiple instances + return + end + block.tiletype[x % 16][y % 16] = df.tiletype.GlowingBarrier local barriers = df.global.world.event.glowing_barriers local barrier = df.glowing_barrier:new() - barrier.buildings:insert('#',-1) -- being unbound to a building makes the barrier disappear immediately + barrier.buildings:insert('#', -1) -- being unbound to a building makes the barrier disappear immediately barrier.pos:assign(pos) - barriers:insert('#',barrier) + barriers:insert('#', barrier) local hfs = df.glowing_barrier:new() - hfs.triggered = 1 -- this prevents HFS events (which can otherwise be triggered by the barrier disappearing) - barriers:insert('#',hfs) - dfhack.timeout(1,'ticks', function() -- barrier tiletype disappears after 1 tick - block.tiletype[x%16][y%16] = tiletype -- restore old tiletype - barriers:erase(#barriers-1) -- remove hfs blocker - barriers:erase(#barriers-1) -- remove revelation barrier + hfs.triggered = 1 -- this prevents HFS events (which can otherwise be triggered by the barrier disappearing) + barriers:insert('#', hfs) + dfhack.timeout(1, 'ticks', function() -- barrier tiletype disappears after 1 tick + block.tiletype[x % 16][y % 16] = tiletype -- restore old tiletype + barriers:erase(#barriers - 1) -- remove hfs blocker + barriers:erase(#barriers - 1) -- remove revelation barrier end) - end end function moveEmbarkStuff(selectedBlock, embarkTiles) - local spawnPosCentre - for _, hotkey in ipairs(df.global.plotinfo.main.hotkeys) do - if hotkey.name == "Wagon arrival location" then -- the preset hotkey is centred around the spawn point - spawnPosCentre = xyz2pos(hotkey.x, hotkey.y, hotkey.z) - hotkey:assign(embarkTiles[math.random(1, #embarkTiles)]) -- set the hotkey to the new spawn point - break + local spawnPosCentre + for _, hotkey in ipairs(df.global.plotinfo.main.hotkeys) do + if hotkey.cmd == df.ui_hotkey.T_cmd.Zoom then -- the preset hotkey is centred around the spawn point + spawnPosCentre = xyz2pos(hotkey.x, hotkey.y, hotkey.z) + hotkey:assign(embarkTiles[math.random(1, #embarkTiles)]) -- set the hotkey to the new spawn point + break + end + end + + if not spawnPosCentre then -- no place for the wagon; use the position of the first unit + spawnPosCentre = xyz2pos(dfhack.units.getPosition(dfhack.units.getCitizens()[1])) end - end --- only target things within this zone to help avoid teleporting non-embark stuff: --- the following values might need to be modified - local x1 = spawnPosCentre.x - 15 - local x2 = spawnPosCentre.x + 15 - local y1 = spawnPosCentre.y - 15 - local y2 = spawnPosCentre.y + 15 - local z1 = spawnPosCentre.z - 3 -- units can be spread across multiple z-levels when embarking on a mountain - local z2 = spawnPosCentre.z + 3 + -- only target things within this zone to help avoid teleporting non-embark stuff: + -- the following values might need to be modified + local x1 = spawnPosCentre.x - 15 + local x2 = spawnPosCentre.x + 15 + local y1 = spawnPosCentre.y - 15 + local y2 = spawnPosCentre.y + 15 + local z1 = spawnPosCentre.z - 3 -- units can be spread across multiple z-levels when embarking on a mountain + local z2 = spawnPosCentre.z + 3 --- Move citizens and pets: - local unitsAtSpawn = dfhack.units.getUnitsInBox(x1,y1,z1,x2,y2,z2) - local movedUnit = false - for i, unit in ipairs(unitsAtSpawn) do - if unit.civ_id == df.global.plotinfo.civ_id and not unit.flags1.inactive and not unit.flags2.killed then - local pos = embarkTiles[math.random(1, #embarkTiles)] - dfhack.units.teleport(unit, pos) - reveal(pos) - movedUnit = true + -- Move citizens and pets: + local unitsAtSpawn = dfhack.units.getUnitsInBox(x1, y1, z1, x2, y2, z2) + local movedUnit = false + for i, unit in ipairs(unitsAtSpawn) do + if unit.civ_id == df.global.plotinfo.civ_id and not unit.flags1.inactive and not unit.flags2.killed then + local pos = embarkTiles[math.random(1, #embarkTiles)] + dfhack.units.teleport(unit, pos) + reveal(pos) + movedUnit = true + end + end + if movedUnit then + blockGlowingBarrierAnnouncements(true) -- this is separate from the reveal() function as it only needs to be called once per tick, regardless of how many times reveal() has been run end - end - if movedUnit then - blockGlowingBarrierAnnouncements(true) -- this is separate from the reveal() function as it only needs to be called once per tick, regardless of how many times reveal() has been run - end --- Move wagon contents: - local wagonFound = false - for _, wagon in ipairs(df.global.world.buildings.other.WAGON) do --as:df.building_wagonst - if wagon.age == 0 then -- just in case there's an older wagon present for some reason - local contained = wagon.contained_items - for i = #contained-1, 0, -1 do - if contained[i].use_mode == df.building_item_role_type.TEMP then -- actual contents (as opposed to building components) - local item = contained[i].item --- dfhack.items.moveToGround() does not handle items within buildings, so do this manually: - contained:erase(i) - for k = #item.general_refs-1, 0, -1 do - if item.general_refs[k]._type == df.general_ref_building_holderst then - item.general_refs:erase(k) + -- Move wagon contents: + local wagonFound = false + for _, wagon in ipairs(df.global.world.buildings.other.WAGON) do --as:df.building_wagonst + if wagon.age == 0 then -- just in case there's an older wagon present for some reason + local contained = wagon.contained_items + for i = #contained - 1, 0, -1 do + if contained[i].use_mode == df.building_item_role_type.TEMP then -- actual contents (as opposed to building components) + local item = contained[i].item + -- dfhack.items.moveToGround() does not handle items within buildings, so do this manually: + contained:erase(i) + for k = #item.general_refs - 1, 0, -1 do + if item.general_refs[k]._type == df.general_ref_building_holderst then + item.general_refs:erase(k) + end + end + item.flags.in_building = false + item.flags.on_ground = true + local pos = embarkTiles[math.random(1, #embarkTiles)] + item.pos:assign(pos) + selectedBlock.items:insert('#', item.id) + selectedBlock.occupancy[pos.x % 16][pos.y % 16].item = true + end end - end - item.flags.in_building = false - item.flags.on_ground = true - local pos = embarkTiles[math.random(1, #embarkTiles)] - item.pos:assign(pos) - selectedBlock.items:insert('#', item.id) - selectedBlock.occupancy[pos.x%16][pos.y%16].item = true + dfhack.buildings.deconstruct(wagon) + wagon.flags.almost_deleted = true -- wagon vanishes a tick later + wagonFound = true + break end - end - dfhack.buildings.deconstruct(wagon) - wagon.flags.almost_deleted = true -- wagon vanishes a tick later - wagonFound = true - break end - end --- Move items scattered around the spawn point if there's no wagon: - if not wagonFound then - for _, item in ipairs(df.global.world.items.other.IN_PLAY) do - local flags = item.flags - if item.age == 0 -- embark equipment consists of newly created items - and item.pos.x >= x1 and item.pos.x <= x2 - and item.pos.y >= y1 and item.pos.y <= y2 - and item.pos.z >= z1 and item.pos.z <= z2 - and flags.on_ground - and not flags.in_inventory - and not flags.in_building - and not flags.construction - and not flags.spider_web - and not flags.encased then - dfhack.items.moveToGround(item, embarkTiles[math.random(1, #embarkTiles)]) - end + -- Move items scattered around the spawn point if there's no wagon: + if not wagonFound then + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + local flags = item.flags + local item_pos = xyz2pos(dfhack.items.getPosition(item)) + -- items spawned into mid-air incorrectly have the `in_job` flag set + if flags.in_job then + local job_ref = dfhack.items.getSpecificRef(item, df.specific_ref_type.JOB) + if job_ref then + dfhack.job.removeJob(job_ref.data.job) + end + flags.in_job = false + end + if item.age == 0 -- embark equipment consists of newly created items + and item_pos.x >= x1 and item_pos.x <= x2 + and item_pos.y >= y1 and item_pos.y <= y2 + and item_pos.z >= z1 and item_pos.z <= z2 + and not flags.in_inventory + and not flags.in_building + and not flags.construction + and not flags.spider_web + and not flags.encased + then + dfhack.items.moveToGround(item, embarkTiles[math.random(1, #embarkTiles)]) + end + end end - end + + dlg.showMessage('deep-embark', 'Please unpause to zoom to your deep embark.', COLOR_WHITE) end function deepEmbark(cavernType, blockDemons) - if not cavernType then - qerror('Cavern type not specified!') - end + if not cavernType then + qerror('Cavern type not specified!') + end - local cavernBlocks = getFeatureBlocks(getFeatureID(cavernType)) - if #cavernBlocks == 0 then - qerror(cavernType .. " not found!") - end + local cavernBlocks = getFeatureBlocks(getFeatureID(cavernType)) + if #cavernBlocks == 0 then + qerror(cavernType .. " not found!") + end - local moved = false - for n = 1, #cavernBlocks do - local i = math.random(1, #cavernBlocks) - local selectedBlock = df.global.world.map.map_blocks[cavernBlocks[i]] - local embarkTiles = getValidEmbarkTiles(selectedBlock) - if #embarkTiles >= 20 then -- value chosen arbitrarily; might want to increase/decrease (determines how cramped the embark spot is allowed to be) - moveEmbarkStuff(selectedBlock, embarkTiles) - moved = true - break + local moved = false + for n = 1, #cavernBlocks do + local i = math.random(1, #cavernBlocks) + local selectedBlock = df.global.world.map.map_blocks[cavernBlocks[i]] + local embarkTiles = getValidEmbarkTiles(selectedBlock) + if #embarkTiles >= 20 then -- value chosen arbitrarily; might want to increase/decrease (determines how cramped the embark spot is allowed to be) + moveEmbarkStuff(selectedBlock, embarkTiles) + moved = true + break + end + table.remove(cavernBlocks, i) + end + if not moved then + qerror('Insufficient space at ' .. cavernType) end - table.remove(cavernBlocks, i) - end - if not moved then - qerror('Insufficient space at ' .. cavernType) - end - if blockDemons then - disableSpireDemons() - end + if blockDemons then + disableSpireDemons() + end end function disableSpireDemons() --- marks underworld spires on the map as having been breached already, preventing HFS events - for _, spire in ipairs(df.global.world.event.deep_vein_hollows) do - spire.triggered = true - end + -- marks underworld spires on the map as having been breached already, preventing HFS events + for _, spire in ipairs(df.global.world.event.deep_vein_hollows) do + spire.triggered = true + end end function inEmbarkMode() - if df.global.gametype ~= df.game_type.DWARF_MAIN then -- is always set at fortress mode setup - return false - end - local embarkViewScreens = { - df.viewscreen_adopt_regionst, -- onLoad.init kicks in early; this is the viewscreen present at this stage (the 'loading world' viewscreen is also present at adventure mode setup and legends mode, hence the game_type check above) - df.viewscreen_choose_start_sitest, - df.viewscreen_setupdwarfgamest - } - local view = dfhack.gui.getCurViewscreen() - for _, valid in ipairs(embarkViewScreens) do - if view._type == valid or view.parent._type == valid and view._type ~= df.viewscreen_textviewerst then -- df.viewscreen_textviewerst is present right after embarking (displays the embark message) and has .parent._type == df.viewscreen_setupdwarfgamest - return true + if df.global.gametype ~= df.game_type.DWARF_MAIN then -- is always set at fortress mode setup + return false + end + local embarkViewScreens = { + df.viewscreen_adopt_regionst, -- onLoad.init kicks in early; this is the viewscreen present at this stage (the 'loading world' viewscreen is also present at adventure mode setup and legends mode, hence the game_type check above) + df.viewscreen_choose_start_sitest, + df.viewscreen_setupdwarfgamest + } + local view = dfhack.gui.getCurViewscreen() + for _, valid in ipairs(embarkViewScreens) do + if view._type == valid then + return true + end end - end - return false + return false end local validArgs = utils.invert({ - 'depth', - 'atReclaim', - 'blockDemons', - 'clear', - 'help' + 'depth', + 'atReclaim', + 'blockDemons', + 'clear', + 'help' }) -local args = utils.processArgs({...}, validArgs) +local args = utils.processArgs({ ... }, validArgs) if moduleMode then - return + return end if args.help then - print(dfhack.script_help()) - return + print(dfhack.script_help()) + return end if args.clear then - dfhack.onStateChange.DeepEmbarkMonitor = nil - print("Cleared settings; now embarking normally.") - return + dfhack.onStateChange.DeepEmbarkMonitor = nil + print("Cleared settings; now embarking normally.") + return end if not args.depth then - qerror('Depth not specified! Enter "help deep-embark" for more information.') + qerror('Depth not specified! Enter "help deep-embark" for more information.') end local validDepths = { - ["CAVERN_1"] = true, - ["CAVERN_2"] = true, - ["CAVERN_3"] = true, - ["UNDERWORLD"] = true + ["CAVERN_1"] = true, + ["CAVERN_2"] = true, + ["CAVERN_3"] = true, + ["UNDERWORLD"] = true } if not validDepths[args.depth] then - qerror("Invalid depth: " .. args.depth) + qerror("Invalid depth: " .. args.depth) end local consoleMode = dfhack.is_interactive() -- true if the script has been called directly from the DFHack console, false if called from onLoad.init if consoleMode and not inEmbarkMode() then - -- if running from the console (not onLoad.init), abort if not currently in an embark viewscreen. - qerror('When run from the command line, this script should be run during the embark setup screens. Enter "help deep-embark" for more information.') + -- if running from the console (not onLoad.init), abort if not currently in an embark viewscreen. + qerror( + 'When run from the command line, this script should be run during the embark setup screens. Enter "help deep-embark" for more information.') end if consoleMode then - print("Embarking at: " .. tostring(args.depth)) + print("Embarking at: " .. tostring(args.depth)) end dfhack.onStateChange.DeepEmbarkMonitor = function(event) - if event == SC_VIEWSCREEN_CHANGED then -- I initially tried using SC_MAP_LOADED, but the map appears to be loaded too early when reclaiming sites - local view = dfhack.gui.getCurViewscreen() - if not consoleMode and not args.atReclaim and df.global.gametype == df.game_type.DWARF_RECLAIM then -- it's assumed that a player who chooses to run the script from console whilst reclaiming knows what they're doing, so there's no need to check for -atReclaim in this scenario - dfhack.onStateChange.DeepEmbarkMonitor = nil -- stop monitoring - return -- don't deepEmbark if running from onLoad.init and in reclaim mode without -atReclaim - elseif view._type == df.viewscreen_choose_start_sitest then -- on embark screen - if view.choosing_embark or view.choosing_reclaim then -- on a fresh embark, or on a reclaim - deepEmbark(args.depth, args.blockDemons) + if event == SC_VIEWSCREEN_CHANGED then -- I initially tried using SC_MAP_LOADED, but the map appears to be loaded too early when reclaiming sites + local view = dfhack.gui.getCurViewscreen() + if not consoleMode and not args.atReclaim and df.global.gametype == df.game_type.DWARF_RECLAIM then -- it's assumed that a player who chooses to run the script from console whilst reclaiming knows what they're doing, so there's no need to check for -atReclaim in this scenario + dfhack.onStateChange.DeepEmbarkMonitor = nil -- stop monitoring + return -- don't deepEmbark if running from onLoad.init and in reclaim mode without -atReclaim + elseif view._type == df.viewscreen_choose_start_sitest then -- on embark screen + if view.choosing_embark or view.choosing_reclaim then -- on a fresh embark, or on a reclaim + deepEmbark(args.depth, args.blockDemons) + dfhack.onStateChange.DeepEmbarkMonitor = nil + end + elseif view._type == df.viewscreen_dwarfmodest then -- we're in game. If we got here then we never got an embark screen, so this is loading a save and we abort. + dfhack.onStateChange.DeepEmbarkMonitor = nil + end + elseif event == SC_WORLD_UNLOADED then -- embark aborted dfhack.onStateChange.DeepEmbarkMonitor = nil - end - elseif view._type == df.viewscreen_dwarfmodest then -- we're in game. If we got here then we never got an embark screen, so this is loading a save and we abort. - dfhack.onStateChange.DeepEmbarkMonitor = nil end - elseif event == SC_WORLD_UNLOADED then -- embark aborted - dfhack.onStateChange.DeepEmbarkMonitor = nil - end end diff --git a/docs/exportlegends.rst b/docs/exportlegends.rst index 2d7c4702d6..5d262a853c 100644 --- a/docs/exportlegends.rst +++ b/docs/exportlegends.rst @@ -11,7 +11,10 @@ about your world so that it can be browsed with external programs like get with vanilla export functionality, and many external tools depend on this extra information. -By default, ``exportlegends`` hooks into the standard vanilla ``Export XML`` button and runs in the background when you click it, allowing both the vanilla export and the extended data export to execute simultaneously. You can continue to browse legends mode via the vanilla UI while the export is running. +By default, ``exportlegends`` hooks into the standard vanilla ``Export XML`` +button and runs in the background when you click it, allowing both the vanilla +export and the extended data export to execute simultaneously. You can continue +to browse legends mode via the vanilla UI while the export is running. To use: @@ -35,7 +38,11 @@ Usage Overlay ------- -This script also provides an overlay that is managed by the `overlay` framework. +This script also provides several overlays that are managed by the `overlay` +framework. + +**exportlegends.export** + When the overlay is enabled, a toggle for exporting extended legends data will appear below the vanilla "Export XML" button. If the toggle is enabled when the "Export XML" button is clicked, then ``exportlegends`` will run alongside the @@ -45,6 +52,13 @@ While the extended data is being exported, a status line will appear in place of the toggle, reporting the current export target and the overall percent complete. -There is an additional overlay that masks out the "Done" button while the -extended export is running. This prevents the player from exiting legends mode -before the export is complete. +**exportlegends.mask** + +This overlay masks out the "Done" button while the extended export is running. +This prevents the player from accidentally exiting legends mode before the +export is complete. + +**exportlegends.histfigfilter** + +This overlay adds a filter widget to the Historical Figures legends page. +Clicking the widget allows you to filter the list of historical figures by race. diff --git a/docs/exterminate.rst b/docs/exterminate.rst index 664019aec2..9ce66b9791 100644 --- a/docs/exterminate.rst +++ b/docs/exterminate.rst @@ -43,7 +43,7 @@ Options ``-m``, ``--method `` Specifies the "method" of killing units. See below for details. ``-o``, ``--only-visible`` - Specifies the tool should only kill units visible to the player. + Specifies the tool should only kill units visible to the player on the map. ``-f``, ``--include-friendly`` Specifies the tool should also kill units friendly to the player. diff --git a/docs/fix/dry-buckets.rst b/docs/fix/dry-buckets.rst index 311e3e6c2e..65740e309d 100644 --- a/docs/fix/dry-buckets.rst +++ b/docs/fix/dry-buckets.rst @@ -12,9 +12,14 @@ water from them. This tool also fixes over-full buckets that are blocking well operations. +If enabled in `gui/control-panel` (it is enabled by default), this fix is +periodically run automaticaly, so you should not normally need to run it +manually. + Usage ----- -:: - - fix/dry-buckets +``fix/dry-buckets`` + Empty water buckets not currently used in jobs. +``fix/dry-buckets -q``, ``fix/dry-buckets --quiet`` + Empty water buckets not currently used in jobs. Don't print to the console. diff --git a/docs/fix/loyaltycascade.rst b/docs/fix/loyaltycascade.rst index 7a1eaad620..39899aae3f 100644 --- a/docs/fix/loyaltycascade.rst +++ b/docs/fix/loyaltycascade.rst @@ -5,7 +5,7 @@ fix/loyaltycascade :summary: Halts loyalty cascades where dwarves are fighting dwarves. :tags: fort bugfix units -This tool aborts loyalty cascades by fixing units who consider their own +This tool neutralizes loyalty cascades by fixing units who consider their own civilization to be the enemy. Usage diff --git a/docs/fix/ownership.rst b/docs/fix/ownership.rst index 18ee5518a9..44c412db84 100644 --- a/docs/fix/ownership.rst +++ b/docs/fix/ownership.rst @@ -2,15 +2,20 @@ fix/ownership ============= .. dfhack-tool:: - :summary: Fixes instances of units claiming the same item or an item they don't own. - :tags: fort bugfix units + :summary: Fixes ownership links. + :tags: fort bugfix items units -Due to a bug a unit can believe they own an item when they actually do not. +Due to a bug, a unit can believe they own an item when they actually do not. +Additionally, a room can remember that it is owned by a unit, but the unit can +forget that they own the room. -When enabled in `gui/control-panel`, `fix/ownership` will run once a day to check citizens and residents and make sure they don't -mistakenly own an item they shouldn't. +Invalid item ownership links result in units getting stuck in a "Store owned +item" job. Missing room ownership links result in rooms becoming unused by the +nominal owner and unclaimable by any other unit. In particular, nobles and +administrators will not recognize that their room requirements are met. -This should help issues of units getting stuck in a "Store owned item" job. +When enabled in `gui/control-panel`, `fix/ownership` will run once a day to +validate and fix ownership links for items and rooms. Usage ----- @@ -18,3 +23,8 @@ Usage :: fix/ownership + +Links +----- + +Among other issues, this tool fixes :bug:`6578`. diff --git a/docs/fix/stuck-worship.rst b/docs/fix/stuck-worship.rst index b0dbfe07fe..04c4129364 100644 --- a/docs/fix/stuck-worship.rst +++ b/docs/fix/stuck-worship.rst @@ -28,4 +28,23 @@ Usage :: - fix/stuck-worship + fix/stuck-worship [] + +Reshuffle prayer needs of units in the fort. + +Examples +-------- + +``fix/stuck-worship`` + Rebalance prayer needs and print the total number of affected units. +``fix/stuck-worship -v`` + Same as above, but also print the names of all affected units. + +Options +------- + +``-v``, ``--verbose`` + Print the names of all affected units. +``-q``, ``--quiet`` + Don't print the number of affected units if it's zero. Intended for + automatic use. diff --git a/docs/fix/wildlife.rst b/docs/fix/wildlife.rst new file mode 100644 index 0000000000..648f1f4dde --- /dev/null +++ b/docs/fix/wildlife.rst @@ -0,0 +1,70 @@ +fix/wildlife +============ + +.. dfhack-tool:: + :summary: Moves stuck wildlife off the map so new waves can enter. + :tags: fort bugfix animals + +This tool identifies wildlife that is trying to leave the map but has gotten +stuck. The stuck creatures will be moved off the map so that new waves of +wildlife can enter. When removing stuck wildlife, their regional population +counters are correctly incremented, just as if they had successfully left the +map on their own. + +Dwarf Fortress manages wildlife in "waves". A small group of creatures of a +species that has population associated with a local region enters the map, +wanders around for a while (or aggressively attacks you if it is an agitated +group), and then leaves the map. Any members of the group that successfully +leave the map will get added back to the regional population. + +The trouble, though, is that the group sometimes gets stuck when attempting to +leave. A new wave cannot enter until the previous group has been destroyed or +has left the map, so wildlife activity effectively completely halts. This is DF +:bug:`12921`. + +You can run this script without parameters to immediately remove stuck +wildlife, or you can enable it in the `gui/control-panel` on the Bug Fixes tab +to monitor and manage wildlife in the background. When enabled from the control +panel, it will monitor for stuck wildlife and remove wildlife after it has been +stuck for 7 days. + +Unlike most bugfixes, this one is not enabled by default since some players +like to keep wildlife around for creative purposes (e.g. for intentionally +stalling wildlife waves or for controlled startling of friendly necromancers). +These players can selectively ignore the wildlife they want to keep captive +before they enable `fix/wildlife`. + +Usage +----- +:: + + fix/wildlife [] + fix/wildlife ignore [unit ID] + +Examples +-------- + +``fix/wildlife`` + Remove any wildlife that is currently trying to leave the map but has not + yet succeeded. +``fix/wildlife --week`` + Remove wildlife that has been stuck for at least a week. The command must + be run periodically with this option so it can discover newly stuck + wildlife and remove wildlife when timeouts expire. +``fix/wildlife ignore`` + Disconnect the selected unit from its wildlife population so it doesn't + block new wildlife from entering the map, but keep the unit on the map. + This unit will not be touched by future invocations of this tool. + +Options +------- + +``-n``, ``--dry-run`` + Print out which creatures are stuck but take no action. +``-w``, ``--week`` + Discover newly stuck units and associate the current in-game time with + them. Units that were discovered on a previous invocation where this + parameter was specified will be removed if that time was at least a week + ago. +``-q``, ``--quiet`` + Don't print the number of affected units if no units were affected. diff --git a/docs/force.rst b/docs/force.rst index 915863a75f..fb52a15803 100644 --- a/docs/force.rst +++ b/docs/force.rst @@ -15,6 +15,7 @@ Usage :: force [] + force Wildlife [all] The civ id is only used for ``Diplomat`` and ``Caravan`` events, and defaults to the player civilization if not specified. @@ -27,18 +28,36 @@ The default civ IDs that you are likely to be interested in are: But to see IDs for all civilizations in your current game, run this command:: - devel/query --table df.global.world.entities.all --search code --maxdepth 2 + :lua ids={} for _,en in ipairs(world.entities.all) do ids[en.entity_raw.code] = true end for id in pairs(ids) do print(id) end + +Examples +-------- + +``force Caravan`` + Spawn a caravan from your parent civilization. +``force Diplomat FOREST`` + Spawn an elven diplomat. +``force Megabeast`` + Call in a megabeast to attack your fort. The megabeast will enter the map + on the surface. +``force Wildlife`` + Allow additional wildlife to enter the map. Only affects areas that you can + see, so if you haven't opened the caverns, cavern wildlife won't be + affected. +``force Wildlife all`` + Allow additional wildlife to enter the map, even in areas you haven't + explored yet. Event types ----------- -The recognized event types are: +The supported event types are: - ``Caravan`` - ``Migrants`` - ``Diplomat`` - ``Megabeast`` -- ``WildlifeCurious`` -- ``WildlifeMischievous`` -- ``WildlifeFlier`` -- ``NightCreature`` +- ``Wildlife`` + +Most events happen on the next tick. The ``Wildlife`` event may take up to 100 +ticks to take effect. diff --git a/docs/gui/gm-editor.rst b/docs/gui/gm-editor.rst index 084b367ecf..e2a34a488b 100644 --- a/docs/gui/gm-editor.rst +++ b/docs/gui/gm-editor.rst @@ -16,9 +16,10 @@ Hold down :kbd:`Shift` and right click to exit, even if you are inspecting a substructure, no matter how deep. If you just want to browse without fear of accidentally changing anything, hit -:kbd:`Ctrl`:kbd:`D` to toggle read-only mode. If you want `gui/gm-editor` to -automatically pick up changes to game data in realtime, hit :kbd:`Alt`:kbd:`A` -to switch to auto update mode. +:kbd:`Ctrl`:kbd:`D` to toggle read-only mode. + +If you want `gui/gm-editor` to automatically pick up changes to game data in +realtime, hit :kbd:`Alt`:kbd:`A` to switch to auto update mode. .. warning:: @@ -28,18 +29,26 @@ to switch to auto update mode. your game before poking around in `gui/gm-editor`, especially if you are examining data while the game is unpaused. +.. warning:: + + Union data structures contain fields that occupy the same memory space. + When you see the ``[union structure]`` badge at the top of the screen, be + aware that only one of the fields in the structure is likely to make sense. + The "correct" field is usually indicated by some context in the parent + structure. If there are any pointers to substructures in the union, + inspecting the pointer when it is not the "correct" field may crash the + game. + Usage ----- -``gui/gm-editor [-f]`` - Open the editor on whatever is selected or viewed (e.g. unit/item/building/ - engraving/etc.) -``gui/gm-editor [-f] `` - Evaluate a lua expression and opens the editor on its results. Field - prefixes of ``df.global`` can be omitted. -``gui/gm-editor [-f] dialog`` - Show an in-game dialog to input the lua expression to evaluate. Works the - same as the version above. +:: + + gui/gm-editor [] [] + gui/gm-editor [] dialog + +When specifying a lua expression, field prefixes of ``df.global`` can be +omitted. Examples -------- @@ -48,15 +57,22 @@ Examples Opens the editor on the selected unit/item/job/workorder/stockpile etc. ``gui/gm-editor world.items.all`` Opens the editor on the items list. +``gui/gm-editor df.unit.find(12345)`` + Opens the editor on the unit with id 12345. +``gui/gm-editor reqscript('gui/quickfort').view`` + Opens the editor on a running instance of `gui/quickfort`. Useful for + debugging GUI tool state during development. ``gui/gm-editor --freeze scr`` Opens the editor on the current DF viewscreen data (bypassing any DFHack - layers) and prevents the underlying viewscreen from getting updates while - you have the editor open. + tools that may be open) and prevents the underlying viewscreen from getting + updates while you are inspecting the data. +``gui/gm-editor dialog`` + Show an in-game dialog to input the lua expression to evaluate. Options ------- -``-f``, ``--freeze`` +``-f``, ``--freeze``, ``--safe-mode`` Freeze the underlying viewscreen so that it does not receive any updates. This allows you to be sure that whatever you are inspecting or modifying will not be read or changed by the game until you are done with it. Note @@ -65,6 +81,12 @@ Options `gui/gm-editor` as usual when the game is frozen. The black background will disappear when the last `gui/gm-editor` window that was opened with the ``--freeze`` option is dismissed. +``--no-stringification`` + Don't attempt to provide helpful string representations of potentially + unsafe fields like language_name when browsing the data structures. Specify + this option when you know you will be browsing garbage data that could lead + to crashes if accessed for stringification. Note that fields in union data + structures are never stringified. Screenshot ---------- diff --git a/docs/gui/journal.rst b/docs/gui/journal.rst index d6004ec95d..a13bda3e67 100644 --- a/docs/gui/journal.rst +++ b/docs/gui/journal.rst @@ -8,47 +8,68 @@ gui/journal The `gui/journal` interface makes it easy to take notes and document important details for the fortresses. -With this multi-line text editor, -you can keep track of your fortress's background story, goals, notable events, -and both short-term and long-term plans. +With this multi-line text editor, you can keep track of your fortress's +background story, goals, notable events, and both short- and long-term plans. This is particularly useful when you need to take a longer break from the game. -Having detailed notes makes it much easier to resume your game after -a few weeks or months, without losing track of your progress and objectives. +Having detailed notes makes it much easier to resume your game after a few +weeks or months without losing track of your progress and objectives. Supported Features ------------------ -- Cursor Control: Navigate through text using arrow keys (left, right, up, down) for precise cursor placement. -- Fast Rewind: Use :kbd:`Ctrl` + :kbd:`Left` / :kbd:`Ctrl` + :kbd:`B` and :kbd:`Ctrl` + :kbd:`Right` / :kbd:`Ctrl` + :kbd:`F` to move the cursor one word back or forward. -- Longest X Position Memory: The cursor remembers the longest x position when moving up or down, making vertical navigation more intuitive. -- Mouse Control: Use the mouse to position the cursor within the text, providing an alternative to keyboard navigation. -- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting multiline text input. -- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit within the display without manual adjustments. -- Backspace Support: Use the backspace key to delete characters to the left of the cursor. -- Delete Character: :kbd:`Ctrl` + :kbd:`D` deletes the character under the cursor. -- Line Navigation: :kbd:`Ctrl` + :kbd:`H` (like "Home") moves the cursor to the beginning of the current line, and :kbd:`Ctrl` + :kbd:`E` (like "End") moves it to the end. -- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line where the cursor is located. -- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to the end of the line. -- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before the cursor. -- Text Selection: Select text with the mouse, with support for replacing or removing selected text. -- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the text using :kbd:`Shift` + :kbd:`Up` and :kbd:`Shift` + :kbd:`Down`. -- Select Word/Line: Use double click to select current word, or triple click to select current line +- Cursor Control: Navigate through text using arrow keys (Left, Right, Up, + and Down) for precise cursor placement. +- Fast Rewind: Use :kbd:`Ctrl` + :kbd:`Left` and :kbd:`Ctrl` + :kbd:`Right` to + move the cursor one word back or forward. +- Longest X Position Memory: The cursor remembers the longest x position when + moving up or down, making vertical navigation more intuitive. +- Mouse Control: Use the mouse to position the cursor within the text, + providing an alternative to keyboard navigation. +- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting + multiline text input. +- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit + within the display without manual adjustments. +- Backspace Support: Use the backspace key to delete characters to the left of + the cursor. +- Delete Character: :kbd:`Delete` deletes the character under the cursor. +- Line Navigation: :kbd:`Home` moves the cursor to the beginning of the current + line, and :kbd:`End` moves it to the end. +- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line + where the cursor is located. +- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to + the end of the line. +- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before + the cursor. +- Text Selection: Select text with the mouse, with support for replacing or + removing selected text. +- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the + text using :kbd:`Ctrl` + :kbd:`Home` and :kbd:`Ctrl` + :kbd:`End`. +- Select Word/Line: Use double click to select current word, or triple click to + select current line - Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A` -- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` + :kbd:`Y` -- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on selected text, allowing you to paste the copied content into other applications. +- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` + + :kbd:`Y` +- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on + selected text, allowing you to paste the copied content into other + applications. - Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text. - - copy selected text, if available - - If no text is selected it copy the entire current line, including the terminating newline if present. + - copy selected text, if available + - If no text is selected it copy the entire current line, including the + terminating newline if present. - Cut Text: Use :kbd:`Ctrl` + :kbd:`X` to cut selected text. - - cut selected text, if available - - If no text is selected it will cut the entire current line, including the terminating newline if present -- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into the editor. - - replace selected text, if available - - If no text is selected, paste text in the cursor position + - cut selected text, if available + - If no text is selected it will cut the entire current line, including the + terminating newline if present +- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into + the editor. + - replace selected text, if available + - If no text is selected, paste text in the cursor position - Scrolling behaviour for long text build-in -- Table of contents (:kbd:`Ctrl` + :kbd:`O`), with headers line prefixed by '#', e.g. '# Fort history', '## Year 1' -- Table of contents navigation: jump to previous/next section by :kbd:`Ctrl` + :kbd:`Up` / :kbd:`Ctrl` + :kbd:`Down` +- Table of contents (:kbd:`Ctrl` + :kbd:`O`), with headers line prefixed by + ``#``, e.g. ``# Fort history``, ``## Year 1`` +- Table of contents navigation: jump to previous/next section by :kbd:`Ctrl` + + :kbd:`Up` / :kbd:`Ctrl` + :kbd:`Down` Usage ----- diff --git a/docs/idle-crafting.rst b/docs/idle-crafting.rst index 39086443a9..b0377a6a75 100644 --- a/docs/idle-crafting.rst +++ b/docs/idle-crafting.rst @@ -45,9 +45,16 @@ needs to craft objects. Workshops that have a master assigned cannot be used in this way. When a workshop is designated for idle crafting, this tool will create crafting -jobs and assign them to idle dwarves who have a need for crafting -objects. Currently, bone carving and stonecrafting are supported, with -stonecrafting being the default option. This script respects the setting for -permitted general work orders from the "Workers" tab. Thus, to designate a +jobs and assign them to idle dwarves who have a need for crafting objects. This +script respects the setting for permitted general work order labors from the "Workers" +tab. + +For workshops without input stockpile links, bone carving and stonecrafting are +supported, with stonecrafting being the default option. Thus, to designate a workshop for bone carving, disable the stonecrafting labor while keeping the bone carving labor enabled. + +For workshops with input stockpile links, the creation of totems, shell crafts, +and horn crafts are supported as well. In this case, the choice of job is made +randomly based on the resources available in the input stockpiles (respecting +the permitted labors from the workshop profile). diff --git a/docs/modtools/force.rst b/docs/modtools/force.rst deleted file mode 100644 index 9f71183fa1..0000000000 --- a/docs/modtools/force.rst +++ /dev/null @@ -1,32 +0,0 @@ -modtools/force -============== - -.. dfhack-tool:: - :summary: Trigger game events. - :tags: dev - -This tool triggers events like megabeasts, caravans, and migrants. - -Usage ------ - -:: - - -eventType event - specify the type of the event to trigger - examples: - Megabeast - Migrants - Caravan - Diplomat - WildlifeCurious - WildlifeMischievous - WildlifeFlier - NightCreature - -civ entity - specify the civ of the event, if applicable - examples: - player - MOUNTAIN - EVIL - 28 diff --git a/docs/notes.rst b/docs/notes.rst new file mode 100644 index 0000000000..faed2c8fbf --- /dev/null +++ b/docs/notes.rst @@ -0,0 +1,42 @@ +notes +===== + +.. dfhack-tool:: + :summary: Manage map-specific notes. + :tags: fort interface map + +The `notes` tool enables players to annotate specific tiles +on the Dwarf Fortress game map with customizable notes. + +Each note is displayed as a green pin on the map and includes a one-line title and a detailed comment. + +It can be used to e.g.: + - marking plans for future constructions + - explaining mechanisms or traps + - noting historical events + +Usage +----- + +:: + + notes add + +Add new note in the current position of the keyboard cursor. + +Creating a Note +--------------- +1. Use the keyboard cursor to select the desired map tile where you want to place a note. +2. Execute ``notes add`` via the DFHack console. +3. In the pop-up dialog, fill in the note's title and detailed comment. +4. Press :kbd:`Alt` + :kbd:`S` to create the note. + +Editing or Deleting a Note +-------------------------- +- Click on the green pin representing the note directly on the map. +- A dialog will appear, offering options to edit the title or comment, or to delete the note entirely. + +Managing Notes Visibility +------------------------- +- Access the `gui/control-panel` / ``UI Overlays`` tab. +- Toggle the ``notes.map-notes`` overlay to show or hide the notes on the map. diff --git a/docs/position.rst b/docs/position.rst index 72ab716b5d..9d06d39618 100644 --- a/docs/position.rst +++ b/docs/position.rst @@ -3,11 +3,13 @@ position .. dfhack-tool:: :summary: Report cursor and mouse position, along with other info. - :tags: fort inspection map + :tags: adventure dfhack fort inspection map This tool reports the current date, clock time, month, season, and historical era. It also reports the keyboard cursor position (or just the z-level if no -active cursor), window size, and mouse location on the screen. +active cursor), window size, and mouse location on the screen. If a site is +loaded, it prints the world coordinates of the site. If not, it prints the world +coordinates of the adventurer (if applicable). Can also be used to copy the current keyboard cursor position for later use. diff --git a/docs/rejuvenate.rst b/docs/rejuvenate.rst index 754510921c..aaa2216ca3 100644 --- a/docs/rejuvenate.rst +++ b/docs/rejuvenate.rst @@ -6,8 +6,9 @@ rejuvenate :tags: fort armok units If your most valuable citizens are getting old, this tool can save them. It -decreases the age of the selected dwarf to 20 years, or to the age specified. -Age is only increased using the --force option. +decreases the age of the selected dwarf to the minimum adult age, or to the age +specified. Age can only be increased (e.g. when this tool is run on babies or +children) if the ``--force`` option is specified. Usage ----- @@ -20,11 +21,15 @@ Examples -------- ``rejuvenate`` - Set the age of the selected dwarf to 20 (if they're older). + Set the age of the selected dwarf to 18 (if they're older than 18). The + target age may be different if you have modded dwarves to become an adult + at a different age, or if you have selected a unit that is not a dwarf. ``rejuvenate --all`` - Set the age of all dwarves over 20 to 20. + Set the ages of all adult citizens and residents to their minimum adult + ages. ``rejuvenate --all --force`` - Set the age of all dwarves (including babies) to 20. + Set the ages of all citizens and residents (including children and babies) + to their minimum adult ages. ``rejuvenate --age 149 --force`` Set the age of the selected dwarf to 149, even if they are younger. @@ -32,11 +37,11 @@ Options ------- ``--all`` - Rejuvenate all citizens, not just the selected one. + Rejuvenate all citizens and residents instead of a selected unit. ``--age `` - Sets the target to the age specified. If this is not set, the target age is 20. + Sets the target to the age specified. If this is not set, the target age defaults to the minimum adult age for the unit. ``--force`` - Set age for units under the specified age to the specified age. Useful if there are too - many babies around... + Set age for units under the specified age to the specified age. Useful if + there are too many babies around... ``--dry-run`` Only list units that would be changed; don't actually change ages. diff --git a/exportlegends.lua b/exportlegends.lua index 131e415676..80a4ec2996 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -2,10 +2,11 @@ --luacheck-flags: strictsubtype --@ module=true -local gui = require('gui') -local overlay = require('plugins.overlay') +local asyncexport = reqscript('internal/exportlegends/asyncexport') +local racefilter = reqscript('internal/exportlegends/racefilter') local script = require('gui.script') -local widgets = require('gui.widgets') + +local GLOBAL_KEY = 'exportlegends' -- Get the date of the world as a string -- Format: "YYYYY-MM-DD" @@ -42,10 +43,7 @@ local function table_containskey(self, key) return false end -progress_item = progress_item or '' -num_done = num_done or -1 -num_total = num_total or -1 -last_update_ms = 0 +local last_update_ms = 0 -- should be frequent enough so that user can still effectively use -- the vanilla legends UI to browse while export is in progress @@ -62,12 +60,12 @@ end --luacheck: skip local function progress_ipairs(vector, desc, skip_count, interval) desc = desc or 'item' - progress_item = desc + asyncexport.progress_item = desc interval = interval or 10000 local cb = ipairs(vector) return function(vector, k, ...) if not skip_count then - num_done = num_done + 1 + asyncexport.num_done = asyncexport.num_done + 1 end if k then if #vector >= interval and (k % interval == 0 or k == #vector - 1) then @@ -80,7 +78,7 @@ local function progress_ipairs(vector, desc, skip_count, interval) end local function make_chunk(name, vector, fn) - num_total = num_total + #vector + asyncexport.num_total = asyncexport.num_total + #vector return { name=name, vector=vector, @@ -1016,113 +1014,32 @@ local function export_more_legends_xml() end local function wrap_export() - if num_total >= 0 then + if asyncexport.num_total >= 0 then qerror('exportlegends already in progress') end - num_total = 0 - num_done = 0 - progress_item = 'basic info' + asyncexport.num_total = 0 + asyncexport.num_done = 0 + asyncexport.progress_item = 'basic info' yield_if_timeout() local ok, err = pcall(export_more_legends_xml) if not ok then dfhack.printerr(err) end - num_total = -1 - num_done = -1 - progress_item = '' -end - --- ------------------- --- LegendsOverlay --- - -LegendsOverlay = defclass(LegendsOverlay, overlay.OverlayWidget) -LegendsOverlay.ATTRS{ - desc='Adds extended export progress bar to the legends main screen.', - default_pos={x=2, y=2}, - default_enabled=true, - viewscreens='legends/Default', - frame={w=55, h=5}, -} - -function LegendsOverlay:init() - self:addviews{ - widgets.Panel{ - view_id='button_mask', - frame={t=0, l=0, w=15, h=3}, - }, - widgets.BannerPanel{ - frame={b=0, l=0, r=0, h=1}, - subviews={ - widgets.ToggleHotkeyLabel{ - view_id='do_export', - frame={t=0, l=1, r=1}, - label='Also export DFHack extended legends data:', - key='CUSTOM_CTRL_D', - visible=function() return num_total < 0 end, - }, - widgets.Label{ - frame={t=0, l=1}, - text={ - 'Exporting ', - {width=27, text=function() return progress_item end}, - ' ', - {text=function() return ('%.2f'):format((num_done * 100) / num_total) end, pen=COLOR_YELLOW}, - '% complete' - }, - visible=function() return num_total >= 0 end, - }, - }, - }, - } -end - -function LegendsOverlay:onInput(keys) - if keys._MOUSE_L and num_total < 0 and - self.subviews.button_mask:getMousePos() and - self.subviews.do_export:getOptionValue() - then - script.start(wrap_export) - end - return LegendsOverlay.super.onInput(self, keys) + asyncexport.reset_state() end --- ------------------- --- DoneMaskOverlay --- - -DoneMaskOverlay = defclass(DoneMaskOverlay, overlay.OverlayWidget) -DoneMaskOverlay.ATTRS{ - desc='Prevents legends mode from being exited while an export is in progress.', - default_pos={x=-2, y=2}, - default_enabled=true, - viewscreens='legends', - frame={w=9, h=3}, +OVERLAY_WIDGETS = { + export=asyncexport.LegendsOverlay, + mask=asyncexport.DoneMaskOverlay, + histfigfilter=racefilter.RaceFilterOverlay, } -function DoneMaskOverlay:init() - self:addviews{ - widgets.Panel{ - frame_background=gui.CLEAR_PEN, - visible=function() return num_total >= 0 end, - } - } -end - -function DoneMaskOverlay:onInput(keys) - if num_total >= 0 then - if keys.LEAVESCREEN or (keys._MOUSE_L and self:getMousePos()) then - return true - end +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_VIEWSCREEN_CHANGED and df.viewscreen_choose_game_typest:is_instance(dfhack.gui.getDFViewscreen(true)) then + asyncexport.reset_state() end - return DoneMaskOverlay.super.onInput(self, keys) end -OVERLAY_WIDGETS = { - export=LegendsOverlay, - mask=DoneMaskOverlay, -} - if dfhack_flags.module then return end diff --git a/exterminate.lua b/exterminate.lua index 06b764eb98..0263fbe483 100644 --- a/exterminate.lua +++ b/exterminate.lua @@ -134,10 +134,23 @@ local function getMapRaces(opts) local map_races = {} for _, unit in pairs(df.global.world.units.active) do if not checkUnit(opts, unit) then goto continue end - local unit_race_name = dfhack.units.isUndead(unit) and "UNDEAD" or df.creature_raw.find(unit.race).creature_id - local race = ensure_key(map_races, unit_race_name) + local race_name, display_name + if dfhack.units.isUndead(unit) then + race_name = 'UNDEAD' + display_name = 'UNDEAD' + else + local craw = df.creature_raw.find(unit.race) + race_name = craw.creature_id + if race_name:match('^FORGOTTEN_BEAST_[0-9]+$') or race_name:match('^TITAN_[0-9]+$') then + display_name = dfhack.units.getReadableName(unit) + else + display_name = craw.name[0] + end + end + local race = ensure_key(map_races, race_name) race.id = unit.race - race.name = unit_race_name + race.name = race_name + race.display_name = display_name race.count = (race.count or 0) + 1 ::continue:: end @@ -187,14 +200,31 @@ local map_races = getMapRaces(options) if not positionals[1] or positionals[1] == 'list' then local sorted_races = {} - for race, value in pairs(map_races) do - table.insert(sorted_races, { name = race, count = value.count }) + local max_width = 10 + for _,v in pairs(map_races) do + max_width = math.max(max_width, #v.name) + table.insert(sorted_races, v) end table.sort(sorted_races, function(a, b) + if a.count == b.count then + local asuffix, bsuffix = a.name:match('([0-9]+)$'), b.name:match('([0-9]+)$') + if asuffix and bsuffix then + local aname, bname = a.name:match('(.*)_[0-9]+$'), b.name:match('(.*)_[0-9]+$') + local anum, bnum = tonumber(asuffix), tonumber(bsuffix) + if aname == bname and anum and bnum then + return anum < bnum + end + end + return a.name < b.name + end return a.count > b.count end) - for _, race in ipairs(sorted_races) do - print(([[%4s %s]]):format(race.count, race.name)) + for _,v in ipairs(sorted_races) do + local name_str = v.name + if name_str ~= 'UNDEAD' and v.display_name ~= string.lower(name_str):gsub('_', ' ') then + name_str = ('%-'..tostring(max_width)..'s (%s)'):format(name_str, v.display_name) + end + print(('%4s %s'):format(v.count, name_str)) end return end diff --git a/fix/dry-buckets.lua b/fix/dry-buckets.lua index 27834abb82..71d2c50468 100644 --- a/fix/dry-buckets.lua +++ b/fix/dry-buckets.lua @@ -1,35 +1,44 @@ local argparse = require("argparse") -local quiet = false - -local emptied = 0 -local in_building = 0 local water_type = dfhack.matinfo.find('WATER').type +local quiet = false argparse.processArgsGetopt({...}, { {'q', 'quiet', handler=function() quiet = true end}, }) -for _,item in ipairs(df.global.world.items.other.IN_PLAY) do - local container = dfhack.items.getContainer(item) - if container - and container:getType() == df.item_type.BUCKET - and not (container.flags.in_job) - and item:getMaterial() == water_type - and item:getType() == df.item_type.LIQUID_MISC - and not (item.flags.in_job) - then - if container.flags.in_building or item.flags.in_building then - in_building = in_building + 1 +local emptied = 0 +local in_building = 0 +for _,item in ipairs(df.global.world.items.other.BUCKET) do + if item.flags.in_job then goto continue end + local emptied_bucket = false + local freed_in_building = false + for _,contained_item in ipairs(dfhack.items.getContainedItems(item)) do + if not contained_item.flags.in_job and + contained_item:getMaterial() == water_type and + contained_item:getType() == df.item_type.LIQUID_MISC + then + if item.flags.in_building or contained_item.flags.in_building then + freed_in_building = true + end + -- ok to remove item while iterating since we're iterating through copy of the vector + dfhack.items.remove(contained_item) + emptied_bucket = true end - dfhack.items.remove(item) + end + if emptied_bucket then emptied = emptied + 1 + df.global.plotinfo.flags.recheck_aid_requests = true + end + if freed_in_building then + in_building = in_building + 1 end + ::continue:: end if not quiet then - print('Emptied '..emptied..' buckets.') - if emptied > 0 then + print(('Emptied %d buckets.'):format(emptied)) + if in_building > 0 then print(('Unclogged %d wells.'):format(in_building)) end end diff --git a/fix/loyaltycascade.lua b/fix/loyaltycascade.lua index a3b7090880..b6ae0515ef 100644 --- a/fix/loyaltycascade.lua +++ b/fix/loyaltycascade.lua @@ -1,5 +1,7 @@ -- Prevents a "loyalty cascade" (intra-fort civil war) when a citizen is killed. +local makeown = reqscript('makeown') + -- Checks if a unit is a former member of a given entity as well as it's -- current enemy. local function getUnitRenegade(unit, entity_id) @@ -14,9 +16,9 @@ local function getUnitRenegade(unit, entity_id) goto skipentity end - if link_type == df.histfig_entity_link_type.FORMER_MEMBER then + if link_type == df.histfig_entity_link_type.FORMER_MEMBER then former_index = index - elseif link_type == df.histfig_entity_link_type.ENEMY then + elseif link_type == df.histfig_entity_link_type.ENEMY then enemy_index = index end @@ -42,11 +44,7 @@ end local function fixUnit(unit) local fixed = false - if not dfhack.units.isOwnCiv(unit) or not dfhack.units.isDwarf(unit) then - return fixed - end - - local unit_name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + local unit_name = dfhack.units.getReadableName(unit) local former_civ_index, enemy_civ_index = getUnitRenegade(unit, df.global.plotinfo.civ_id) local former_group_index, enemy_group_index = getUnitRenegade(unit, df.global.plotinfo.group_id) @@ -57,7 +55,8 @@ local function fixUnit(unit) convertUnit(unit, df.global.plotinfo.civ_id, former_civ_index, enemy_civ_index) - dfhack.gui.showAnnouncement(('loyaltycascade: %s is now a member of %s again'):format(unit_name, civ_name), COLOR_WHITE) + dfhack.gui.showAnnouncement( + ('loyaltycascade: %s is now a happy member of %s again'):format(unit_name, civ_name), COLOR_WHITE) fixed = true end @@ -67,31 +66,14 @@ local function fixUnit(unit) convertUnit(unit, df.global.plotinfo.group_id, former_group_index, enemy_group_index) - dfhack.gui.showAnnouncement(('loyaltycascade: %s is now a member of %s again'):format(unit_name, group_name), COLOR_WHITE) + dfhack.gui.showAnnouncement( + ('loyaltycascade: %s is now a happy member of %s again'):format(unit_name, group_name), COLOR_WHITE) fixed = true end - if fixed and unit.enemy.enemy_status_slot ~= -1 then - local status_cache = df.global.world.enemy_status_cache - local status_slot = unit.enemy.enemy_status_slot - - unit.enemy.enemy_status_slot = -1 - status_cache.slot_used[status_slot] = false - - for index, _ in pairs(status_cache.rel_map[status_slot]) do - status_cache.rel_map[status_slot][index] = -1 - end - - for index, _ in pairs(status_cache.rel_map) do - status_cache.rel_map[index][status_slot] = -1 - end - - -- TODO: what if there were status slots taken above status_slot? - -- does everything need to be moved down by one? - if status_cache.next_slot > status_slot then - status_cache.next_slot = status_slot - end + if fixed then + makeown.clear_enemy_status(unit) end return false diff --git a/fix/ownership.lua b/fix/ownership.lua index b21b818a7e..c98dd42476 100644 --- a/fix/ownership.lua +++ b/fix/ownership.lua @@ -1,24 +1,51 @@ +local utils = require('utils') + -- unit thinks they own the item but the item doesn't hold the proper -- ref that actually makes this true -local function owner_not_recognized() +local function clean_item_ownership() for _,unit in ipairs(dfhack.units.getCitizens()) do for index = #unit.owned_items-1, 0, -1 do - local item = df.item.find(unit.owned_items[index]) - if not item then goto continue end - - for _, ref in ipairs(item.general_refs) do - if df.general_ref_unit_itemownerst:is_instance(ref) then - -- make sure the ref belongs to unit - if ref.unit_id == unit.id then goto continue end + local item_id = unit.owned_items[index] + local item = df.item.find(item_id) + if item then + for _, ref in ipairs(item.general_refs) do + if df.general_ref_unit_itemownerst:is_instance(ref) then + -- make sure the ref belongs to unit + if ref.unit_id == unit.id then goto continue end + end end end - print('Erasing ' .. dfhack.TranslateName(unit.name) .. ' invalid claim on item #' .. item.id) + print(('fix/ownership: Erasing invalid claim on item #%d for %s'):format( + item_id, dfhack.df2console(dfhack.units.getReadableName(unit)))) unit.owned_items:erase(index) ::continue:: end end end +local other = df.global.world.buildings.other +local zone_vecs = { + other.ZONE_BEDROOM, + other.ZONE_OFFICE, + other.ZONE_DINING_HALL, + other.ZONE_TOMB, +} +local function relink_zones() + for _,zones in ipairs(zone_vecs) do + for _,zone in ipairs(zones) do + local unit = zone.assigned_unit + if not unit then goto continue end + if not utils.linear_index(unit.owned_buildings, zone.id, 'id') then + print(('fix/ownership: Restoring %s ownership link for %s'):format( + df.civzone_type[zone:getSubtype()], dfhack.df2console(dfhack.units.getReadableName(unit)))) + dfhack.buildings.setOwner(zone, nil) + dfhack.buildings.setOwner(zone, unit) + end + ::continue:: + end + end +end + local args = {...} if args[1] == "help" then @@ -26,4 +53,5 @@ if args[1] == "help" then return end -owner_not_recognized() +clean_item_ownership() +relink_zones() diff --git a/fix/stuck-worship.lua b/fix/stuck-worship.lua index 0781181f5f..2d48809141 100644 --- a/fix/stuck-worship.lua +++ b/fix/stuck-worship.lua @@ -1,3 +1,11 @@ +local argparse = require('argparse') + +local verbose, quiet = false, false +argparse.processArgsGetopt({...}, { + {'v', 'verbose', handler=function() verbose = true end}, + {'q', 'quiet', handler=function() quiet = true end}, +}) + local function for_pray_need(needs, fn) for idx, need in ipairs(needs) do if need.id == df.need_type.PrayOrMeditate then @@ -79,6 +87,11 @@ local function get_prayer_targets(unit) end end +local function unit_name(unit) + return dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) +end + +local count = 0 for _,unit in ipairs(dfhack.units.getCitizens(false, true)) do local prayer_targets = get_prayer_targets(unit) if not unit.status.current_soul or not prayer_targets then @@ -86,8 +99,14 @@ for _,unit in ipairs(dfhack.units.getCitizens(false, true)) do end local needs = unit.status.current_soul.personality.needs if shuffle_prayer_needs(needs, prayer_targets) then - print('rebalanced prayer needs for ' .. - dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) + count = count + 1 + if verbose then + print('Shuffled prayer target for '..unit_name(unit)) + end end ::next_unit:: end + +if not quiet or count > 0 then + print(('Rebalanced prayer needs for %d units.'):format(count)) +end diff --git a/fix/wildlife.lua b/fix/wildlife.lua new file mode 100644 index 0000000000..bd80f2433b --- /dev/null +++ b/fix/wildlife.lua @@ -0,0 +1,189 @@ +--@module = true + +local argparse = require('argparse') +local exterminate = reqscript('exterminate') + +local GLOBAL_KEY = 'fix/wildlife' + +DEBUG = DEBUG or false + +stuck_creatures = stuck_creatures or {} + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if (sc == SC_MAP_UNLOADED or sc == SC_MAP_LOADED) and + dfhack.world.isFortressMode() + then + stuck_creatures = {} + end +end + +local function print_summary(opts, unstuck) + if not next(unstuck) then + if not opts.quiet then + print('No stuck wildlife found') + return + end + end + local prefix = opts.week and (GLOBAL_KEY .. ': ') or '' + local msg_txt = opts.dry_run and '' or 'no longer ' + for _,entry in pairs(unstuck) do + if entry.count == 1 then + print(('%s%d %s is %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceReadableNameById(entry.race) or 'hidden creature', + msg_txt)) + else + print(('%s%d %s are %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceNamePluralById(entry.race) or 'hidden creatures', + msg_txt)) + end + end +end + +local function refund_population(entry) + local epop = entry.pop + for _,population in ipairs(df.global.world.populations) do + local wpop = population.population + if population.quantity < 10000001 and + wpop.region_x == epop.region_x and + wpop.region_y == epop.region_y and + wpop.feature_idx == epop.feature_idx and + wpop.cave_id == epop.cave_id and + wpop.site_id == epop.site_id and + wpop.population_idx == epop.population_idx + then + population.quantity = math.min(population.quantity + entry.count, population.quantity_max) + break + end + end +end + +-- refund unit to population and ensure it doesn't get picked up by unstick_surface_wildlife in the future +local function detach_unit(unit) + unit.flags2.roaming_wilderness_population_source = false + unit.flags2.roaming_wilderness_population_source_not_a_map_feature = false + refund_population{race=unit.race, pop=unit.animal.population, known=true, count=1} +end + +local TICKS_PER_DAY = 1200 +local TICKS_PER_WEEK = TICKS_PER_DAY * 7 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_SEASON = 3 * TICKS_PER_MONTH +local TICKS_PER_YEAR = 4 * TICKS_PER_SEASON + +local WEEK_BEFORE_EOY_TICKS = TICKS_PER_YEAR - TICKS_PER_WEEK + +-- update stuck_creatures records and check timeout +-- we only enter this function if the unit's leave_countdown has already expired +-- returns true if the unit has timed out +local function check_timeout(opts, unit, week_ago_ticks) + if not opts.week then return true end + if not stuck_creatures[unit.id] then + stuck_creatures[unit.id] = df.global.cur_year_tick + return false + end + local timestamp = stuck_creatures[unit.id] + return timestamp < week_ago_ticks or + (timestamp > df.global.cur_year_tick and timestamp > WEEK_BEFORE_EOY_TICKS) +end + +local function to_key(pop) + return ('%d:%d:%d:%d:%d:%d'):format( + pop.region_x, pop.region_y, pop.feature_idx, pop.cave_id, pop.site_id, pop.population_idx) +end + +local function is_active_wildlife(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isWildlife(unit) and + unit.flags2.roaming_wilderness_population_source +end + +-- called by force for the "Wildlife" event +function free_all_wildlife(include_hidden) + for _,unit in ipairs(df.global.world.units.active) do + if is_active_wildlife(unit) and + (include_hidden or not dfhack.units.isHidden(unit)) + then + detach_unit(unit) + end + end +end + +local function unstick_surface_wildlife(opts) + local unstuck = {} + local week_ago_ticks = math.max(0, df.global.cur_year_tick - TICKS_PER_WEEK) + for _,unit in ipairs(df.global.world.units.active) do + if not is_active_wildlife(unit) or unit.animal.leave_countdown > 0 then + goto skip + end + if not check_timeout(opts, unit, week_ago_ticks) then + goto skip + end + local pop = unit.animal.population + local unstuck_entry = ensure_key(unstuck, to_key(pop), {race=unit.race, pop=pop, known=false, count=0}) + unstuck_entry.known = unstuck_entry.known or not dfhack.units.isHidden(unit) + unstuck_entry.count = unstuck_entry.count + 1 + if not opts.dry_run then + stuck_creatures[unit.id] = nil + exterminate.killUnit(unit, exterminate.killMethod.DISINTEGRATE) + end + ::skip:: + end + for _,entry in pairs(unstuck) do + refund_population(entry) + end + print_summary(opts, unstuck) +end + +if dfhack_flags.module then + return +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map to work') +end + +local opts = { + dry_run=false, + help=false, + quiet=false, + week=false, +} + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler = function() opts.help = true end}, + {'n', 'dry-run', handler = function() opts.dry_run = true end}, + {'w', 'week', handler = function() opts.week = true end}, + {'q', 'quiet', handler = function() opts.quiet = true end}, +}) + +if positionals[1] == 'help' or opts.help then + print(dfhack.script_help()) + return +end + +if positionals[1] == 'ignore' then + local unit + local unit_id = positionals[2] and argparse.nonnegativeInt(positionals[2], 'unit_id') + if unit_id then + unit = df.unit.find(unit_id) + else + unit = dfhack.gui.getSelectedUnit(true) + end + if not unit then + qerror('please select a unit or pass a unit ID on the commandline') + end + if not is_active_wildlife(unit) then + qerror('selected unit is not blocking new waves of wildlife; nothing to do') + end + detach_unit(unit) + if not opts.quiet then + print(('%s will now be ignored by fix/wildlife'):format(dfhack.units.getReadableName(unit))) + end +else + unstick_surface_wildlife(opts) +end diff --git a/force.lua b/force.lua index 9360ca8b95..afb3c4febd 100644 --- a/force.lua +++ b/force.lua @@ -1,30 +1,58 @@ --- Forces an event (wrapper for modtools/force) +local wildlife = reqscript('fix/wildlife') -local utils = require 'utils' -local args = {...} +local function findCiv(civ) + if civ == 'player' then return df.historical_entity.find(df.global.plotinfo.civ_id) end + if tonumber(civ) then return df.historical_entity.find(tonumber(civ)) end + civ = string.lower(tostring(civ)) + for _,entity in ipairs(df.global.world.entities.all) do + if string.lower(entity.entity_raw.code) == civ then return entity end + end +end + +local args = { ... } if #args < 1 then qerror('missing event type') end if args[1]:find('help') then print(dfhack.script_help()) return end -local eventType = nil + +local eventType = args[1]:upper() + +-- handle synthetic events +if eventType == 'WILDLIFE' then + wildlife.free_all_wildlife(args[2] == 'all') + return +end + +-- handle native events for _, type in ipairs(df.timed_event_type) do if type:lower() == args[1]:lower() then eventType = type end end -if not eventType then +if not df.timed_event_type[eventType] then qerror('unknown event type: ' .. args[1]) end +if eventType == 'FeatureAttack' then + qerror('Event type: FeatureAttack is not currently supported') +end + +local civ -local newArgs = {'--eventType', eventType} if eventType == 'Caravan' or eventType == 'Diplomat' then - table.insert(newArgs, '--civ') - if not args[2] then - table.insert(newArgs, 'player') - else - table.insert(newArgs, args[2]) + civ = findCiv(args[2] or 'player') + if not civ then + qerror('unable to find civilization: '..tostring(civ)) end +elseif eventType == 'Migrants' then + civ = findCiv('player') end -dfhack.run_script('modtools/force', table.unpack(newArgs)) +df.global.timed_events:insert('#', { + new=true, + type=df.timed_event_type[eventType], + season=df.global.cur_season, + season_ticks=df.global.cur_season_tick, + entity=civ, + feature_ind=-1, +}) diff --git a/gui/civ-alert.lua b/gui/civ-alert.lua index 29ee4cc561..37bec014de 100644 --- a/gui/civ-alert.lua +++ b/gui/civ-alert.lua @@ -118,14 +118,17 @@ CivalertOverlay.ATTRS{ frame={w=20, h=5}, } +local function is_squads_panel_open() + return dfhack.gui.matchFocusString('dwarfmode/Squads/Default', + dfhack.gui.getDFViewscreen(true)) +end + local function should_show_alert_button() - return can_clear_alarm() or - (df.global.game.main_interface.squads.open and can_sound_alarm()) + return can_clear_alarm() or (is_squads_panel_open() and can_sound_alarm()) end local function should_show_configure_button() - return df.global.game.main_interface.squads.open - and not can_sound_alarm() and not can_clear_alarm() + return is_squads_panel_open() and not can_sound_alarm() and not can_clear_alarm() end local function launch_config() diff --git a/gui/gm-editor.lua b/gui/gm-editor.lua index 9ec7cdd2ce..6d37f96c5f 100644 --- a/gui/gm-editor.lua +++ b/gui/gm-editor.lua @@ -1,12 +1,13 @@ -- Interface powered memory object editor. --@module=true -local gui = require 'gui' -local json = require 'json' -local dialog = require 'gui.dialogs' -local widgets = require 'gui.widgets' -local guiScript = require 'gui.script' -local utils = require 'utils' +local argparse = require('argparse') +local gui = require('gui') +local json = require('json') +local dialog = require('gui.dialogs') +local widgets = require('gui.widgets') +local guiScript = require('gui.script') +local utils = require('utils') config = config or json.open('dfhack-config/gm-editor.json') @@ -124,7 +125,8 @@ GmEditorUi.ATTRS{ frame_inset=0, resizable=true, resize_min=RESIZE_MIN, - read_only=(config.data.read_only or false) + read_only=(config.data.read_only or false), + helpers=true, } function burning_red(input) -- todo does not work! bug angavrilov that so that he would add this, very important!! @@ -183,7 +185,7 @@ function GmEditorUi:init(args) local mainPage=widgets.Panel{ subviews={ mainList, - widgets.Label{text={{text="",id="name"},{gap=1,text="Help",key=keybindings.help.key,key_sep = '()'}}, view_id = 'lbl_current_item',frame = {l=1,t=1,yalign=0}}, + widgets.Label{text={{text="",id="name"},{text="",pen=COLOR_CYAN,id="union"},{gap=1,text="Help",key=keybindings.help.key,key_sep = '()'}}, view_id = 'lbl_current_item',frame = {l=1,t=1,yalign=0}}, widgets.EditField{frame={l=1,t=2,h=1},label_text="Search",key=keybindings.start_filter.key,key_sep='(): ',on_change=self:callback('text_input'),view_id="filter_input"}} ,view_id='page_main'} @@ -621,27 +623,32 @@ function GmEditorUi:onInput(keys) end end -function getStringValue(trg,field) - local obj=trg.target +function GmEditorUi:getStringValue(trg, field) + local obj = trg.target local text=tostring(obj[field]) pcall(function() - if obj._field ~= nil then + if obj._field == nil then return end local f = obj:_field(field) - if df.coord:is_instance(f) then - text=('(%d, %d, %d) '):format(f.x, f.y, f.z) .. text - elseif df.coord2d:is_instance(f) then - text=('(%d, %d) '):format(f.x, f.y) .. text + if self.helpers and not obj._type._union then + if df.coord:is_instance(f) then + text=('(%d, %d, %d) %s'):format(f.x, f.y, f.z, text) + elseif df.coord2d:is_instance(f) then + text=('(%d, %d) %s'):format(f.x, f.y, text) + elseif df.language_name:is_instance(f) then + text=('%s (%s) %s'):format(dfhack.TranslateName(f, false), dfhack.TranslateName(f, true), text) + end end - local enum=f._type + local enum = f._type if enum._kind=="enum-type" then text=text.." ("..tostring(enum[obj[field]])..")" end + -- this will throw for types that have no ref target; pcall will catch it, but make sure this bit stays + -- at the end of the pcall function body local ref_target=f.ref_target if ref_target then text=text.. " (ref-target: "..getmetatable(ref_target)..")" end - end end) return text end @@ -681,10 +688,11 @@ function GmEditorUi:updateTarget(preserve_pos,reindex) end end end + self.subviews.lbl_current_item:itemById('union').text = type(trg.target) == 'userdata' and trg.target._type._union and " [union structure]" or "" self.subviews.lbl_current_item:itemById('name').text=tostring(trg.target) local t={} for k,v in pairs(trg.keys) do - table.insert(t,{text={{text=string.format("%-"..trg.kw.."s",tostring(v))},{gap=2,text=getStringValue(trg,v)}}}) + table.insert(t,{text={{text=string.format("%-"..trg.kw.."s",tostring(v))},{gap=2,text=self:getStringValue(trg,v)}}}) end local last_selected, last_top if preserve_pos then @@ -824,7 +832,7 @@ function GmScreen:init(args) end end end - self:addviews{GmEditorUi{target=target}} + self:addviews{GmEditorUi{target=target, helpers=args.helpers}} views[self] = true end @@ -838,28 +846,26 @@ function GmScreen:onDismiss() end local function get_editor(args) - local freeze = false - if args[1] == '-f' or args[1] == '--freeze' then - freeze = true - table.remove(args, 1) - end - if #args~=0 then - if args[1]=="dialog" then - dialog.showInputPrompt("Gm Editor", "Object to edit:", COLOR_GRAY, + local freeze, helpers = false, true + local positionals = argparse.processArgsGetopt(args, { + {'f', 'freeze', 'safe-mode', handler=function() freeze = true end}, + {nil, 'no-stringification', handler=function() helpers = false end}, + }) + if #positionals == 0 then + GmScreen{freeze=freeze, helpers=helpers, target=getTargetFromScreens()}:show() + else + if positionals[1]=="dialog" then + dialog.showInputPrompt("GM Editor", "Object to edit:", COLOR_GRAY, "", function(entry) - GmScreen{freeze=freeze, target=eval(entry)}:show() + GmScreen{freeze=freeze, helpers=helpers, target=eval(entry)}:show() end) - elseif args[1]=="free" then - GmScreen{freeze=freeze, target=df.reinterpret_cast(df[args[2]],args[3])}:show() - elseif args[1]=="scr" then + elseif positionals[1]=="scr" then -- this will not work for more complicated expressions, like scr.fieldname, but -- it should capture the most common case - GmScreen{freeze=freeze, target=dfhack.gui.getDFViewscreen(true)}:show() + GmScreen{freeze=freeze, helpers=helpers, target=dfhack.gui.getDFViewscreen(true)}:show() else - GmScreen{freeze=freeze, target=eval(args[1])}:show() + GmScreen{freeze=freeze, helpers=helpers, target=eval(positionals[1])}:show() end - else - GmScreen{freeze=freeze, target=getTargetFromScreens()}:show() end end diff --git a/gui/journal.lua b/gui/journal.lua index cfb6f234bc..d14468129e 100644 --- a/gui/journal.lua +++ b/gui/journal.lua @@ -123,6 +123,7 @@ function JournalWindow:init() on_text_change=self:callback('onTextChange'), on_cursor_change=self:callback('onCursorChange'), }, + widgets.HelpButton{command="gui/journal", frame={r=0,t=1}}, widgets.Panel{ frame={l=0,r=0,b=1,h=1}, frame_inset={l=1,r=1,t=0, w=100}, diff --git a/gui/quickfort.lua b/gui/quickfort.lua index eae4c701fd..aff6824e00 100644 --- a/gui/quickfort.lua +++ b/gui/quickfort.lua @@ -314,7 +314,7 @@ function Quickfort:init() widgets.ResizingPanel{autoarrange_subviews=true, subviews={ widgets.Label{text='Current blueprint:'}, widgets.WrappedLabel{ - text_pen=COLOR_GREY, + text_pen=COLOR_CYAN, text_to_wrap=self:callback('get_blueprint_name')} }}, widgets.ResizingPanel{autoarrange_subviews=true, subviews={ diff --git a/idle-crafting.lua b/idle-crafting.lua index 4f74ae7249..f78e667631 100644 --- a/idle-crafting.lua +++ b/idle-crafting.lua @@ -4,6 +4,56 @@ local overlay = require('plugins.overlay') local widgets = require('gui.widgets') local repeatutil = require("repeat-util") +local orders = require('plugins.orders') + +---iterate over input materials of workshop with stockpile links +---@param workshop df.building_workshopst +---@param action fun(item:df.item):any +local function for_inputs(workshop, action) + if #workshop.profile.links.take_from_pile == 0 then + dfhack.error('workshop has no links') + else + for _, stockpile in ipairs(workshop.profile.links.take_from_pile) do + for _, item in ipairs(dfhack.buildings.getStockpileContents(stockpile)) do + if item:isAssignedToThisStockpile(stockpile.id) then + for _, contained_item in ipairs(dfhack.items.getContainedItems(item)) do + action(contained_item) + end + else + action(item) + end + end + end + for _, contained_item in ipairs(workshop.contained_items) do + if contained_item.use_mode == df.building_item_role_type.TEMP then + action(contained_item.item) + end + end + end +end + +---choose random value based on positive integer weights +---@generic T +---@param choices table +---@return T +function weightedChoice(choices) + local sum = 0 + for _, weight in pairs(choices) do + sum = sum + weight + end + if sum <= 0 then + return nil + end + local random = math.random(sum) + for choice, weight in pairs(choices) do + if random > weight then + random = random - weight + else + return choice + end + end + return nil --never reached on well-formed input +end ---create a new linked job ---@return df.job @@ -13,6 +63,61 @@ function make_job() return job end +function assignToWorkshop(job, workshop) + job.pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z) + dfhack.job.addGeneralRef(job, df.general_ref_type.BUILDING_HOLDER, workshop.id) + workshop.jobs:insert("#", job) +end + +---make totem at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeTotem(unit, workshop) + local job = make_job() + job.job_type = df.job_type.MakeTotem + job.mat_type = -1 + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE --the game seems to leave this uninitialized + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.totemable = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make totem at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeHornCrafts(unit, workshop) + local job = make_job() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category.horn = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE --the game seems to leave this uninitialized + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.horn = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + ---make bone crafts at specified workshop ---@param unit df.unit ---@param workshop df.building_workshopst @@ -22,7 +127,6 @@ function makeBoneCraft(unit, workshop) job.job_type = df.job_type.MakeCrafts job.mat_type = -1 job.material_category.bone = true - job.pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z) local jitem = df.job_item:new() jitem.item_type = df.item_type.NONE @@ -35,8 +139,32 @@ function makeBoneCraft(unit, workshop) jitem.flags2.body_part = true job.job_items.elements:insert('#', jitem) - dfhack.job.addGeneralRef(job, df.general_ref_type.BUILDING_HOLDER, workshop.id) - workshop.jobs:insert("#", job) + assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make shell crafts at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeShellCraft(unit, workshop) + local job = make_job() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category.shell = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.shell = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + assignToWorkshop(job, workshop) return dfhack.job.addWorker(job, unit) end @@ -48,7 +176,6 @@ function makeRockCraft(unit, workshop) local job = make_job() job.job_type = df.job_type.MakeCrafts job.mat_type = 0 - job.pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z) local jitem = df.job_item:new() jitem.item_type = df.item_type.BOULDER @@ -60,12 +187,29 @@ function makeRockCraft(unit, workshop) jitem.flags3.hard = true job.job_items.elements:insert('#', jitem) - dfhack.job.addGeneralRef(job, df.general_ref_type.BUILDING_HOLDER, workshop.id) - workshop.jobs:insert("#", job) - + assignToWorkshop(job, workshop) return dfhack.job.addWorker(job, unit) end +---categorize and count crafting materials (for Craftsdwarf's workshop) +---@param tab table +---@param item df.item +local function categorize_craft(tab,item) + if df.item_corpsepiecest:is_instance(item) then + if item.corpse_flags.bone then + tab['bone'] = (tab['bone'] or 0) + item.material_amount.Bone + elseif item.corpse_flags.skull then + tab['skull'] = (tab['skull'] or 0) + 1 + elseif item.corpse_flags.horn then + tab['horn'] = (tab['horn'] or 0) + item.material_amount.Horn + elseif item.corpse_flags.shell then + tab['shell'] = (tab['shell'] or 0) + 1 + end + elseif df.item_boulderst:is_instance(item) then + tab['boulder'] = (tab['boulder'] or 0) + 1 + end +end + -- script logic local GLOBAL_KEY = 'idle-crafting' @@ -179,6 +323,34 @@ function unitIsAvailable(unit) return true end +---select crafting job based on available resources +---@param workshop df.building_workshopst +---@return (fun(unit:df.unit, workshop:df.building_workshopst):boolean)? +function select_crafting_job(workshop) + local tab = {} + for_inputs(workshop, curry(categorize_craft,tab)) + local blocked_labors = workshop.profile.blocked_labors + if blocked_labors[STONE_CRAFT] then + tab['boulder'] = nil + end + if blocked_labors[BONE_CARVE] then + tab['bone'] = nil + tab['skull'] = nil + tab['horn'] = nil + tab['shell'] = nil + end + local material = weightedChoice(tab) + if material == 'bone' then return makeBoneCraft + elseif material == 'skull' then return makeTotem + elseif material == 'horn' then return makeHornCrafts + elseif material == 'shell' then return makeShellCraft + elseif material == 'boulder' then return makeRockCraft + else + return nil + end +end + + ---check if unit is ready and try to create a crafting job for it ---@param workshop df.building_workshopst ---@param idx integer "index of the unit's group" @@ -199,19 +371,31 @@ local function processUnit(workshop, idx, unit_id) end -- We have an available unit local success = false - if workshop.profile.blocked_labors[STONE_CRAFT] == false then - success = makeRockCraft(unit, workshop) - end - if not success and workshop.profile.blocked_labors[BONE_CARVE] == false then - success = makeBoneCraft(unit, workshop) + if #workshop.profile.links.take_from_pile == 0 then + -- can we do something smarter here? + if workshop.profile.blocked_labors[STONE_CRAFT] == false then + success = makeRockCraft(unit, workshop) + end + if not success and workshop.profile.blocked_labors[BONE_CARVE] == false then + success = makeBoneCraft(unit, workshop) + end + if not success then + dfhack.printerr('idle-crafting: profile allows neither bone carving nor stonecrafting') + end + else + local craftItem = select_crafting_job(workshop) + if craftItem then + success = craftItem(unit, workshop) + else + print('idle-crafting: workshop has no usable materials in linked stockpiles') + failing[workshop.id] = true + end end if success then -- Why is the encoding still wrong, even when using df2console? print('idle-crafting: assigned crafting job to ' .. dfhack.df2console(dfhack.units.getReadableName(unit))) watched[idx][unit_id] = nil allowed[workshop.id] = df.global.world.frame_counter - else - dfhack.printerr('idle-crafting: profile allows neither bone carving nor stonecrafting, disabling workshop') end return true end @@ -355,17 +539,20 @@ end IdleCraftingOverlay = defclass(IdleCraftingOverlay, overlay.OverlayWidget) IdleCraftingOverlay.ATTRS { desc = "Adds a toggle for recreational crafting to Craftdwarf's workshops.", - default_pos = { x = -42, y = 41 }, + default_pos = { x = -39, y = 41 }, + version = 2, default_enabled = true, viewscreens = { 'dwarfmode/ViewSheets/BUILDING/Workshop/Craftsdwarfs/Workers', }, - frame = { w = 54, h = 1 }, + frame = { w = 58, h = 1 }, + visible = orders.can_set_labors } function IdleCraftingOverlay:init() self:addviews { widgets.BannerPanel{ + frame={l=0, w=54}, subviews={ widgets.CycleHotkeyLabel { view_id = 'leisure_toggle', @@ -386,6 +573,10 @@ function IdleCraftingOverlay:init() } }, }, + widgets.HelpButton{ + frame={r=0}, + command='idle-crafting', + }, } end diff --git a/internal/caravan/common.lua b/internal/caravan/common.lua index cbf95eb809..135762376c 100644 --- a/internal/caravan/common.lua +++ b/internal/caravan/common.lua @@ -488,8 +488,8 @@ function get_info_widgets(self, export_agreements, strict_ethical_bins_default, label='Item origins:', options={ {label='All', value='all', pen=COLOR_GREEN}, - {label='Foreign-made only', value='foreign', pen=COLOR_YELLOW}, {label='Fort-made only', value='local', pen=COLOR_BLUE}, + {label='Foreign-made only', value='foreign', pen=COLOR_YELLOW}, }, on_change=function() self:refresh_list() end, }, diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index cbb33cd90e..a6e0301eb4 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -330,7 +330,7 @@ ConfirmSpec{ -- sticks out the left side so it can move with the panel -- when the screen is resized too narrow intercept_frame={r=32, t=19, w=101, b=3}, - context='dwarfmode/SquadEquipment/Customizing/Default', + context='dwarfmode/Squads/Equipment/Customizing/Default', predicate=function(keys, mouse_offset) if keys._MOUSE_R then return uniform_has_changes() diff --git a/internal/control-panel/common.lua b/internal/control-panel/common.lua index 21472c45c1..922f053644 100644 --- a/internal/control-panel/common.lua +++ b/internal/control-panel/common.lua @@ -180,6 +180,9 @@ function set_preference(data, in_value) if expected_type == 'boolean' and type(value) ~= 'boolean' then value = argparse.boolean(value) end + if expected_type == "number" then + value = tonumber(value) or value + end local actual_type = type(value) if actual_type ~= expected_type then qerror(('"%s" has an unexpected value type: got: %s; expected: %s'):format( diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 5f20954cc2..aa35e5ae5e 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -90,9 +90,11 @@ COMMANDS_BY_IDX = { desc='Fix activity references on stuck instruments to make them usable again.', params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, {command='fix/stuck-worship', group='bugfix', mode='repeat', default=true, - params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', ']'}}, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', '-q', ']'}}, {command='fix/noexert-exhaustion', group='bugfix', mode='repeat', default=true, params={'--time', '439', '--timeUnits', 'ticks', '--command', '[', 'fix/noexert-exhaustion', ']'}}, + {command='fix/wildlife', group='bugfix', mode='repeat', + params={'--time', '2', '--timeUnits', 'days', '--command', '[', 'fix/wildlife', '-wq', ']'}}, {command='flask-contents', help_command='tweak', group='bugfix', mode='tweak', default=true, desc='Displays flask contents in the item name, similar to barrels and bins.'}, {command='named-codices', help_command='tweak', group='bugfix', mode='tweak', default=true, @@ -125,6 +127,8 @@ COMMANDS_BY_IDX = { {command='partial-items', help_command='tweak', group='gameplay', mode='tweak', default=true, desc='Displays percentages on partially-consumed items like hospital cloth.'}, {command='pop-control', group='gameplay', mode='enable'}, + {command='realistic-melting', help_command='tweak', group='gameplay', mode='tweak', + desc='Adjust selected item types melt return for all metals to ~95% of forging cost. Reduce melt return by 10% per wear level.'}, {command='starvingdead', group='gameplay', mode='enable'}, {command='timestream', group='gameplay', mode='enable'}, {command='work-now', group='gameplay', mode='enable'}, diff --git a/internal/exportlegends/asyncexport.lua b/internal/exportlegends/asyncexport.lua new file mode 100644 index 0000000000..21c4bb95fd --- /dev/null +++ b/internal/exportlegends/asyncexport.lua @@ -0,0 +1,104 @@ +--@module = true + +local gui = require('gui') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +progress_item = nil +num_done = nil +num_total = nil + +function reset_state() + progress_item = '' + num_done = -1 + num_total = -1 +end +reset_state() + +-- ------------------- +-- LegendsOverlay +-- + +LegendsOverlay = defclass(LegendsOverlay, overlay.OverlayWidget) +LegendsOverlay.ATTRS{ + desc='Adds extended export progress bar to the legends main screen.', + default_pos={x=2, y=2}, + default_enabled=true, + viewscreens='legends', + frame={w=55, h=5}, +} + +function LegendsOverlay:init() + self:addviews{ + widgets.Panel{ + view_id='button_mask', + frame={t=0, l=0, w=15, h=3}, + }, + widgets.BannerPanel{ + frame={b=0, l=0, r=0, h=1}, + visible=function() return dfhack.gui.matchFocusString('legends/Default', dfhack.gui.getDFViewscreen(true)) end, + subviews={ + widgets.ToggleHotkeyLabel{ + view_id='do_export', + frame={t=0, l=1, r=1}, + label='Also export DFHack extended legends data:', + key='CUSTOM_CTRL_D', + visible=function() return num_total < 0 end, + }, + widgets.Label{ + frame={t=0, l=1}, + text={ + 'Exporting ', + {width=27, text=function() return progress_item end}, + ' ', + {text=function() return ('%.2f'):format((num_done * 100) / num_total) end, pen=COLOR_YELLOW}, + '% complete' + }, + visible=function() return num_total >= 0 end, + }, + }, + }, + } +end + +function LegendsOverlay:onInput(keys) + if keys._MOUSE_L and self.subviews.button_mask:getMousePos() and self.subviews.do_export:getOptionValue() then + if num_total < 0 then + dfhack.run_script('exportlegends') + else + return true + end + end + return LegendsOverlay.super.onInput(self, keys) +end + +-- ------------------- +-- DoneMaskOverlay +-- + +DoneMaskOverlay = defclass(DoneMaskOverlay, overlay.OverlayWidget) +DoneMaskOverlay.ATTRS{ + desc='Prevents legends mode from being exited while an export is in progress.', + default_pos={x=-2, y=2}, + default_enabled=true, + viewscreens='legends', + frame={w=9, h=3}, +} + +function DoneMaskOverlay:init() + self:addviews{ + widgets.Panel{ + frame_background=gui.CLEAR_PEN, + visible=function() return num_total >= 0 end, + } + } +end + +function DoneMaskOverlay:onInput(keys) + if num_total >= 0 then + if keys.LEAVESCREEN or (keys._MOUSE_L and self:getMousePos()) then + return true + end + end + return DoneMaskOverlay.super.onInput(self, keys) +end diff --git a/internal/exportlegends/racefilter.lua b/internal/exportlegends/racefilter.lua new file mode 100644 index 0000000000..52524a9b71 --- /dev/null +++ b/internal/exportlegends/racefilter.lua @@ -0,0 +1,130 @@ +--@module = true + +local dlg = require('gui.dialogs') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +local choices, race_to_label, hfid_to_race, hfid_to_name, cur_race, prev_search + +function reset_state() + choices = {} + race_to_label = {[-1]='All'} + hfid_to_race = {} + hfid_to_name = {} + cur_race = -1 + prev_search = '' +end +reset_state() + +-- ------------------- +-- RaceFilterOverlay +-- + +RaceFilterOverlay = defclass(RaceFilterOverlay, overlay.OverlayWidget) +RaceFilterOverlay.ATTRS { + desc="Adds the ability to filter historical figures by race in legends mode.", + default_pos={x=56, y=11}, + default_enabled=true, + viewscreens='legends', -- finer grained visibility managed in render and onInput functions + frame={w=54, h=1}, -- can't use visible property due to self.dirty state management +} + +function RaceFilterOverlay:init() + self:addviews{ + widgets.BannerPanel{ + subviews={ + widgets.HotkeyLabel{ + frame={l=1}, + label='Filter by race:', + key='CUSTOM_ALT_S', + auto_width=true, + on_activate=self:callback('choose_race'), + }, + widgets.Label{ + frame={l=24}, + text={{text=function() return race_to_label[cur_race] end}}, + text_pen=COLOR_YELLOW, + }, + }, + }, + } +end + +function RaceFilterOverlay:set_race(_, choice) + if cur_race == choice.race then return end + cur_race = choice.race + self.dirty = true +end + +function RaceFilterOverlay:choose_race() + if #choices == 0 then + for race,cre in ipairs(df.global.world.raws.creatures.all) do + local label = string.lower(cre.creature_id) + race_to_label[race] = label + table.insert(choices, {text=label, race=race}) + end + table.sort(choices, function(a, b) return a.text < b.text end) + table.insert(choices, 1, {text='All', race=-1}) + end + + dlg.showListPrompt('Races', 'Choose race filter', COLOR_WHITE, choices, + self:callback('set_race'), nil, 30, true) +end + +local function do_filter(scr, filter_str, full_refresh) + print('filtering', cur_race, filter_str, full_refresh) + if full_refresh then + scr.histfigs_filtered:resize(#scr.histfigs) + for i=0,#scr.histfigs-1 do + scr.histfigs_filtered[i] = i + end + end + local filter_by_name = full_refresh and #filter_str > 0 + filter_str = dfhack.toSearchNormalized(filter_str) + if cur_race < 0 and not filter_by_name then return end + for idx=#scr.histfigs_filtered-1,0,-1 do + local hfid = scr.histfigs[scr.histfigs_filtered[idx]] + if not hfid_to_race[hfid] then + local hf = df.historical_figure.find(hfid) + hfid_to_race[hfid] = hf and hf.race or -1 + hfid_to_name[hfid] = hf and + dfhack.toSearchNormalized( + ('%s %s'):format(dfhack.TranslateName(hf.name, false), dfhack.TranslateName(hf.name, true))) or '' + end + if cur_race >= 0 and hfid_to_race[hfid] ~= cur_race then + scr.histfigs_filtered:erase(idx) + elseif filter_by_name and not hfid_to_name[hfid]:match(filter_str) then + scr.histfigs_filtered:erase(idx) + end + end +end + +local function get_cur_page(scr) + scr = scr or dfhack.gui.getDFViewscreen(true) + return scr.page[scr.active_page_index] +end + +local function is_hf_page(scr, page) + page = page or get_cur_page(scr) + return page.mode == df.legend_pagest.T_mode.HFS and page.index == -1 +end + +function RaceFilterOverlay:render(dc) + local scr = dfhack.gui.getDFViewscreen(true) + local page = get_cur_page(scr) + if not is_hf_page(scr, page) then + self.dirty = true + return + end + if self.dirty or prev_search ~= page.filter_str then + do_filter(scr, page.filter_str, self.dirty) + prev_search = page.filter_str + self.dirty = false + end + RaceFilterOverlay.super.render(self, dc) +end + +function RaceFilterOverlay:onInput(keys) + if not is_hf_page() then return end + RaceFilterOverlay.super.onInput(self, keys) +end diff --git a/internal/journal/table_of_contents.lua b/internal/journal/table_of_contents.lua index 67e12209a5..c1e2096df9 100644 --- a/internal/journal/table_of_contents.lua +++ b/internal/journal/table_of_contents.lua @@ -37,12 +37,12 @@ function TableOfContents:init() local function can_prev() local toc = self.subviews.table_of_contents - return #toc:getChoices() > 0 and toc:getSelected() > 1 + return #toc:getChoices() > 0 end local function can_next() local toc = self.subviews.table_of_contents local num_choices = #toc:getChoices() - return num_choices > 0 and toc:getSelected() < num_choices + return num_choices > 0 end self:addviews{ diff --git a/internal/journal/text_editor.lua b/internal/journal/text_editor.lua index 8bdd0aeb74..9815a2f12c 100644 --- a/internal/journal/text_editor.lua +++ b/internal/journal/text_editor.lua @@ -94,6 +94,7 @@ TextEditor.ATTRS{ select_pen = COLOR_CYAN, on_text_change = DEFAULT_NIL, on_cursor_change = DEFAULT_NIL, + one_line_mode = false, debug = false } @@ -104,27 +105,28 @@ function TextEditor:init() TextEditorView{ view_id='text_area', frame={l=0,r=3,t=0}, - text = self.init_text, + text=self.init_text, - text_pen = self.text_pen, - ignore_keys = self.ignore_keys, - select_pen = self.select_pen, - debug = self.debug, + text_pen=self.text_pen, + ignore_keys=self.ignore_keys, + select_pen=self.select_pen, + debug=self.debug, + one_line_mode=self.one_line_mode, - on_text_change = function (val) + on_text_change=function (val) self:updateLayout() if self.on_text_change then self.on_text_change(val) end end, - on_cursor_change = self:callback('onCursorChange') + on_cursor_change=self:callback('onCursorChange') }, widgets.Scrollbar{ view_id='scrollbar', frame={r=0,t=1}, - on_scroll=self:callback('onScrollbar') - }, - widgets.HelpButton{command="gui/journal", frame={r=0,t=0}} + on_scroll=self:callback('onScrollbar'), + visible=not self.one_line_mode + } } self:setFocus(true) end @@ -169,14 +171,14 @@ function TextEditor:setCursor(cursor_offset) end function TextEditor:getPreferredFocusState() - return true + return self.parent_view.focus end function TextEditor:postUpdateLayout() self:updateScrollbar(self.render_start_line_y) if self.subviews.text_area.cursor == nil then - local cursor = self.init_cursor or #self.text + 1 + local cursor = self.init_cursor or #self.init_text + 1 self.subviews.text_area:setCursor(cursor) self:scrollToCursor(cursor) end @@ -234,6 +236,10 @@ function TextEditor:onInput(keys) return self.subviews.scrollbar:onInput(keys) end + if keys._MOUSE_L and self:getMousePos() then + self:setFocus(true) + end + return TextEditor.super.onInput(self, keys) end @@ -248,6 +254,7 @@ TextEditorView.ATTRS{ on_cursor_change = DEFAULT_NIL, enable_cursor_blink = true, debug = false, + one_line_mode = false, history_size = 10, } @@ -270,6 +277,8 @@ function TextEditorView:init() bold=true }) + self.text = self:normalizeText(self.text) + self.wrapped_text = wrapped_text.WrappedText{ text=self.text, wrap_width=256 @@ -278,6 +287,14 @@ function TextEditorView:init() self.history = TextEditorHistory{history_size=self.history_size} end +function TextEditorView:normalizeText(text) + if self.one_line_mode then + return text:gsub("\r?\n", "") + end + + return text +end + function TextEditorView:setRenderStartLineY(render_start_line_y) self.render_start_line_y = render_start_line_y end @@ -407,7 +424,7 @@ end function TextEditorView:setText(text) local changed = self.text ~= text - self.text = text + self.text = self:normalizeText(text) self:recomputeLines() @@ -629,6 +646,8 @@ function TextEditorView:onInput(keys) self:paste() self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) return true + else + return TextEditor.super.onInput(self, keys) end end @@ -742,30 +761,30 @@ function TextEditorView:onCursorInput(keys) self:setCursor(offset) self.last_cursor_x = last_cursor_x return true - elseif keys.KEYBOARD_CURSOR_UP_FAST then + elseif keys.CUSTOM_CTRL_HOME then self:setCursor(1) return true - elseif keys.KEYBOARD_CURSOR_DOWN_FAST then + elseif keys.CUSTOM_CTRL_END then -- go to text end self:setCursor(#self.text + 1) return true - elseif keys.CUSTOM_CTRL_B or keys.A_MOVE_W_DOWN then + elseif keys.CUSTOM_CTRL_LEFT then -- back one word local word_start = self:wordStartOffset() self:setCursor(word_start) return true - elseif keys.CUSTOM_CTRL_F or keys.A_MOVE_E_DOWN then + elseif keys.CUSTOM_CTRL_RIGHT then -- forward one word local word_end = self:wordEndOffset() self:setCursor(word_end) return true - elseif keys.CUSTOM_CTRL_H then + elseif keys.CUSTOM_HOME then -- line start self:setCursor( self:lineStartOffset() ) return true - elseif keys.CUSTOM_CTRL_E then + elseif keys.CUSTOM_END then -- line end self:setCursor( self:lineEndOffset() @@ -777,12 +796,14 @@ end function TextEditorView:onTextManipulationInput(keys) if keys.SELECT then -- handle enter - self.history:store( - HISTORY_ENTRY.WHITESPACE_BLOCK, - self.text, - self.cursor - ) - self:insert(NEWLINE) + if not self.one_line_mode then + self.history:store( + HISTORY_ENTRY.WHITESPACE_BLOCK, + self.text, + self.cursor + ) + self:insert(NEWLINE) + end return true @@ -857,8 +878,7 @@ function TextEditorView:onTextManipulationInput(keys) self:eraseSelection() return true - elseif keys.CUSTOM_CTRL_D then - -- delete char, there is no support for `Delete` key + elseif keys.CUSTOM_DELETE then self.history:store(HISTORY_ENTRY.DELETE, self.text, self.cursor) if (self:hasSelection()) then diff --git a/internal/quickfort/building.lua b/internal/quickfort/building.lua index 9f258c7e77..147442a656 100644 --- a/internal/quickfort/building.lua +++ b/internal/quickfort/building.lua @@ -532,7 +532,7 @@ function check_tiles_and_extents(ctx, buildings) -- as invalid; the building can still build around it owns_preview = quickfort_preview.set_preview_tile(ctx, pos, - is_valid_tile or db_entry.has_extents) + is_valid_tile or db_entry.has_extents or false) if not is_valid_tile then log('tile not usable: (%d, %d, %d)', pos.x, pos.y, pos.z) col[extent_y] = false diff --git a/internal/quickfort/notes.lua b/internal/quickfort/notes.lua index aa64168607..5667cfb933 100644 --- a/internal/quickfort/notes.lua +++ b/internal/quickfort/notes.lua @@ -31,7 +31,11 @@ function do_run(_, grid, ctx) if #line > 0 then table.insert(lines, table.concat(line, ' ')) end - table.insert(ctx.messages, table.concat(lines, '\n')) + local message = table.concat(lines, '\n') + if not ctx.messages_set[message] then + table.insert(ctx.messages, message) + ctx.messages_set[message] = true + end end function do_orders() diff --git a/internal/quickfort/preview.lua b/internal/quickfort/preview.lua index ea98b9de76..a1a7afd5f2 100644 --- a/internal/quickfort/preview.lua +++ b/internal/quickfort/preview.lua @@ -11,7 +11,7 @@ end function set_preview_tile(ctx, pos, is_valid_tile, override) local preview = ctx.preview if not preview then return false end - local preview_row = ensure_key(ensure_key(ctx.preview.tiles, pos.z), pos.y) + local preview_row = ensure_keys(ctx.preview.tiles, pos.z, pos.y) if preview_row[pos.x] == nil then preview.total_tiles = preview.total_tiles + 1 end diff --git a/makeown.lua b/makeown.lua index 565b7c6856..73054f6ed3 100644 --- a/makeown.lua +++ b/makeown.lua @@ -59,6 +59,30 @@ local function fix_clothing_ownership(unit) unit.uniform.uniform_drop:resize(0) end +function clear_enemy_status(unit) + if unit.enemy.enemy_status_slot <= -1 then return end + + local status_cache = df.global.world.enemy_status_cache + local status_slot = unit.enemy.enemy_status_slot + + unit.enemy.enemy_status_slot = -1 + status_cache.slot_used[status_slot] = false + + for index in ipairs(status_cache.rel_map[status_slot]) do + status_cache.rel_map[status_slot][index] = -1 + end + + for index in ipairs(status_cache.rel_map) do + status_cache.rel_map[index][status_slot] = -1 + end + + -- TODO: what if there were status slots taken above status_slot? + -- does everything need to be moved down by one to fill the gap? + if status_cache.next_slot > status_slot then + status_cache.next_slot = status_slot + end +end + local function fix_unit(unit) unit.flags1.marauder = false; unit.flags1.merchant = false; @@ -82,6 +106,8 @@ local function fix_unit(unit) if unit.profession == df.profession.MERCHANT then unit.profession = df.profession.TRADER end if unit.profession2 == df.profession.MERCHANT then unit.profession2 = df.profession.TRADER end + + clear_enemy_status(unit) end local function add_to_entity(hf, eid) diff --git a/modtools/create-item.lua b/modtools/create-item.lua index b81978f7ab..95811af968 100644 --- a/modtools/create-item.lua +++ b/modtools/create-item.lua @@ -37,6 +37,15 @@ local no_quality_item_types = utils.invert{ 'BRANCH', } +local typesThatUseCreaturesExceptCorpses = utils.invert { + 'REMAINS', + 'FISH', + 'FISH_RAW', + 'VERMIN', + 'PET', + 'EGG', +} + local CORPSE_PIECES = utils.invert{'BONE', 'SKIN', 'CARTILAGE', 'TOOTH', 'NERVE', 'NAIL', 'HORN', 'HOOF', 'CHITIN', 'SHELL', 'IVORY', 'SCALE'} local HAIR_PIECES = utils.invert{'HAIR', 'EYEBROW', 'EYELASH', 'MOUSTACHE', 'CHIN_WHISKERS', 'SIDEBURNS'} @@ -327,14 +336,16 @@ function hackWish(accessors, opts) end if not mattype or not itemtype then return end if df.item_type.attrs[itemtype].is_stackable then - return createItem({mattype, matindex}, {itemtype, itemsubtype}, quality, unit, description, count) + local mat = typesThatUseCreaturesExceptCorpses[df.item_type[itemtype]] and {matindex, casteId} or {mattype, matindex} + return createItem(mat, {itemtype, itemsubtype}, quality, unit, description, count) end local items = {} for _ = 1,count do if itemtype == df.item_type.CORPSEPIECE or itemtype == df.item_type.CORPSE then table.insert(items, createCorpsePiece(unit, bodypart, partlayerID, matindex, casteId, corpsepieceGeneric)) else - for _,item in ipairs(createItem({mattype, matindex}, {itemtype, itemsubtype}, quality, unit, description, 1)) do + local mat = typesThatUseCreaturesExceptCorpses[df.item_type[itemtype]] and {matindex, casteId} or {mattype, matindex} + for _,item in ipairs(createItem(mat, {itemtype, itemsubtype}, quality, unit, description, 1)) do table.insert(items, item) end end diff --git a/modtools/force.lua b/modtools/force.lua deleted file mode 100644 index eb49b91552..0000000000 --- a/modtools/force.lua +++ /dev/null @@ -1,64 +0,0 @@ --- Forces an event (caravan, migrants, etc) --- author Putnam --- edited by expwnent -local utils = require 'utils' - -local function findCiv(arg) - local entities = df.global.world.entities.all - if tonumber(arg) then return df.historical_entity.find(tonumber(arg)) end - if arg then - for eid,entity in ipairs(entities) do - if entity.entity_raw.code == arg then return entity end - end - end - return nil -end - -local validArgs = utils.invert({ - 'eventType', - 'help', - 'civ' -}) - -local args = utils.processArgs({...}, validArgs) -if args.help then - print(dfhack.script_help()) - return -end - -if not args.eventType then - error 'Specify an eventType.' -elseif not df.timed_event_type[args.eventType] then - error('Invalid eventType: ' .. args.eventType) -elseif args.eventType == 'FeatureAttack' then - qerror('Event type: FeatureAttack is not currently supported') -end - -local civ = nil --as:df.historical_entity -if args.civ == 'player' then - civ = df.historical_entity.find(df.global.plotinfo.civ_id) -elseif args.civ then - civ = findCiv(args.civ) -end -if args.civ and not civ then - error('Invalid civ: ' .. args.civ) -end -if args.eventType == 'Caravan' or args.eventType == 'Diplomat' then - if not civ then - error('Specify civ for this eventType') - end -end - -if args.eventType == 'Migrants' then - civ = df.historical_entity.find(df.global.plotinfo.civ_id) -end - -local timedEvent = df.timed_event:new() -timedEvent.type = df.timed_event_type[args.eventType] -timedEvent.season = df.global.cur_season -timedEvent.season_ticks = df.global.cur_season_tick -if civ then - timedEvent.entity = civ -end - -df.global.timed_events:insert('#', timedEvent) diff --git a/modtools/reaction-trigger.lua b/modtools/reaction-trigger.lua index c71e84f378..5514a01513 100644 --- a/modtools/reaction-trigger.lua +++ b/modtools/reaction-trigger.lua @@ -181,7 +181,7 @@ local validArgs = utils.invert({ 'allowMultipleTargets', 'range', 'ignoreWorker', - 'dontSkipInactive', + 'dontSkipInactive', --TODO: positions for inactive units are meaningless! 'resetPolicy' }) diff --git a/necronomicon.lua b/necronomicon.lua index 68ef8d083c..fcffb0fdb0 100644 --- a/necronomicon.lua +++ b/necronomicon.lua @@ -1,5 +1,4 @@ -- lists books that contain secrets of life and death. --- Author: Ajhaa local argparse = require("argparse") @@ -14,7 +13,7 @@ function get_book_interactions(item) for _, ref in ipairs (written_content.refs) do if ref._type == df.general_ref_interactionst then - local interaction = df.global.world.raws.interactions[ref.interaction_id] + local interaction = df.interaction.find(ref.interaction_id) table.insert(book_interactions, interaction) end end @@ -34,7 +33,7 @@ end function get_item_artifact(item) for _, ref in ipairs(item.general_refs) do if ref._type == df.general_ref_is_artifactst then - return df.global.world.artifacts.all[ref.artifact_id] + return df.artifact_record.find(ref.artifact_id) end end end diff --git a/notes.lua b/notes.lua new file mode 100644 index 0000000000..99aab4a748 --- /dev/null +++ b/notes.lua @@ -0,0 +1,342 @@ +--@ module = true + +local gui = require('gui') +local widgets = require('gui.widgets') +local textures = require('gui.textures') +local overlay = require('plugins.overlay') +local guidm = require('gui.dwarfmode') +local text_editor = reqscript('internal/journal/text_editor') + +local green_pin = dfhack.textures.loadTileset( + 'hack/data/art/note_green_pin_map.png', + 32, + 32, + true +) + +NotesOverlay = defclass(NotesOverlay, overlay.OverlayWidget) +NotesOverlay.ATTRS{ + desc='Render map notes.', + viewscreens='dwarfmode', + default_enabled=true, + overlay_onupdate_max_freq_seconds=30, +} + +local waypoints = df.global.plotinfo.waypoints +local map_points = df.global.plotinfo.waypoints.points + +function NotesOverlay:init() + self.visible_notes = {} + self.note_manager = nil + self.last_click_pos = {} + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_onupdate() + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_trigger(args) + return self:showNoteManager() +end + +function NotesOverlay:onInput(keys) + if keys._MOUSE_L then + local top_most_screen = dfhack.gui.getDFViewscreen(true) + if dfhack.gui.matchFocusString('dwarfmode/Default', top_most_screen) then + local pos = dfhack.gui.getMousePos() + if pos == nil then + return false + end + + local note = self:clickedNote(pos) + if note ~= nil then + self:showNoteManager(note) + end + end + end +end + +function NotesOverlay:clickedNote(click_pos) + local pos_curr_note = same_xyz(self.last_click_pos, click_pos) + and self.note_manager + and self.note_manager.note + or nil + + self.last_click_pos = click_pos + + local last_note_on_pos = nil + local first_note_on_pos = nil + for _, note in ipairs(self.visible_notes) do + if same_xyz(note.point.pos, click_pos) then + if (last_note_on_pos and pos_curr_note + and last_note_on_pos.point.id == pos_curr_note.point.id + ) then + return note + end + + first_note_on_pos = first_note_on_pos or note + last_note_on_pos = note + end + end + + return first_note_on_pos +end + +function NotesOverlay:showNoteManager(note) + if self.note_manager ~= nil then + self.note_manager:dismiss() + end + + self.note_manager = NoteManager{ + note=note, + on_update=function() self:reloadVisibleNotes() end + } + + return self.note_manager:show() +end + +function NotesOverlay:viewportChanged() + return self.viewport_pos.x ~= df.global.window_x or + self.viewport_pos.y ~= df.global.window_y or + self.viewport_pos.z ~= df.global.window_z +end + +function NotesOverlay:onRenderFrame(dc) + if not df.global.pause_state and not dfhack.screen.inGraphicsMode() then + return + end + + if self:viewportChanged() then + self:reloadVisibleNotes() + end + + dc:map(true) + + local texpos = dfhack.textures.getTexposByHandle(green_pin[1]) + dc:pen({fg=COLOR_BLACK, bg=COLOR_LIGHTCYAN, tile=texpos}) + + for _, note in pairs(self.visible_notes) do + dc + :seek(note.screen_pos.x, note.screen_pos.y) + :char('N') + end + + dc:map(false) +end + +function NotesOverlay:reloadVisibleNotes() + self.visible_notes = {} + + local viewport = guidm.Viewport.get() + self.viewport_pos = { + x=df.global.window_x, + y=df.global.window_y, + z=df.global.window_z + } + + for _, map_point in ipairs(map_points) do + if (viewport:isVisible(map_point.pos) + and map_point.name ~= nil and #map_point.name > 0) + then + local screen_pos = viewport:tileToScreen(map_point.pos) + table.insert(self.visible_notes, { + point=map_point, + screen_pos=screen_pos + }) + end + end +end + +NoteManager = defclass(NoteManager, gui.ZScreen) +NoteManager.ATTRS{ + focus_path='notes/note-manager', + note=DEFAULT_NIL, + on_update=DEFAULT_NIL, +} + +function NoteManager:init() + local edit_mode = self.note ~= nil + + self:addviews{ + widgets.Window{ + frame={w=35,h=20}, + frame_inset={t=1}, + frame_title='Notes', + resizable=true, + subviews={ + widgets.HotkeyLabel { + key='CUSTOM_ALT_N', + label='Name', + frame={l=0,t=0}, + auto_width=true, + on_activate=function() self.subviews.name:setFocus(true) end, + }, + text_editor.TextEditor{ + view_id='name', + frame={t=1,h=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.name or '', + init_cursor=self.note and #self.note.point.name+1 or 1, + one_line_mode=true + }, + widgets.HotkeyLabel { + key='CUSTOM_ALT_C', + label='Comment', + frame={l=0,t=5}, + auto_width=true, + on_activate=function() self.subviews.comment:setFocus(true) end, + }, + text_editor.TextEditor{ + view_id='comment', + frame={t=6,b=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.comment or '', + init_cursor=1 + }, + widgets.Panel{ + view_id='buttons', + frame={b=0,h=1}, + frame_inset={l=1,r=1}, + subviews={ + widgets.HotkeyLabel{ + view_id='Save', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Save', + key='CUSTOM_ALT_S', + visible=edit_mode, + on_activate=function() self:saveNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='Create', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Create', + key='CUSTOM_ALT_S', + visible=not edit_mode, + on_activate=function() self:createNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='delete', + frame={r=0,t=0,h=1}, + auto_width=true, + label='Delete', + key='CUSTOM_ALT_D', + visible=edit_mode, + on_activate=function() self:deleteNote() end, + } or nil, + } + } + }, + }, + } +end + +function NoteManager:createNote() + local cursor_pos = guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + map_points:insert("#", { + new=true, + + id = waypoints.next_point_id, + tile=88, + fg_color=7, + bg_color=0, + name=name, + comment=comment, + pos=cursor_pos + }) + waypoints.next_point_id = waypoints.next_point_id + 1 + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:saveNote() + if self.note == nil then + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + self.note.point.name = name + self.note.point.comment = comment + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:deleteNote() + if self.note == nil then + return + end + + for ind, map_point in pairs(map_points) do + if map_point.id == self.note.point.id then + map_points:erase(ind) + break + end + end + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:onDismiss() + self.note = nil +end + +-- register widgets +OVERLAY_WIDGETS = { + map_notes=NotesOverlay +} + +local function main(args) + if #args == 0 then + return + end + + if args[1] == 'add' then + local cursor_pos = guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + return dfhack.internal.runCommand('overlay trigger notes.map_notes') + end +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/position.lua b/position.lua index 3ca3bdd543..de6248f62f 100644 --- a/position.lua +++ b/position.lua @@ -33,18 +33,18 @@ local months = { --Adventurer mode counts 86400 ticks to a day and 29030400 ticks per year --Twelve months per year, 28 days to every month, 336 days per year -local julian_day = math.floor(df.global.cur_year_tick / 1200) + 1 -local month = math.floor(julian_day / 28) + 1 --days and months are 1-indexed +local julian_day = df.global.cur_year_tick // 1200 + 1 +local month = julian_day // 28 + 1 --days and months are 1-indexed local day = julian_day % 28 -local time_of_day = math.floor(df.global.cur_year_tick_advmode / 336) +local time_of_day = df.global.cur_year_tick_advmode // 336 local second = time_of_day % 60 -local minute = math.floor(time_of_day / 60) % 60 -local hour = math.floor(time_of_day / 3600) % 24 +local minute = time_of_day // 60 % 60 +local hour = time_of_day // 3600 % 24 print('Time:') -print(' The time is '..string.format('%02d:%02d:%02d', hour, minute, second)) -print(' The date is '..string.format('%05d-%02d-%02d', df.global.cur_year, month, day)) +print((' The time is %02d:%02d:%02d'):format(hour, minute, second)) +print((' The date is %03d-%02d-%02d'):format(df.global.cur_year, month, day)) print(' It is the month of '..months[month]) local eras = df.global.world.history.eras @@ -54,8 +54,43 @@ end print('Place:') print(' The z-level is z='..df.global.window_z) -print(' The cursor is at x='..cursor.x..', y='..cursor.y) -print(' The window is '..df.global.gps.dimx..' tiles wide and '..df.global.gps.dimy..' tiles high') -if df.global.gps.mouse_x == -1 then print(' The mouse is not in the DF window') else -print(' The mouse is at x='..df.global.gps.mouse_x..', y='..df.global.gps.mouse_y..' within the window') end ---TODO: print(' The fortress is at '..x, y..' on the world map ('..worldsize..' square)') + +if cursor.x < 0 then + print(' The keyboard cursor is inactive.') +else + print(' The keyboard cursor is at x='..cursor.x..', y='..cursor.y) +end + +local x, y = dfhack.screen.getWindowSize() +print(' The window is '..x..' tiles wide and '..y..' tiles high.') + +x, y = dfhack.screen.getMousePos() +if x then + print(' The mouse is at x='..x..', y='..y..' within the window.') + local pos = dfhack.gui.getMousePos() + if pos then + print(' The mouse is over map tile x='..pos.x..', y='..pos.y) + end +else + print(' The mouse is not in the DF window.') +end + +local wd = df.global.world.world_data +local site = dfhack.world.getCurrentSite() +if site then + print((' The current site is at x=%d, y=%d on the %dx%d world map.'): + format(site.pos.x, site.pos.y, wd.world_width, wd.world_height)) +elseif dfhack.world.isAdventureMode() then + x, y = -1, -1 + for _,army in ipairs(df.global.world.armies.all) do + if army.flags.player then + x, y = army.pos.x // 48, army.pos.y // 48 + break + end + end + if x < 0 then + x, y = wd.midmap_data.adv_region_x, wd.midmap_data.adv_region_y + end + print((' The adventurer is at x=%d, y=%d on the %dx%d world map.'): + format(x, y, wd.world_width, wd.world_height)) +end diff --git a/rejuvenate.lua b/rejuvenate.lua index de9bf14b23..04c79cdf5f 100644 --- a/rejuvenate.lua +++ b/rejuvenate.lua @@ -1,35 +1,82 @@ --- set age of selected unit --- by vjek --@ module = true local utils = require('utils') -function rejuvenate(unit, force, dry_run, age) - local current_year = df.global.cur_year - if not age then - age = 20 +local DEFAULT_CHILD_AGE = 18 +local DEFAULT_OLD_AGE = 160 +local ANY_BABY = df.global.world.units.other.ANY_BABY + +local function get_caste_misc(unit) + local craw = dfhack.units.getCasteRaw(unit) + if not craw then return end + return craw.misc +end + +local function get_adult_age(misc) + return misc and misc.child_age or DEFAULT_CHILD_AGE +end + +local function get_rand_old_age(misc) + return misc and math.random(misc.maxage_min, misc.maxage_max) or DEFAULT_OLD_AGE +end + +-- called by armoks-blessing +function rejuvenate(unit, quiet, force, dry_run, age) + local name = dfhack.df2console(dfhack.units.getReadableName(unit)) + local misc = get_caste_misc(unit) + local adult_age = get_adult_age(misc) + age = age or adult_age + if age < adult_age then + dfhack.printerr('cannot set age to child or baby range') + return end + local current_year = df.global.cur_year local new_birth_year = current_year - age - local name = dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) + local new_old_year = unit.old_year < 0 and -1 or math.max(unit.old_year, new_birth_year + get_rand_old_age(misc)) if unit.birth_year > new_birth_year and not force then - print(name .. ' is under ' .. age .. ' years old. Use --force to force.') + if not quiet then + dfhack.printerr(name .. ' is under ' .. age .. ' years old. Use --force to force.') + end return end if dry_run then print('would change: ' .. name) return end + + local hf = df.historical_figure.find(unit.hist_figure_id) unit.birth_year = new_birth_year - if unit.old_year < new_birth_year + 160 then - unit.old_year = new_birth_year + 160 - end + if hf then hf.born_year = new_birth_year end + unit.old_year = new_old_year + if hf then hf.old_year = new_old_year end + if unit.profession == df.profession.BABY or unit.profession == df.profession.CHILD then + if unit.profession == df.profession.BABY then + local idx = utils.linear_index(ANY_BABY, unit.id, 'id') + if idx then + ANY_BABY:erase(idx) + end + unit.flags1.rider = false + unit.relationship_ids.RiderMount = -1 + unit.mount_type = df.rider_positions_type.STANDARD + unit.profession2 = df.profession.STANDARD + unit.idle_area_type = df.unit_station_type.MillBuilding + unit.mood = -1 + + -- let the mom know she isn't carrying anyone anymore + local mother = df.unit.find(unit.relationship_ids.Mother) + if mother then mother.flags1.ridden = false end + end unit.profession = df.profession.STANDARD + unit.profession2 = df.profession.STANDARD + if hf then hf.profession = df.profession.STANDARD end + end + if not quiet then + print(name .. ' is now ' .. age .. ' years old and will live a normal lifespan henceforth') end - print(name .. ' is now ' .. age .. ' years old and will live to at least 160') end -function main(args) +local function main(args) local units = {} --as:df.unit[] if args.all then units = dfhack.units.getCitizens() @@ -37,7 +84,7 @@ function main(args) table.insert(units, dfhack.gui.getSelectedUnit(true) or qerror("Please select a unit in the UI.")) end for _, u in ipairs(units) do - rejuvenate(u, args.force, args['dry-run'], args.age) + rejuvenate(u, false, args.force, args['dry-run'], args.age) end end diff --git a/test/gui/journal.lua b/test/gui/journal.lua index 29fcc1d694..612e860785 100644 --- a/test/gui/journal.lua +++ b/test/gui/journal.lua @@ -77,7 +77,7 @@ local function arrange_empty_journal(options) gui_journal.main({ save_prefix='test:', save_on_change=options.save_on_change or false, - save_layout=options.allow_layout_restore or false + save_layout=options.allow_layout_restore or false, }) local journal = gui_journal.view @@ -772,7 +772,7 @@ function test.handle_delete() text_area:setCursor(1) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '_: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -783,7 +783,7 @@ function test.handle_delete() text_area:setCursor(124) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -794,7 +794,7 @@ function test.handle_delete() text_area:setCursor(123) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -805,7 +805,7 @@ function test.handle_delete() text_area:setCursor(171) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -815,7 +815,7 @@ function test.handle_delete() }, '\n')); for i=1,59 do - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') end expect.eq(read_rendered_text(text_area), table.concat({ @@ -824,7 +824,7 @@ function test.handle_delete() 'nibhorttitor mi, vitae rutrum eros metus nec libero._', }, '\n')); - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -849,7 +849,7 @@ function test.line_end() text_area:setCursor(1) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_E') + simulate_input_keys('CUSTOM_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', @@ -861,7 +861,7 @@ function test.line_end() text_area:setCursor(70) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_E') + simulate_input_keys('CUSTOM_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -873,7 +873,7 @@ function test.line_end() text_area:setCursor(200) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_E') + simulate_input_keys('CUSTOM_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -882,7 +882,7 @@ function test.line_end() '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', }, '\n')); - simulate_input_keys('CUSTOM_CTRL_E') + simulate_input_keys('CUSTOM_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -905,7 +905,7 @@ function test.line_beging() simulate_input_text(text) - simulate_input_keys('CUSTOM_CTRL_H') + simulate_input_keys('CUSTOM_HOME') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -917,7 +917,7 @@ function test.line_beging() text_area:setCursor(173) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_H') + simulate_input_keys('CUSTOM_HOME') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -929,7 +929,7 @@ function test.line_beging() text_area:setCursor(1) journal:onRender() - simulate_input_keys('CUSTOM_CTRL_H') + simulate_input_keys('CUSTOM_HOME') expect.eq(read_rendered_text(text_area), table.concat({ '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -1110,7 +1110,7 @@ function test.jump_to_text_end() text_area:setCursor(1) journal:onRender() - simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + simulate_input_keys('CUSTOM_CTRL_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -1119,7 +1119,7 @@ function test.jump_to_text_end() '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', }, '\n')); - simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + simulate_input_keys('CUSTOM_CTRL_END') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -1142,7 +1142,7 @@ function test.jump_to_text_begin() simulate_input_text(text) - simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + simulate_input_keys('CUSTOM_CTRL_HOME') expect.eq(read_rendered_text(text_area), table.concat({ '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -1151,7 +1151,7 @@ function test.jump_to_text_begin() '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', }, '\n')); - simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + simulate_input_keys('CUSTOM_CTRL_HOME') expect.eq(read_rendered_text(text_area), table.concat({ '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -1355,10 +1355,10 @@ function test.line_navigation_reset_selection() 'porttitor mi, vitae rutrum eros metus nec libero.', }, '\n')); - simulate_input_keys('CUSTOM_CTRL_H') + simulate_input_keys('CUSTOM_HOME') expect.eq(read_selected_text(text_area), '') - simulate_input_keys('CUSTOM_CTRL_E') + simulate_input_keys('CUSTOM_END') expect.eq(read_selected_text(text_area), '') journal:dismiss() @@ -1388,10 +1388,10 @@ function test.jump_begin_or_end_reset_selection() 'porttitor mi, vitae rutrum eros metus nec libero.', }, '\n')); - simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + simulate_input_keys('CUSTOM_CTRL_HOME') expect.eq(read_selected_text(text_area), '') - simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + simulate_input_keys('CUSTOM_CTRL_END') expect.eq(read_selected_text(text_area), '') journal:dismiss() @@ -1496,7 +1496,7 @@ function test.delete_char_delete_selection() 'porttitor mi, vitae rutrum ero', }, '\n')); - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') expect.eq(read_rendered_text(text_area), table.concat({ '60: _ metus nec libero.', @@ -2236,7 +2236,7 @@ function test.restore_text_between_sessions() local journal, text_area = arrange_empty_journal({w=80,save_on_change=true}) simulate_input_keys('CUSTOM_CTRL_A') - simulate_input_keys('CUSTOM_CTRL_D') + simulate_input_keys('CUSTOM_DELETE') local text = table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -2426,7 +2426,7 @@ function test.scroll_follows_cursor() 'Ut gravida tortor ac accumsan suscipit.', }, '\n')) - simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + simulate_input_keys('CUSTOM_CTRL_HOME') simulate_mouse_click(text_area, 0, 9) simulate_input_keys('KEYBOARD_CURSOR_DOWN') @@ -2861,7 +2861,7 @@ function test.fast_rewind_words_right() text_area:setCursor(1) journal:onRender() - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_rendered_text(text_area), table.concat({ '60:_Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2871,7 +2871,7 @@ function test.fast_rewind_words_right() 'libero.', }, '\n')); - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem_ipsum dolor sit amet, consectetur adipiscing ', @@ -2882,7 +2882,7 @@ function test.fast_rewind_words_right() }, '\n')); for i=1,6 do - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') end expect.eq(read_rendered_text(text_area), table.concat({ @@ -2893,7 +2893,7 @@ function test.fast_rewind_words_right() 'libero.', }, '\n')); - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2903,7 +2903,7 @@ function test.fast_rewind_words_right() 'libero.', }, '\n')); - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2914,7 +2914,7 @@ function test.fast_rewind_words_right() }, '\n')); for i=1,17 do - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') end expect.eq(read_rendered_text(text_area), table.concat({ @@ -2925,7 +2925,7 @@ function test.fast_rewind_words_right() 'libero._', }, '\n')); - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2948,7 +2948,7 @@ function test.fast_rewind_words_left() simulate_input_text(text) - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2958,7 +2958,7 @@ function test.fast_rewind_words_left() '_ibero.', }, '\n')); - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2969,7 +2969,7 @@ function test.fast_rewind_words_left() }, '\n')); for i=1,8 do - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') end expect.eq(read_rendered_text(text_area), table.concat({ @@ -2980,7 +2980,7 @@ function test.fast_rewind_words_left() 'libero.', }, '\n')); - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') expect.eq(read_rendered_text(text_area), table.concat({ '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -2991,7 +2991,7 @@ function test.fast_rewind_words_left() }, '\n')); for i=1,16 do - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') end expect.eq(read_rendered_text(text_area), table.concat({ @@ -3002,7 +3002,7 @@ function test.fast_rewind_words_left() 'libero.', }, '\n')); - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') expect.eq(read_rendered_text(text_area), table.concat({ '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', @@ -3039,12 +3039,12 @@ function test.fast_rewind_reset_selection() 'porttitor mi, vitae rutrum eros metus nec libero.', }, '\n')); - simulate_input_keys('A_MOVE_W_DOWN') + simulate_input_keys('CUSTOM_CTRL_LEFT') expect.eq(read_selected_text(text_area), '') simulate_input_keys('CUSTOM_CTRL_A') - simulate_input_keys('A_MOVE_E_DOWN') + simulate_input_keys('CUSTOM_CTRL_RIGHT') expect.eq(read_selected_text(text_area), '') journal:dismiss() @@ -3068,3 +3068,6 @@ function test.show_tutorials_on_first_use() expect.str_find('Section 1\n', read_rendered_text(toc_panel)); journal:dismiss() end + +-- TODO: separate journal tests from TextEditor tests +-- add "one_line_mode" tests diff --git a/uniform-unstick.lua b/uniform-unstick.lua index af86aa8006..e77663242e 100644 --- a/uniform-unstick.lua +++ b/uniform-unstick.lua @@ -283,34 +283,47 @@ end ReportWindow = defclass(ReportWindow, widgets.Window) ReportWindow.ATTRS { frame_title='Equipment conflict report', - frame={w=100, h=45}, - resizable=true, -- if resizing makes sense for your dialog - resize_min={w=50, h=20}, -- try to allow users to shrink your windows - autoarrange_subviews=1, - autoarrange_gap=1, + frame={w=100, h=35}, + resizable=true, + resize_min={w=60, h=20}, report=DEFAULT_NIL, } function ReportWindow:init() self:addviews{ - widgets.HotkeyLabel{ - frame={t=0, l=0, r=0}, - label='Try to resolve conflicts', - key='CUSTOM_CTRL_T', - auto_width=true, - on_activate=function() - dfhack.run_script('uniform-unstick', '--all', '--drop', '--free') - self.parent_view:dismiss() - end, + widgets.Label{ + frame={t=0, l=0}, + text_pen=COLOR_YELLOW, + text='Equipment conflict report:', + }, + widgets.Panel{ + frame={t=2, b=7}, + subviews={ + widgets.WrappedLabel{ + frame={t=0}, + text_to_wrap=self.report, + }, + }, }, widgets.WrappedLabel{ - frame={t=2, l=0, r=0}, + frame={b=4, h=2, l=0}, text_pen=COLOR_LIGHTRED, text_to_wrap='After resolving conflicts, be sure to click the "Update equipment" button to reassign new equipment!', + auto_height=false, }, - widgets.WrappedLabel{ - frame={t=4, l=0, r=0}, - text_to_wrap=self.report, + widgets.Panel{ + frame={b=0, w=34, h=3}, + frame_style=gui.FRAME_THIN, + subviews={ + widgets.HotkeyLabel{ + label='Try to resolve conflicts', + key='CUSTOM_CTRL_T', + on_activate=function() + dfhack.run_script('uniform-unstick', '--all', '--drop', '--free') + self.parent_view:dismiss() + end, + }, + }, }, } end @@ -332,7 +345,7 @@ EquipOverlay.ATTRS{ desc='Adds a link to the equip screen to fix equipment conflicts.', default_pos={x=7,y=21}, default_enabled=true, - viewscreens='dwarfmode/SquadEquipment/Default', + viewscreens='dwarfmode/Squads/Equipment/Default', frame={w=MIN_WIDTH, h=1}, }