diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d93eae187..f38681099 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.3 + rev: 0.29.4 hooks: - id: check-github-workflows - repo: https://github.com/Lucas-C/pre-commit-hooks diff --git a/changelog.txt b/changelog.txt index 3809982b0..ac0ad32ec 100644 --- a/changelog.txt +++ b/changelog.txt @@ -28,6 +28,7 @@ Template for new versions: ## New Tools - `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. +- `immortal-cravings`: allow immortals to satisfy their cravings for food and drink ## New Features - `force`: support the ``Wildlife`` event to allow additional wildlife to enter the map @@ -38,6 +39,9 @@ Template for new versions: - `makeown`: halt any hostile jobs the unit may be engaged in, like kidnapping - `fix/loyaltycascade`: allow the fix to work on non-dwarven citizens - `control-panel`: fix setting numeric preferences from the commandline +- `gui/quickfort`: fix build mode evluation rules to allow placement of various furniture and constructions on tiles with stair shapes or without orthagonal floor. +- `emigration`: save-and-reload no longer resets the emigration cycle timeout, making gameplay more consistent +- `rejuvenate`: ``--age`` no longer throws the error ``attempt to compare string with number`` ## Misc Improvements - `control-panel`: Add realistic-melting tweak to control-panel registry diff --git a/docs/immortal-cravings.rst b/docs/immortal-cravings.rst new file mode 100644 index 000000000..dcb3cb13d --- /dev/null +++ b/docs/immortal-cravings.rst @@ -0,0 +1,19 @@ +immortal-cravings +================= + +.. dfhack-tool:: + :summary: Allow immortals to satisfy their cravings for food and drink. + :tags: fort gameplay + +When enabled, this script watches your fort for units that have no physiological +need to eat or drink but still have personality needs that can only be satisfied +by eating or drinking (e.g. necromancers). This enables those units to help +themselves to a drink or a meal when they crave one and are not otherwise +occupied. + +Usage +----- + +:: + + enable immortal-cravings diff --git a/emigration.lua b/emigration.lua index 3721535a3..1d1dea305 100644 --- a/emigration.lua +++ b/emigration.lua @@ -1,18 +1,27 @@ --@module = true --@enable = true +local utils = require('utils') + local GLOBAL_KEY = 'emigration' -- used for state change hooks and persistence -enabled = enabled or false +local function get_default_state() + return {enabled=false, last_cycle_tick=0} +end + +state = state or get_default_state() function isEnabled() - return enabled + return state.enabled end local function persist_state() - dfhack.persistent.saveSiteData(GLOBAL_KEY, {enabled=enabled}) + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) end +local TICKS_PER_MONTH = 33600 +local TICKS_PER_YEAR = 12 * TICKS_PER_MONTH + function desireToStay(unit,method,civ_id) -- on a percentage scale local value = 100 - unit.status.current_soul.personality.stress / 5000 @@ -191,18 +200,26 @@ function checkmigrationnow() else for _, civ_id in pairs(merchant_civ_ids) do checkForDeserters('merchant', civ_id) end end + + state.last_cycle_tick = dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear() end local function event_loop() - if enabled then - checkmigrationnow() - dfhack.timeout(1, 'months', event_loop) + if state.enabled then + local current_tick = dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear() + if current_tick - state.last_cycle_tick < TICKS_PER_MONTH then + local timeout_ticks = state.last_cycle_tick - current_tick + TICKS_PER_MONTH + dfhack.timeout(timeout_ticks, 'ticks', event_loop) + else + checkmigrationnow() + dfhack.timeout(1, 'months', event_loop) + end end end dfhack.onStateChange[GLOBAL_KEY] = function(sc) if sc == SC_MAP_UNLOADED then - enabled = false + state.enabled = false return end @@ -210,8 +227,9 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) return end - local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {enabled=false}) - enabled = persisted_data.enabled + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + event_loop() end @@ -230,11 +248,11 @@ if dfhack_flags and dfhack_flags.enable then end if args[1] == "enable" then - enabled = true + state.enabled = true elseif args[1] == "disable" then - enabled = false + state.enabled = false else - print('emigration is ' .. (enabled and 'enabled' or 'not enabled')) + print('emigration is ' .. (state.enabled and 'enabled' or 'not enabled')) return end diff --git a/geld.lua b/geld.lua index 690d6469d..ff5044449 100644 --- a/geld.lua +++ b/geld.lua @@ -1,6 +1,3 @@ --- Gelds or ungelds animals --- Written by Josh Cooper(cppcooper) on 2019-12-10, last modified: 2020-02-23 - utils = require('utils') local validArgs = utils.invert({ @@ -11,29 +8,11 @@ local validArgs = utils.invert({ 'find', }) local args = utils.processArgs({...}, validArgs) -local help = [====[ - -geld -==== -Geld allows the user to geld and ungeld animals. - -Valid options: - -``-unit ``: Gelds the unit with the specified ID. - This is optional; if not specified, the selected unit is used instead. - -``-ungeld``: Ungelds the specified unit instead (see also `ungeld`). - -``-toggle``: Toggles the gelded status of the specified unit. - -``-help``: Shows this help information - -]====] unit=nil if args.help then - print(help) + print(dfhack.script_help()) return end diff --git a/gui/manipulator.lua b/gui/manipulator.lua index 2e2c901d0..b3d2edcba 100644 --- a/gui/manipulator.lua +++ b/gui/manipulator.lua @@ -1341,9 +1341,12 @@ function ManipulatorOverlay:init() } end -OVERLAY_WIDGETS = { - launcher=ManipulatorOverlay, -} +-- +-- disable overlay widget while tool is still in dark launch mode +-- +-- OVERLAY_WIDGETS = { +-- launcher=ManipulatorOverlay, +-- } if dfhack_flags.module then return end diff --git a/gui/notify.lua b/gui/notify.lua index 39bafe93c..cc469d148 100644 --- a/gui/notify.lua +++ b/gui/notify.lua @@ -132,14 +132,14 @@ DwarfNotifyOverlay.ATTRS{ } local DWARFMODE_CONFLICTING_TOOLTIPS = utils.invert{ - df.main_hover_instruction.InfoUnits, - df.main_hover_instruction.InfoJobs, - df.main_hover_instruction.InfoPlaces, - df.main_hover_instruction.InfoLabors, - df.main_hover_instruction.InfoWorkOrders, - df.main_hover_instruction.InfoNobles, - df.main_hover_instruction.InfoObjects, - df.main_hover_instruction.InfoJustice, + df.main_hover_instruction.MAIN_OPEN_CREATURES, + df.main_hover_instruction.MAIN_OPEN_TASKS, + df.main_hover_instruction.MAIN_OPEN_PLACES, + df.main_hover_instruction.MAIN_OPEN_LABOR, + df.main_hover_instruction.MAIN_OPEN_WORK_ORDERS, + df.main_hover_instruction.MAIN_OPEN_NOBLES, + df.main_hover_instruction.MAIN_OPEN_OBJECTS, + df.main_hover_instruction.MAIN_OPEN_JUSTICE, } local mi = df.global.game.main_interface diff --git a/immortal-cravings.lua b/immortal-cravings.lua new file mode 100644 index 000000000..cb4223811 --- /dev/null +++ b/immortal-cravings.lua @@ -0,0 +1,238 @@ +--@enable = true +--@module = true + +local idle = reqscript('idle-crafting') +local repeatutil = require("repeat-util") +--- utility functions + +---3D city metric +---@param p1 df.coord +---@param p2 df.coord +---@return number +function distance(p1, p2) + return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + math.abs(p1.z - p2.z) +end + +---find closest accessible item in an item vector +---@generic T : df.item +---@param pos df.coord +---@param item_vector T[] +---@param is_good? fun(item: T): boolean +---@return T? +local function findClosest(pos, item_vector, is_good) + local closest = nil + local dclosest = -1 + for _,item in ipairs(item_vector) do + if not item.flags.in_job and (not is_good or is_good(item)) then + local pitem = xyz2pos(dfhack.items.getPosition(item)) + local ditem = distance(pos, pitem) + if dfhack.maps.canWalkBetween(pos, pitem) and (not closest or ditem < dclosest) then + closest = item + dclosest = ditem + end + end + end + return closest +end + +---find a drink +---@param pos df.coord +---@return df.item_drinkst? +local function get_closest_drink(pos) + local is_good = function (drink) + local container = dfhack.items.getContainer(drink) + return container and container:isFoodStorage() + end + return findClosest(pos, df.global.world.items.other.DRINK, is_good) +end + +---find some prepared meal +---@return df.item_foodst? +local function get_closest_meal(pos) + ---@param meal df.item_foodst + local function is_good(meal) + if meal.flags.rotten then + return false + else + local container = dfhack.items.getContainer(meal) + return not container or container:isFoodStorage() + end + end + return findClosest(pos, df.global.world.items.other.FOOD, is_good) +end + +---create a Drink job for the given unit +---@param unit df.unit +local function goDrink(unit) + local drink = get_closest_drink(unit.pos) + if not drink then + -- print('no accessible drink found') + return + end + local job = idle.make_job() + job.job_type = df.job_type.DrinkItem + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(drink) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, drink, df.job_item_ref.T_role.Other, -1, -1) then + error('could not attach drink') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + print(dfhack.df2console('immortal-cravings: %s is getting a drink'):format(name)) +end + +---create Eat job for the given unit +---@param unit df.unit +local function goEat(unit) + local meal = get_closest_meal(unit.pos) + if not meal then + -- print('no accessible meals found') + return + end + local job = idle.make_job() + job.job_type = df.job_type.Eat + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(meal) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, meal, df.job_item_ref.T_role.Other, -1, -1) then + error('could not attach meal') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + print(dfhack.df2console('immortal-cravings: %s is getting something to eat'):format(name)) +end + +--- script logic + +local GLOBAL_KEY = 'immortal-cravings' + +enabled = enabled or false +function isEnabled() + return enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + enabled = persisted_data.enabled or false +end + +DrinkAlcohol = df.need_type.DrinkAlcohol +EatGoodMeal = df.need_type.EatGoodMeal + +---@type integer[] +watched = watched or {} + +local threshold = -9000 + +---unit loop: check for idle watched units and create eat/drink jobs for them +local function unit_loop() + -- print(('immortal-cravings: running unit loop (%d watched units)'):format(#watched)) + ---@type integer[] + local kept = {} + for _, unit_id in ipairs(watched) do + local unit = df.unit.find(unit_id) + if + not unit or not dfhack.units.isActive(unit) or + unit.flags1.caged or unit.flags1.chained + then + goto next_unit + end + if not idle.unitIsAvailable(unit) then + table.insert(kept, unit.id) + else + -- unit is available for jobs; satisfy one of its needs + for _, need in ipairs(unit.status.current_soul.personality.needs) do + if need.id == DrinkAlcohol and need.focus_level < threshold then + goDrink(unit) + break + elseif need.id == EatGoodMeal and need.focus_level < threshold then + goEat(unit) + break + end + end + end + ::next_unit:: + end + watched = kept + if #watched == 0 then + -- print('immortal-cravings: no more watched units, cancelling unit loop') + repeatutil.cancel(GLOBAL_KEY .. '-unit') + end +end + +---main loop: look for citizens with personality needs for food/drink but w/o physiological need +local function main_loop() + -- print('immortal-cravings watching:') + watched = {} + for _, unit in ipairs(dfhack.units.getCitizens()) do + if unit.curse.add_tags1.NO_DRINK or unit.curse.add_tags1.NO_EAT then + for _, need in ipairs(unit.status.current_soul.personality.needs) do + if need.id == DrinkAlcohol and need.focus_level < threshold or + need.id == EatGoodMeal and need.focus_level < threshold + then + table.insert(watched, unit.id) + -- print(' '..dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) + goto next_unit + end + end + end + ::next_unit:: + end + + if #watched > 0 then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-unit', 59, 'ticks', unit_loop) + end +end + +local function start() + if enabled then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-main', 4003, 'ticks', main_loop) + end +end + +local function stop() + repeatutil.cancel(GLOBAL_KEY..'-main') + repeatutil.cancel(GLOBAL_KEY..'-unit') +end + + + +-- script action + +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + enabled = false + -- repeat-util will cancel the loops on unload + return + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + load_state() + start() +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + enabled = true + start() + else + enabled = false + stop() + end + persist_state() +end diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index a6e0301eb..b5e356bf7 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -213,7 +213,7 @@ ConfirmSpec{ message='Are you sure you want to delete this route?', intercept_keys='_MOUSE_L', context='dwarfmode/Hauling', - predicate=function() return mi.current_hover == df.main_hover_instruction.RouteRemove end, + predicate=function() return mi.current_hover == df.main_hover_instruction.HAULING_REMOVE_ROUTE end, pausable=true, } @@ -223,7 +223,7 @@ ConfirmSpec{ message='Are you sure you want to delete this stop?', intercept_keys='_MOUSE_L', context='dwarfmode/Hauling', - predicate=function() return mi.current_hover == df.main_hover_instruction.StopRemove end, + predicate=function() return mi.current_hover == df.main_hover_instruction.HAULING_REMOVE_STOP end, pausable=true, } @@ -234,7 +234,7 @@ ConfirmSpec{ intercept_keys='_MOUSE_L', context='dwarfmode/ViewSheets/BUILDING/TradeDepot', predicate=function() - return mi.current_hover == df.main_hover_instruction.BuildingRemove and has_caravans() + return mi.current_hover == df.main_hover_instruction.BUILDING_SHEET_REMOVE and has_caravans() end, } @@ -244,7 +244,7 @@ ConfirmSpec{ message='Are you sure you want to disband this squad?', intercept_keys='_MOUSE_L', context='dwarfmode/Squads', - predicate=function() return mi.current_hover == df.main_hover_instruction.SquadDisband end, + predicate=function() return mi.current_hover == df.main_hover_instruction.SQUAD_DISBAND end, pausable=true, } @@ -438,7 +438,7 @@ ConfirmSpec{ message='Are you sure you want to remove this manager order?', intercept_keys='_MOUSE_L', context='dwarfmode/Info/WORK_ORDERS/Default', - predicate=function() return mi.current_hover == df.main_hover_instruction.ManagerOrderRemove end, + predicate=function() return mi.current_hover == df.main_hover_instruction.WORK_ORDERS_REMOVE end, pausable=true, } @@ -460,8 +460,8 @@ ConfirmSpec{ intercept_keys='_MOUSE_L', context='dwarfmode/Burrow', predicate=function() - return mi.current_hover == df.main_hover_instruction.BurrowRemove or - mi.current_hover == df.main_hover_instruction.BurrowRemovePaint + return mi.current_hover == df.main_hover_instruction.BURROW_REMOVE_EXISTING or + mi.current_hover == df.main_hover_instruction.BURROW_PAINT_REMOVE end, pausable=true, } @@ -472,7 +472,7 @@ ConfirmSpec{ message='Are you sure you want to remove this stockpile?', intercept_keys='_MOUSE_L', context='dwarfmode/Stockpile', - predicate=function() return mi.current_hover == df.main_hover_instruction.StockpileRemove end, + predicate=function() return mi.current_hover == df.main_hover_instruction.STOCKPILE_REMOVE_EXISTING end, pausable=true, } diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index c71a53f07..548057b32 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -119,6 +119,7 @@ COMMANDS_BY_IDX = { {command='fastdwarf', group='gameplay', mode='enable'}, {command='hermit', group='gameplay', mode='enable'}, {command='hide-tutorials', group='gameplay', mode='system_enable'}, + {command='immortal-cravings', group='gameplay', mode='enable'}, {command='light-aquifers-only', group='gameplay', mode='run'}, {command='misery', group='gameplay', mode='enable'}, {command='orders-reevaluate', help_command='orders', group='gameplay', mode='repeat', diff --git a/internal/quickfort/build.lua b/internal/quickfort/build.lua index e2f65ad70..fa053043f 100644 --- a/internal/quickfort/build.lua +++ b/internal/quickfort/build.lua @@ -139,7 +139,7 @@ local function is_valid_tile_bridge(pos, db_entry, b) (dir == T_direction.Right and pos.x == b.pos.x+b.width-1) then return is_valid_tile_has_space(pos) end - return is_valid_tile_has_space_or_is_ramp(pos) + return is_valid_tile_machine(pos) end -- although vanilla allows constructions to be built on top of constructed @@ -213,7 +213,7 @@ local function is_tile_coverable(pos) shape ~= df.tiletype_shape.STAIR_DOWN) then return false end - return is_tile_floor_adjacent(pos) + return true end -- diff --git a/modtools/moddable-gods.lua b/modtools/moddable-gods.lua index a0f8f7694..5d783f397 100644 --- a/modtools/moddable-gods.lua +++ b/modtools/moddable-gods.lua @@ -83,7 +83,7 @@ godFig.caste = 0 godFig.sex = gender godFig.name.first_name = args.name for _,sphere in ipairs(args.spheres) do - godFig.info.spheres.spheres:insert('#',df.sphere_type[sphere]) + godFig.info.metaphysical.spheres:insert('#',df.sphere_type[sphere]) end df.global.world.history.figures:insert('#',godFig) diff --git a/rejuvenate.lua b/rejuvenate.lua index 04c79cdf5..a2e36c86e 100644 --- a/rejuvenate.lua +++ b/rejuvenate.lua @@ -84,7 +84,7 @@ local 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, false, args.force, args['dry-run'], args.age) + rejuvenate(u, false, args.force, args['dry-run'], tonumber(args.age)) end end diff --git a/ungeld.lua b/ungeld.lua index b3736974d..0ea29c2a4 100644 --- a/ungeld.lua +++ b/ungeld.lua @@ -1,28 +1,13 @@ --- Ungelds animals --- Written by Josh Cooper(cppcooper) on 2019-12-10, last modified: 2020-02-23 utils = require('utils') + local validArgs = utils.invert({ 'unit', 'help', }) local args = utils.processArgs({...}, validArgs) -local help = [====[ - -ungeld -====== -A wrapper around `geld` that ungelds the specified animal. - -Valid options: - -``-unit ``: Ungelds the unit with the specified ID. - This is optional; if not specified, the selected unit is used instead. - -``-help``: Shows this help information - -]====] if args.help then - print(help) + print(dfhack.script_help()) return end