diff --git a/changelog.txt b/changelog.txt index 49a241e338..d5f006fd7a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,7 @@ Template for new versions: # Future ## New Tools +- `deteriorate`: (reinstated) allow corpses, body parts, food, and/or damaged clothes to rot away - `modtools/moddable-gods`: (reinstated) create new deities from scratch ## New Features diff --git a/deteriorate.lua b/deteriorate.lua index ddd6c0f872..2d6aa0164a 100644 --- a/deteriorate.lua +++ b/deteriorate.lua @@ -1,88 +1,142 @@ -- Cause selected item types to quickly rot away --@module = true +--@enable = true local argparse = require('argparse') local utils = require('utils') -local function get_clothes_vectors() - return {df.global.world.items.other.GLOVES, - df.global.world.items.other.ARMOR, - df.global.world.items.other.SHOES, - df.global.world.items.other.PANTS, - df.global.world.items.other.HELM} +-------------------- +-- state + +local GLOBAL_KEY = 'deteriorate' + +local categories = { + 'clothes', + 'food', + 'corpses', + 'usable-parts', + 'unusable-parts', +} + +local aliases = { + parts={'usable-parts', 'unusable-parts'}, + all=categories, +} + +local function get_default_state() + local default_state = { + enabled=false, + categories={}, + } + for _,category in ipairs(categories) do + local default_enabled = category == 'corpses' or category == 'unusable-parts' + default_state.categories[category] = { + enabled=default_enabled, + frequency=1, + last_cycle_tick=0, + } + end + return default_state end -local function get_corpse_vectors() - return {df.global.world.items.other.ANY_CORPSE} +state = state or get_default_state() + +function isEnabled() + return state.enabled end -local function get_remains_vectors() - return {df.global.world.items.other.REMAINS} +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +----------------------- +-- deterioration logic + +local function get_clothes_vectors() + return { + df.global.world.items.other.GLOVES, + df.global.world.items.other.ARMOR, + df.global.world.items.other.SHOES, + df.global.world.items.other.PANTS, + df.global.world.items.other.HELM, + } end local function get_food_vectors() - return {df.global.world.items.other.FISH, - df.global.world.items.other.FISH_RAW, - df.global.world.items.other.EGG, - df.global.world.items.other.CHEESE, - df.global.world.items.other.PLANT, - df.global.world.items.other.PLANT_GROWTH, - df.global.world.items.other.FOOD} + return { + df.global.world.items.other.FISH, + df.global.world.items.other.FISH_RAW, + df.global.world.items.other.EGG, + df.global.world.items.other.CHEESE, + df.global.world.items.other.PLANT, + df.global.world.items.other.PLANT_GROWTH, + df.global.world.items.other.FOOD, + df.global.world.items.other.MEAT, + df.global.world.items.other.LIQUID_MISC, + } +end + +local function get_corpse_vectors() + return { + df.global.world.items.other.CORPSE, + df.global.world.items.other.REMAINS, + } +end + +local function get_parts_vectors() + return { + df.global.world.items.other.CORPSEPIECE, + } end local function is_valid_clothing(item) + -- includes discarded owned clothes return item.subtype.armorlevel == 0 and item.flags.on_ground and item.wear > 0 end -local function keep_usable(opts, item) - return opts.keep_usable and ( - not item.corpse_flags.unbutchered and ( - item.corpse_flags.bone or - item.corpse_flags.horn or - item.corpse_flags.leather or - item.corpse_flags.skull or - item.corpse_flags.tooth) or ( - item.corpse_flags.hair_wool or - item.corpse_flags.pearl or - item.corpse_flags.plant or - item.corpse_flags.shell or - item.corpse_flags.silk or - item.corpse_flags.yarn) ) +local function is_valid_food(item) + if not df.item_liquid_miscst:is_instance(item) then + return true + end + local mi = dfhack.matinfo.decode(item) + return mi:getToken():endswith(':MILK') end -local function is_valid_corpse(opts, item) - -- check if the corpse is a resident of the fortress and is not keep_usable - local unit = df.unit.find(item.unit_id) - if not unit then - return not keep_usable(opts, item) - end - local hf = df.historical_figure.find(unit.hist_figure_id) - if not hf then - return not keep_usable(opts, item) - end - for _,link in ipairs(hf.entity_links) do - if link.entity_id == df.global.plotinfo.group_id and df.histfig_entity_link_type[link:getType()] == 'MEMBER' then - return false - end - end - return not keep_usable(opts, item) +-- TODO: is just checking in_building sufficient, or do we need to validate +-- that the building it is in is a coffin? +local function is_entombed(item) + return item.flags.in_building +end + +local function is_valid_corpse(item) + return not is_entombed(item) +end + +local function is_usable_corpse_piece(item) + return item.corpse_flags.hair_wool or + item.corpse_flags.pearl or + item.corpse_flags.plant or + item.corpse_flags.shell or + item.corpse_flags.silk or + item.corpse_flags.yarn end -local function is_valid_remains(opts, item) - return true +local function is_valid_usable_corpse_piece(item) + return not is_entombed(item) and is_usable_corpse_piece(item) end -local function is_valid_food(opts, item) - return true +local function is_valid_unusable_corpse_piece(item) + return not is_entombed(item) and not is_usable_corpse_piece(item) end +-- different algorithm for clothes so they rot away when they become tattered local function increment_clothes_wear(item) item.wear_timer = math.ceil(item.wear_timer * (item.wear + 0.5)) return item.wear > 2 end -local function increment_generic_wear(item, threshold) +local function increment_wear(threshold, item) item.wear_timer = item.wear_timer + 1 if item.wear_timer > threshold then item.wear_timer = 0 @@ -91,211 +145,229 @@ local function increment_generic_wear(item, threshold) return item.wear > 3 end -local function increment_corpse_wear(item) - return increment_generic_wear(item, 24) -end - -local function increment_remains_wear(item) - return increment_generic_wear(item, 6) -end - -local function increment_food_wear(item) - return increment_generic_wear(item, 24) -end - -local function deteriorate(opts, get_item_vectors_fn, is_valid_fn, increment_wear_fn) - local count = 0 +local function deteriorate_items(now, get_item_vectors_fn, is_valid_fn, increment_wear_fn) + local items_to_remove = {} for _,v in ipairs(get_item_vectors_fn()) do for _,item in ipairs(v) do - if is_valid_fn(opts, item) and increment_wear_fn(item) - and not item.flags.garbage_collect then - dfhack.items.remove(item) - count = count + 1 + if is_valid_fn(item) and (now or increment_wear_fn(item)) and not item.flags.garbage_collect then + table.insert(items_to_remove, item) end end end - return count + for _,item in ipairs(items_to_remove) do + print(('deteriorate: %s crumbles away to dust'):format(dfhack.items.getReadableDescription(item))) + dfhack.items.remove(item) + end + return #items_to_remove end -local function always_worn() - return true +local function mk_deteriorate_fn(get_item_vectors_fn, is_valid_fn, increment_wear_fn) + return function(now) + return deteriorate_items(now, get_item_vectors_fn, is_valid_fn, increment_wear_fn) + end end -local function deteriorate_clothes(opts, now) - return deteriorate(opts, get_clothes_vectors, is_valid_clothing, - now and always_worn or increment_clothes_wear) -end +local category_fns = { + clothes=mk_deteriorate_fn(get_clothes_vectors, is_valid_clothing, increment_clothes_wear), + food=mk_deteriorate_fn(get_food_vectors, is_valid_food, curry(increment_wear, 24)), + corpses=mk_deteriorate_fn(get_corpse_vectors, is_valid_corpse, curry(increment_wear, 24)), + ['usable-parts']=mk_deteriorate_fn(get_parts_vectors, is_valid_usable_corpse_piece, curry(increment_wear, 24)), + ['unusable-parts']=mk_deteriorate_fn(get_parts_vectors, is_valid_unusable_corpse_piece, curry(increment_wear, 24)), +} -local function deteriorate_corpses(opts, now) - return deteriorate(opts, get_corpse_vectors, is_valid_corpse, - now and always_worn or increment_corpse_wear) - + deteriorate(opts, get_remains_vectors, is_valid_remains, - now and always_worn or increment_remains_wear) -end +---------------------------- +-- cycle and timer logic -local function deteriorate_food(opts, now) - return deteriorate(opts, get_food_vectors, is_valid_food, - now and always_worn or increment_food_wear) +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_YEAR = 12 * TICKS_PER_MONTH + +local function get_normalized_tick() + return dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear() end -local type_fns = { - clothes=deteriorate_clothes, - corpses=deteriorate_corpses, - food=deteriorate_food, -} +timeout_ids = timeout_ids or {} --- maps the type string to {id=int, time=int, timeunit=string} -timeout_ids = timeout_ids or { - clothes={}, - corpses={}, - food={}, -} +local function event_loop(category) + local category_data = state.categories[category] + if not state.enabled or not category_data.enabled then return end -local function _stop(item_type) - local timeout_id = timeout_ids[item_type].id - if timeout_id then - dfhack.timeout_active(timeout_id, nil) -- cancel callback - timeout_ids[item_type].id = nil - return true + local current_tick = get_normalized_tick() + local ticks_per_cycle = math.max(1, math.floor(TICKS_PER_DAY * category_data.frequency)) + local timeout_ticks = ticks_per_cycle + + if current_tick - category_data.last_cycle_tick < ticks_per_cycle then + timeout_ticks = category_data.last_cycle_tick - current_tick + ticks_per_cycle + else + category_fns[category](false) + category_data.last_cycle_tick = current_tick + persist_state() end + timeout_ids[category] = dfhack.timeout(timeout_ticks, 'ticks', curry(event_loop, category)) end -local function make_timeout_cb(item_type, opts) - local fn - fn = function(first_time) - local timeout_data = timeout_ids[item_type] - timeout_data.time, timeout_data.mode = opts.time, opts.mode - timeout_data.id = dfhack.timeout(opts.time, opts.mode, fn) - if not timeout_ids[item_type].id then - print('Map has been unloaded; stopping deteriorate') - for k in pairs(type_fns) do - _stop(k) - end - return - end - if not first_time then - local count = type_fns[item_type](opts) - if count > 0 then - print(('Deteriorated %d %s'):format(count, item_type)) - end - end - end - return fn +-- launches timer. first cycle will be after the configured frequency +local function start_category(category, category_data, current_tick) + category_data = category_data or state.categories[category] + category_data.last_cycle_tick = current_tick or get_normalized_tick() + event_loop(category) end -local function start(opts) - for _,v in ipairs(opts.types) do - _stop(v) - if not opts.quiet then - print(('Deterioration of %s commencing...'):format(v)) - end - -- create a callback and call it to make it register itself - make_timeout_cb(v, opts)(true) +local function stop_category(category) + local timeout_id = timeout_ids[category] + if timeout_id then + dfhack.timeout_active(timeout_id, nil) -- cancel callback + timeout_ids[category] = nil end end -local function stop(opts) - for _,v in ipairs(opts.types) do - if _stop(v) and not opts.quiet then - print('Stopped deteriorating ' .. v) +local function do_enable() + if state.enabled then return end + + state.enabled = true + local current_tick = get_normalized_tick() + for _,category in ipairs(categories) do + local category_data = state.categories[category] + if category_data.enabled then + start_category(category, category_data, current_tick) end end end -local function status() - for k in pairs(type_fns) do - local timeout_data = timeout_ids[k] - local status_str = 'Stopped' - if timeout_data.id then - local time, mode = timeout_data.time, timeout_data.mode - if time == 1 then - mode = mode:sub(1, #mode - 1) -- make singular - end - status_str = ('Running (every %s %s)') :format(time, mode) - end - print(('%7s:\t%s'):format(k, status_str)) +local function do_disable() + if not state.enabled then return end + + state.enabled = false + for _,category in ipairs(categories) do + stop_category(category) end end -local function now(opts) - for _,v in ipairs(opts.types) do - local count = type_fns[v](opts, true) - if not opts.quiet then - print(('Deteriorated %d %s'):format(count, v)) - end +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return end -end -local function help() - print(dfhack.script_help()) + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end + + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + + event_loop() end +--------------------- +-- CLI + if dfhack_flags.module then return end -if not dfhack.isMapLoaded() then - qerror('deteriorate needs a fortress map to be loaded.') +if dfhack_flags.enable then + if dfhack_flags.enable_state then + do_enable() + else + do_disable() + end end -local command_switch = { - start=start, - stop=stop, - status=status, - now=now, -} - -local valid_timeunits = utils.invert{'days', 'months', 'years'} - -local function parse_freq(arg) - local elems = argparse.stringList(arg) - local num = tonumber(elems[1]) - if not num or num <= 0 then - qerror('number parameter for --freq option must be greater than 0') +local function parse_categories(arg) + local list = {} + for _,v in ipairs(argparse.stringList(arg)) do + if aliases[v] then + for _,alias in ipairs(aliases[v]) do + table.insert(list, alias) + end + elseif category_fns[v] then + table.insert(list, v) + else + qerror(('unrecognized category: "%s"'):format(v)) + end end - if #elems == 1 then - return num, 'days' + if #list == 0 then + qerror('no categories specified') end - local timeunit = elems[2]:lower() - if valid_timeunits[timeunit] then return num, timeunit end - timeunit = timeunit .. 's' -- it's ok if the user specified a singular - if valid_timeunits[timeunit] then return num, timeunit end - qerror(('invalid time unit: "%s"'):format(elems[2])) + return list end -local function parse_types(arg) - local types = argparse.stringList(arg) - for _,v in ipairs(types) do - if not type_fns[v] then - qerror(('unrecognized type: "%s"'):format(v)) +local function status() + local running_str = state.enabled and 'Running' or 'Would run' + print(('deteriorate is %s'):format(state.enabled and 'enabled' or 'disabled')) + print() + for _,category in pairs(categories) do + local status_str = 'Stopped' + local category_data = state.categories[category] + if category_data.enabled then + status_str = ('%s every %s day%s') :format(running_str, + category_data.frequency, category_data.frequency == 1 and '' or 's') end + print(('%18s: %s'):format(category, status_str)) end - return types end -local opts = { - time = 1, - mode = 'days', - quiet = false, - types = {}, - keep_usable = false, - help = false, -} +local help = false -local nonoptions = argparse.processArgsGetopt({...}, { - {'f', 'freq', 'frequency', hasArg=true, - handler=function(optarg) opts.time,opts.mode = parse_freq(optarg) end}, - {'h', 'help', handler=function() opts.help = true end}, - {'q', 'quiet', handler=function() opts.quiet = true end}, - {'k', 'keep-usable', handler=function() opts.keep_usable = true end}, - {'t', 'types', hasArg=true, - handler=function(optarg) opts.types = parse_types(optarg) end}}) +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, +}) -local command = nonoptions[1] -if not command or not command_switch[command] then opts.help = true end +local command = table.remove(positionals, 1) +if command == 'help' or help then + print(dfhack.script_help()) + return +end -if not opts.help and command ~= 'status' and #opts.types == 0 then - qerror('no item types specified! try adding a --types parameter.') +if not command or command == 'status' then + status() +elseif command == 'enable' then + local cats = parse_categories(positionals[1]) + for _,v in ipairs(cats) do + if state.categories[v].enabled then + goto continue + end + state.categories[v].enabled = true + if state.enabled then + start_category(v) + end + ::continue:: + end +elseif command == 'disable' then + local cats = parse_categories(positionals[1]) + for _,v in ipairs(cats) do + if not state.categories[v].enabled then + goto continue + end + state.categories[v].enabled = false + if state.enabled then + stop_category(v) + end + ::continue:: + end +elseif command == 'frequency' or command == 'freq' then + local freq = tonumber(positionals[1]) + if not freq or freq <= 0 then + qerror('frequency must be greater than 0') + end + local cats = parse_categories(positionals[2]) + for _,v in ipairs(cats) do + state.categories[v].frequency = freq + if state.enabled then + stop_category(v) + start_category(v) + end + end +elseif command == 'now' then + local cats = parse_categories(positionals[1]) + local count = 0 + for _,v in ipairs(cats) do + count = count + category_fns[v](true) + end + print(('Deteriorated %d item%s'):format(count, count == 1 and '' or 's')) +else + qerror('unrecognized command: "' .. command .. '"') end -(command_switch[command] or help)(opts) +persist_state() diff --git a/docs/deteriorate.rst b/docs/deteriorate.rst index 583150afdb..f97cc3b089 100644 --- a/docs/deteriorate.rst +++ b/docs/deteriorate.rst @@ -3,11 +3,12 @@ deteriorate .. dfhack-tool:: :summary: Cause corpses, clothes, and/or food to rot away over time. - :tags: unavailable + :tags: fort fps gameplay items When enabled, this script will cause the specified item types to slowly rot -away. By default, items disappear after a few months, but you can choose to slow -this down or even make things rot away instantly! +away. As they deteriorate, they will acquire the normal ``x``, ``X``, and +``XX`` markings. By default, items disappear after a few months, but you can +choose to slow this down or even make things rot away instantly! Now all those slightly worn wool shoes that dwarves scatter all over the place or the toes, teeth, fingers, and limbs from the last undead siege will @@ -15,61 +16,63 @@ deteriorate at a greatly increased rate, and eventually just crumble into nothing. As warm and fuzzy as a dining room full of used socks makes your dwarves feel, your FPS does not like it! +By default (if you run ``enable deteriorate`` without changing any settings), +only non-entombed corpses and non-usable body parts will be affected. + +You can set other common options for new forts on the Gameplay / Autostart tab +of the DFHack control panel. + Usage ----- -``deteriorate start --types [--freq ] [--quiet] [--keep-usable]`` - Starts deteriorating the specified item types while you play. -``deteriorate stop --types `` - Stops deteriorating the specified item types. -``deteriorate status`` - Shows the item types that are currently being monitored and their - deterioration frequencies. -``deteriorate now --types [--quiet] [--keep-usable]`` - Causes all items (of the specified item types) to rot away within a few - ticks. - -You can have different types of items rotting away at different rates by running -``deteriorate start`` multiple times with different options. +:: + + enable deteriorate + deteriorate [status] + deteriorate (enable|disable) + deteriorate frequency + deteriorate now + +Where ```` is a comma-separated list of item types to affect. The +following categories are available: + +:clothes: All non-armor clothing pieces that are lying on the ground + that already have some damage. +:food: All food and plants. Milk is included, but seeds are left + untouched. +:corpses: All vermin remains and non-entombed corpses. This includes + former members of your fort, so if this category is enabled, + dwarves that have fallen down your well will rot away with + time. +:usable-parts: Non-entombed body parts that can be used for manufacturing, + crafting, or suturing (e.g. hair, wool, skulls, horns, etc.). +:unusable-parts: Non-entombed body parts that can't be used for manufacturing + or crafting. +:parts: Shorthand for the combination of the above two categories. +:all: Shorthand for all of the above categories. + +When setting a frequency, the number indicates the number of days between +adjustments of the deterioration counter. The default frequency of 1 day will +result in items disappearing after several months. The number does not need to +be a whole number. E.g. ``deteriorate frequency 0.5 all`` is perfectly valid. Examples -------- -Start deteriorating corpses and body parts, keeping usable parts such as hair, wool:: - - deteriorate start --types corpses --keep-usable - -Start deteriorating corpses and food and do it at twice the default rate:: - - deteriorate start --types corpses,food --freq 0.5,days - -Deteriorate corpses quickly but clothes slowly:: - - deteriorate start -tcorpses -f0.1 - deteriorate start -tclothes -f3,months - -Options -------- - -``-f``, ``--freq``, ``--frequency [,]`` - How often to increment the wear counters. ```` can be one of - ``days``, ``months``, or ``years`` and defaults to ``days`` if not - specified. The default frequency of 1 day will result in items disappearing - after several months. The number does not need to be a whole number. E.g. - ``--freq=0.5,days`` is perfectly valid. -``-k``, ``--keep-usable`` - Keep usable body parts such as hair, wool, hooves, bones, and skulls. -``-q``, ``--quiet`` - Silence non-error output. -``-t``, ``--types `` - The comma-separated list of item types to affect. This option is required - for ``start``, ``stop``, and ``now`` commands. - -Types ------ - -:clothes: All clothing pieces that have an armor rating of 0 and are lying on - the ground. -:corpses: All resident corpses and body parts. -:food: All food and plants, regardless of whether they are in barrels or - stockpiles. Seeds are left untouched. +``enable deteriorate`` + Start deteriorating items with current settings. +``deteriorate status`` + Show the current settings. +``deteriorate enable corpses,parts`` + Deteriorate corpses and body parts. This includes potentially useful parts + such as hair or wool, so use them quickly or lose them! +``deteriorate frequency 0.5 all`` + Deteriorate items of the enabled categories at twice the default rate. +``deteriorate frequency 14 clothes`` + Deteriorate clothes very slowly. +``deteriorate now corpses,unusable-parts`` + Deteriorate corpses and unusable body parts immediately. This is useful for + cleaning up after a siege or ambush (maybe after you have buried your own + casualties). +``deteriorate now food`` + Deteriorate all food items immediately. Instant famine! diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 37cd56c4e2..76fbee5c10 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -116,6 +116,13 @@ COMMANDS_BY_IDX = { {command='combine', group='gameplay', mode='repeat', desc='Combine partial stacks in stockpiles into full stacks.', params={'--time', '7', '--timeUnits', 'days', '--command', '[', 'combine', 'all', '-q', ']'}}, + {command='deteriorate', group='gameplay', mode='enable'}, + {command='deteriorate enable all', group='gameplay', mode='run', + desc='Enable if you want deteriorate to run on all supported categories instead of just corpses.'}, + {command='deteriorate frequency 0.15 all', group='gameplay', mode='run', + desc='Enable if you want to slow item deterioration down so they take about a year to rot away.'}, + {command='deteriorate frequency 3 all', group='gameplay', mode='run', + desc='Enable if you want to speed item deterioration up so they take less than a month to rot away.'}, {command='dwarfvet', group='gameplay', mode='enable'}, {command='eggs-fertile', help_command='tweak', group='gameplay', mode='tweak', default=true, desc='Displays an indicator on fertile eggs.'},