diff --git a/changelog.txt b/changelog.txt index 1ed30ae501..c2284bbe88 100644 --- a/changelog.txt +++ b/changelog.txt @@ -32,6 +32,8 @@ Template for new versions: ## New Features +- `gui/notify`: new `mandates_expiring_early` notification warns when a production mandate is within 3 months of expiring (disabled by default) + ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record - `fix/loyaltycascade`: guard against citizens that are not historical figures and emit a warning. diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua index 108fe92f97..3cc8aecdd4 100644 --- a/internal/notify/notifications.lua +++ b/internal/notify/notifications.lua @@ -421,6 +421,19 @@ function Cache_nemesis_all:get_affected_unit_ids() return self.ATTRS.affected_unit_ids end +-- mandate.timeout_counter increments once per 10 frames +local function count_expiring_mandates(remaining) + local count = 0 + for _, mandate in ipairs(df.global.world.mandates.all) do + if mandate.mode == df.mandate_type.Make and + mandate.timeout_limit - mandate.timeout_counter < remaining + then + count = count + 1 + end + end + return count +end + -- the order of this list controls the order the notifications will appear in the overlay NOTIFICATIONS_BY_IDX = { { @@ -585,14 +598,7 @@ NOTIFICATIONS_BY_IDX = { desc='Notifies when a production mandate is within 1 month of expiring.', default=true, dwarf_fn=function() - local count = 0 - for _, mandate in ipairs(df.global.world.mandates.all) do - if mandate.mode == df.mandate_type.Make and - mandate.timeout_limit - mandate.timeout_counter < 2500 - then - count = count + 1 - end - end + local count = count_expiring_mandates(2500) if count > 0 then return ('%d production mandate%s near deadline'):format( count, @@ -604,6 +610,23 @@ NOTIFICATIONS_BY_IDX = { gui.simulateInput(dfhack.gui.getDFViewscreen(), 'D_NOBLES') end, }, + { + name='mandates_expiring_early', + desc='Notifies when a production mandate is within 3 months of expiring.', + default=false, + dwarf_fn=function() + local count = count_expiring_mandates(10080) + if count > 0 then + return ('%d production mandate%s expire within 3 months'):format( + count, + count == 1 and '' or 's' + ) + end + end, + on_click=function() + gui.simulateInput(dfhack.gui.getDFViewscreen(), 'D_NOBLES') + end, + }, { name='petitions_agreed', desc='Notifies when you have agreed to build (but have not yet built) a guildhall or temple.', diff --git a/test/notify.lua b/test/notify.lua new file mode 100644 index 0000000000..35e290a636 --- /dev/null +++ b/test/notify.lua @@ -0,0 +1,77 @@ +config = { + target = 'gui/notify', + mode = 'fortress', +} + +local notifications = reqscript('internal/notify/notifications') + +local all_mandates = df.global.world.mandates.all + +-- mandate.timeout_counter increments once per 10 frames +local NEAR_THRESHOLD = 2500 -- ~3 weeks +local EARLY_THRESHOLD = 10080 -- 3 months + +local function add_mandate(remaining) + local m = df.mandate:new() + m.mode = df.mandate_type.Make + m.timeout_counter = 0 + m.timeout_limit = remaining + all_mandates:insert('#', m) +end + +local function remove_added_mandates(orig_size) + for i = #all_mandates - 1, orig_size, -1 do + all_mandates:erase(i) + end +end + +local function window_count(remaining) + local count = 0 + for _, m in ipairs(all_mandates) do + if m.mode == df.mandate_type.Make and + m.timeout_limit - m.timeout_counter < remaining + then + count = count + 1 + end + end + return count +end + +local function expected_msg(count, suffix) + if count == 0 then return nil end + return ('%d production mandate%s %s'):format( + count, count == 1 and '' or 's', suffix) +end + +local function expect_mandate_msgs(near_count, early_count) + local near = notifications.NOTIFICATIONS_BY_NAME.mandates_expiring + local early = notifications.NOTIFICATIONS_BY_NAME.mandates_expiring_early + expect.eq(expected_msg(near_count, 'near deadline'), near.dwarf_fn()) + expect.eq(expected_msg(early_count, 'expire within 3 months'), + early.dwarf_fn()) +end + +function test.mandates_expiring() + local orig_size = #all_mandates + return dfhack.with_finalize( + function() remove_added_mandates(orig_size) end, + function() + -- the fort may already have real mandates; measure baselines + local base_near = window_count(NEAR_THRESHOLD) + local base_early = window_count(EARLY_THRESHOLD) + + expect_mandate_msgs(base_near, base_early) + + -- ~1.5 months remaining: early notification only + add_mandate(5000) + expect_mandate_msgs(base_near, base_early + 1) + + -- ~8 days remaining: both notifications + add_mandate(1000) + expect_mandate_msgs(base_near + 1, base_early + 2) + + -- ~4.5 months remaining: neither notification + add_mandate(15000) + expect_mandate_msgs(base_near + 1, base_early + 2) + end) +end