From 48f8ee6ef41ca74a9266098ab307aa0e0ab8cb25 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Mon, 12 Aug 2024 10:27:27 -0700 Subject: [PATCH] make it much harder to accidentally offend the elves --- caravan.lua | 1 + changelog.txt | 3 + internal/caravan/common.lua | 116 ++++++++------ internal/caravan/movegoods.lua | 233 ++++++++++++++++----------- internal/caravan/trade.lua | 277 +++++++++++++++++++++++++++++---- internal/confirm/specs.lua | 53 ++++--- 6 files changed, 499 insertions(+), 184 deletions(-) diff --git a/caravan.lua b/caravan.lua index 7c434c3cbb..2d3a0cfb79 100644 --- a/caravan.lua +++ b/caravan.lua @@ -18,6 +18,7 @@ end OVERLAY_WIDGETS = { trade=trade.TradeOverlay, tradebanner=trade.TradeBannerOverlay, + tradeethics=trade.TradeEthicsWarningOverlay, tradeagreement=tradeagreement.TradeAgreementOverlay, movegoods=movegoods.MoveGoodsOverlay, movegoods_hider=movegoods.MoveGoodsHiderOverlay, diff --git a/changelog.txt b/changelog.txt index 059011337e..ff92542951 100644 --- a/changelog.txt +++ b/changelog.txt @@ -30,6 +30,9 @@ Template for new versions: - `embark-anyone`: allows you to embark as any civilisation, including dead, and non-dwarven ones ## 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 ## Fixes - `timestream`: ensure child growth events (e.g. becoming an adult) are not skipped over diff --git a/internal/caravan/common.lua b/internal/caravan/common.lua index 056ab1e2d2..cbf95eb809 100644 --- a/internal/caravan/common.lua +++ b/internal/caravan/common.lua @@ -111,10 +111,10 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_C', key='CUSTOM_SHIFT_V', options={ - {label='XXTatteredXX', value=3}, - {label='XFrayedX', value=2}, - {label='xWornx', value=1}, - {label='Pristine', value=0}, + {label='XXTatteredXX', value=3, pen=COLOR_BROWN}, + {label='XFrayedX', value=2, pen=COLOR_LIGHTRED}, + {label='xWornx', value=1, pen=COLOR_YELLOW}, + {label='Pristine', value=0, pen=COLOR_GREEN}, }, initial_option=3, on_change=function(val) @@ -132,10 +132,10 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_E', key='CUSTOM_SHIFT_R', options={ - {label='XXTatteredXX', value=3}, - {label='XFrayedX', value=2}, - {label='xWornx', value=1}, - {label='Pristine', value=0}, + {label='XXTatteredXX', value=3, pen=COLOR_BROWN}, + {label='XFrayedX', value=2, pen=COLOR_LIGHTRED}, + {label='xWornx', value=1, pen=COLOR_YELLOW}, + {label='Pristine', value=0, pen=COLOR_GREEN}, }, initial_option=0, on_change=function(val) @@ -160,7 +160,7 @@ function get_slider_widgets(self, suffix) }, }, widgets.Panel{ - frame={t=5, l=0, r=0, h=4}, + frame={t=6, l=0, r=0, h=4}, subviews={ widgets.CycleHotkeyLabel{ view_id='min_quality'..suffix, @@ -170,13 +170,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_Z', key='CUSTOM_SHIFT_X', options={ - {label='Ordinary', value=0}, - {label='-Well Crafted-', value=1}, - {label='+Finely Crafted+', value=2}, - {label='*Superior*', value=3}, - {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4}, - {label=CH_MONEY..'Masterful'..CH_MONEY, value=5}, - {label='Artifact', value=6}, + {label='Ordinary', value=0, pen=COLOR_GRAY}, + {label='-Well Crafted-', value=1, pen=COLOR_LIGHTBLUE}, + {label='+Fine Crafted+', value=2, pen=COLOR_BLUE}, + {label='*Superior*', value=3, pen=COLOR_YELLOW}, + {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4, pen=COLOR_BROWN}, + {label=CH_MONEY..'Masterful'..CH_MONEY, value=5, pen=COLOR_MAGENTA}, + {label='Artifact', value=6, pen=COLOR_GREEN}, }, initial_option=0, on_change=function(val) @@ -194,13 +194,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_Q', key='CUSTOM_SHIFT_W', options={ - {label='Ordinary', value=0}, - {label='-Well Crafted-', value=1}, - {label='+Finely Crafted+', value=2}, - {label='*Superior*', value=3}, - {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4}, - {label=CH_MONEY..'Masterful'..CH_MONEY, value=5}, - {label='Artifact', value=6}, + {label='Ordinary', value=0, pen=COLOR_GRAY}, + {label='-Well Crafted-', value=1, pen=COLOR_LIGHTBLUE}, + {label='+Fine Crafted+', value=2, pen=COLOR_BLUE}, + {label='*Superior*', value=3, pen=COLOR_YELLOW}, + {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4, pen=COLOR_BROWN}, + {label=CH_MONEY..'Masterful'..CH_MONEY, value=5, pen=COLOR_MAGENTA}, + {label='Artifact', value=6, pen=COLOR_GREEN}, }, initial_option=6, on_change=function(val) @@ -225,7 +225,7 @@ function get_slider_widgets(self, suffix) }, }, widgets.Panel{ - frame={t=10, l=0, r=0, h=4}, + frame={t=12, l=0, r=0, h=4}, subviews={ widgets.CycleHotkeyLabel{ view_id='min_value'..suffix, @@ -235,14 +235,14 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_B', key='CUSTOM_SHIFT_N', options={ - {label='1'..CH_MONEY, value={index=1, value=1}}, - {label='20'..CH_MONEY, value={index=2, value=20}}, - {label='50'..CH_MONEY, value={index=3, value=50}}, - {label='100'..CH_MONEY, value={index=4, value=100}}, - {label='500'..CH_MONEY, value={index=5, value=500}}, - {label='1000'..CH_MONEY, value={index=6, value=1000}}, + {label='1'..CH_MONEY, value={index=1, value=1}, pen=COLOR_BROWN}, + {label='20'..CH_MONEY, value={index=2, value=20}, pen=COLOR_BROWN}, + {label='50'..CH_MONEY, value={index=3, value=50}, pen=COLOR_BROWN}, + {label='100'..CH_MONEY, value={index=4, value=100}, pen=COLOR_BROWN}, + {label='500'..CH_MONEY, value={index=5, value=500}, pen=COLOR_BROWN}, + {label='1000'..CH_MONEY, value={index=6, value=1000}, pen=COLOR_BROWN}, -- max "min" value is less than max "max" value since the range of inf - inf is not useful - {label='5000'..CH_MONEY, value={index=7, value=5000}}, + {label='5000'..CH_MONEY, value={index=7, value=5000}, pen=COLOR_BROWN}, }, initial_option=1, on_change=function(val) @@ -260,13 +260,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_T', key='CUSTOM_SHIFT_Y', options={ - {label='1'..CH_MONEY, value={index=1, value=1}}, - {label='20'..CH_MONEY, value={index=2, value=20}}, - {label='50'..CH_MONEY, value={index=3, value=50}}, - {label='100'..CH_MONEY, value={index=4, value=100}}, - {label='500'..CH_MONEY, value={index=5, value=500}}, - {label='1000'..CH_MONEY, value={index=6, value=1000}}, - {label='Max', value={index=7, value=math.huge}}, + {label='1'..CH_MONEY, value={index=1, value=1}, pen=COLOR_BROWN}, + {label='20'..CH_MONEY, value={index=2, value=20}, pen=COLOR_BROWN}, + {label='50'..CH_MONEY, value={index=3, value=50}, pen=COLOR_BROWN}, + {label='100'..CH_MONEY, value={index=4, value=100}, pen=COLOR_BROWN}, + {label='500'..CH_MONEY, value={index=5, value=500}, pen=COLOR_BROWN}, + {label='1000'..CH_MONEY, value={index=6, value=1000}, pen=COLOR_BROWN}, + {label='Max', value={index=7, value=math.huge}, pen=COLOR_GREEN}, }, initial_option=7, on_change=function(val) @@ -479,10 +479,22 @@ function get_advanced_filter_widgets(self, context) } end -function get_info_widgets(self, export_agreements, context) +function get_info_widgets(self, export_agreements, strict_ethical_bins_default, context) return { + widgets.CycleHotkeyLabel{ + view_id='provenance', + frame={t=0, l=0, w=34}, + key='CUSTOM_SHIFT_P', + 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}, + }, + on_change=function() self:refresh_list() end, + }, widgets.Panel{ - frame={t=0, l=0, r=0, h=2}, + frame={t=2, l=0, r=0, h=2}, subviews={ widgets.Label{ frame={t=0, l=0}, @@ -506,7 +518,7 @@ function get_info_widgets(self, export_agreements, context) key='CUSTOM_SHIFT_A', options={ {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + {label='No', value=false}, }, initial_option=false, on_change=function() self:refresh_list() end, @@ -515,7 +527,7 @@ function get_info_widgets(self, export_agreements, context) }, }, widgets.Panel{ - frame={t=3, l=0, r=0, h=3}, + frame={t=5, l=0, r=0, h=4}, subviews={ widgets.Label{ frame={t=0, l=0}, @@ -530,7 +542,7 @@ function get_info_widgets(self, export_agreements, context) key='CUSTOM_SHIFT_G', options={ {label='Show only ethically acceptable items', value='only', pen=COLOR_GREEN}, - {label='Ignore ethical restrictions', value='show'}, + {label='Ignore ethical restrictions', value='show', pen=COLOR_YELLOW}, {label='Show only ethically unacceptable items', value='hide', pen=COLOR_RED}, }, initial_option='only', @@ -538,10 +550,26 @@ function get_info_widgets(self, export_agreements, context) visible=self.animal_ethics or self.wood_ethics, on_change=function() self:refresh_list() end, }, + widgets.ToggleHotkeyLabel{ + view_id='strict_ethical_bins', + frame={t=3, l=0}, + key='CUSTOM_SHIFT_U', + options={ + {label='Include mixed bins', value=false, pen=COLOR_GREEN}, + {label='Exclude mixed bins', value=true, pen=COLOR_YELLOW}, + }, + initial_option=strict_ethical_bins_default, + option_gap=0, + visible=function() + if not self.animal_ethics and not self.wood_ethics then return false end + return self.subviews.ethical:getOptionValue() ~= 'show' + end, + on_change=function() self:refresh_list() end, + }, }, }, widgets.Panel{ - frame={t=7, l=0, r=0, h=5}, + frame={t=10, l=0, r=0, h=5}, subviews={ widgets.Label{ frame={t=0, l=0}, diff --git a/internal/caravan/movegoods.lua b/internal/caravan/movegoods.lua index feb5c73255..c935c5fb1d 100644 --- a/internal/caravan/movegoods.lua +++ b/internal/caravan/movegoods.lua @@ -14,10 +14,10 @@ local widgets = require('gui.widgets') MoveGoods = defclass(MoveGoods, widgets.Window) MoveGoods.ATTRS { frame_title='Move goods to/from depot', - frame={w=85, h=46}, + frame={w=86, h=46}, resizable=true, - resize_min={h=35}, - frame_inset={l=1, t=1, b=1, r=0}, + resize_min={h=40}, + frame_inset={l=0, t=1, b=1, r=0}, pending_item_ids=DEFAULT_NIL, depot=DEFAULT_NIL, } @@ -155,7 +155,7 @@ function MoveGoods:init() self:addviews{ widgets.CycleHotkeyLabel{ view_id='sort', - frame={l=0, t=0, w=21}, + frame={l=1, t=0, w=21}, label='Sort by:', key='CUSTOM_SHIFT_S', options={ @@ -173,32 +173,38 @@ function MoveGoods:init() }, widgets.EditField{ view_id='search', - frame={l=26, t=0}, + frame={l=27, t=0, r=1}, label_text='Search: ', on_char=function(ch) return ch:match('[%l -]') end, }, widgets.Panel{ - frame={t=2, l=0, w=38, h=14}, - subviews=common.get_slider_widgets(self), - }, - widgets.ToggleHotkeyLabel{ - view_id='hide_forbidden', - frame={t=2, l=40, w=28}, - label='Hide forbidden items:', - key='CUSTOM_SHIFT_F', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + frame={t=2, l=0, r=0, h=18}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.Panel{ + frame={t=0, l=0, w=38}, + subviews=common.get_slider_widgets(self), + }, + widgets.ToggleHotkeyLabel{ + view_id='hide_forbidden', + frame={t=0, l=40, w=28}, + label='Hide forbidden items:', + key='CUSTOM_SHIFT_F', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=false, + on_change=function() self:refresh_list() end, + }, + widgets.Panel{ + frame={t=1, l=40, r=0}, + subviews=common.get_info_widgets(self, get_export_agreements(), false, self.predicate_context), + }, }, - initial_option=false, - on_change=function() self:refresh_list() end, - }, - widgets.Panel{ - frame={t=4, l=40, r=1, h=12}, - subviews=common.get_info_widgets(self, get_export_agreements(), self.predicate_context), }, widgets.Panel{ - frame={t=17, l=0, r=0, b=6}, + frame={t=21, l=0, r=0, b=7}, subviews={ widgets.CycleHotkeyLabel{ view_id='sort_dist', @@ -255,48 +261,60 @@ function MoveGoods:init() }, } }, - widgets.Label{ - frame={l=0, b=4, h=1, r=0}, - text={ - 'Total value of items marked for trade:', - {gap=1, - text=function() return common.obfuscate_value(self.value_pending) end}, - }, - }, - widgets.HotkeyLabel{ - frame={l=0, b=2}, - label='Select all/none', - key='CUSTOM_CTRL_A', - on_activate=self:callback('toggle_visible'), - auto_width=true, + widgets.Divider{ + frame={b=6, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, }, - widgets.ToggleHotkeyLabel{ - view_id='group_items', - frame={l=25, b=2, w=24}, - label='Group items:', - key='CUSTOM_CTRL_G', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} - }, - initial_option=true, - on_change=function() self:refresh_list() end, - }, - widgets.ToggleHotkeyLabel{ - view_id='inside_containers', - frame={l=51, b=2, w=30}, - label='Inside containers:', - key='CUSTOM_CTRL_I', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + widgets.Panel{ + frame={l=1, r=1, b=0, h=5}, + subviews={ + widgets.Label{ + frame={l=0, t=0}, + text={ + 'Total value of items marked for trade:', + {gap=1, + text=function() return common.obfuscate_value(self.value_pending) end, + pen=COLOR_GREEN}, + }, + }, + widgets.Label{ + frame={l=0, t=2}, + text='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', + }, + widgets.HotkeyLabel{ + frame={l=0, b=0}, + label='Select all/none', + key='CUSTOM_CTRL_A', + on_activate=self:callback('toggle_visible'), + auto_width=true, + }, + widgets.ToggleHotkeyLabel{ + view_id='group_items', + frame={l=25, b=0, w=24}, + label='Group items:', + key='CUSTOM_CTRL_G', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=true, + on_change=function() self:refresh_list() end, + }, + widgets.ToggleHotkeyLabel{ + view_id='inside_containers', + frame={l=51, b=0, w=30}, + label='Inside containers:', + key='CUSTOM_CTRL_I', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=false, + on_change=function() self:refresh_list() end, + }, }, - initial_option=false, - on_change=function() self:refresh_list() end, - }, - widgets.WrappedLabel{ - frame={b=0, l=0, r=0}, - text_to_wrap='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', }, } @@ -390,41 +408,50 @@ local function make_choice_text(at_depot, dist, value, quantity, desc) } end +local function is_ethical_item(item, animal_ethics, wood_ethics) + return (not animal_ethics or not item:isAnimalProduct()) and + (not wood_ethics or not common.has_wood(item)) +end + local function is_ethical_product(item, animal_ethics, wood_ethics) if not animal_ethics and not wood_ethics then return true end - if item.flags.container then - local contained_items = dfhack.items.getContainedItems(item) - if df.item_binst:is_instance(item) then - -- ignore the safety of the bin itself (unless the bin is empty) - -- so items inside can still be traded - local has_items = false - for _, contained_item in ipairs(contained_items) do - has_items = true - if (not animal_ethics or not contained_item:isAnimalProduct()) and - (not wood_ethics or not common.has_wood(contained_item)) - then - -- bin passes if at least one contained item is safe - return true + + -- if item is not a container or is an empty container, then the ethics is not mixed + -- and the ethicality of the item speaks for itself + local has_ethical = is_ethical_item(item, animal_ethics, wood_ethics) + local is_mixed = false + if not item.flags.container then + return has_ethical, is_mixed + end + local contained_items = dfhack.items.getContainedItems(item) + if #contained_items == 0 then + return has_ethical, is_mixed + end + + if df.item_binst:is_instance(item) then + for _, contained_item in ipairs(contained_items) do + if is_ethical_item(contained_item, animal_ethics, wood_ethics) then + if not has_ethical then + has_ethical, is_mixed = true, true + break end + elseif has_ethical then + is_mixed = true + break end - if has_items then - -- no contained items are safe - return false - end - else - -- for other types of containers, any contamination makes it untradeable - for _, contained_item in ipairs(contained_items) do - if (animal_ethics and contained_item:isAnimalProduct()) or - (wood_ethics and common.has_wood(contained_item)) - then - return false - end + end + elseif has_ethical then + -- for other types of containers, any contamination makes it unethical since contained + -- items cannot be individually selected in the barter screen + for _, contained_item in ipairs(contained_items) do + if not is_ethical_item(contained_item, animal_ethics, wood_ethics) then + has_ethical = false + break end end end - return (not animal_ethics or not item:isAnimalProduct()) and - (not wood_ethics or not common.has_wood(item)) + return has_ethical, is_mixed end local function make_container_search_key(item, desc) @@ -492,7 +519,7 @@ function MoveGoods:cache_choices() group.data.has_risky = group.data.has_risky or is_risky group.data.has_requested = group.data.has_requested or is_requested else - local is_ethical = is_ethical_product(item, self.animal_ethics, self.wood_ethics) + local has_ethical, is_ethical_mixed = is_ethical_product(item, self.animal_ethics, self.wood_ethics) local data = { desc=desc, per_item_value=value, @@ -507,10 +534,12 @@ function MoveGoods:cache_choices() selected=is_pending and 1 or 0, num_at_depot=item.flags.in_building and 1 or 0, has_forbidden=is_forbidden, + has_foreign=item.flags.foreign, has_banned=is_banned, has_risky=is_risky, has_requested=is_requested, - ethical=is_ethical, + has_ethical=has_ethical, + ethical_mixed=is_ethical_mixed, dirty=false, } local search_key @@ -555,9 +584,11 @@ function MoveGoods:get_choices() local raw_choices = self:cache_choices() local choices = {} local include_forbidden = not self.subviews.hide_forbidden:getOptionValue() + local provenance = self.subviews.provenance:getOptionValue() local banned = self.subviews.banned:getOptionValue() local only_agreement = self.subviews.only_agreement:getOptionValue() local ethical = self.subviews.ethical:getOptionValue() + local strict_ethical_bins = self.subviews.strict_ethical_bins:getOptionValue() local min_condition = self.subviews.min_condition:getOptionValue() local max_condition = self.subviews.max_condition:getOptionValue() local min_quality = self.subviews.min_quality:getOptionValue() @@ -567,8 +598,9 @@ function MoveGoods:get_choices() for _,choice in ipairs(raw_choices) do local data = choice.data if ethical ~= 'show' then - if ethical == 'hide' and data.ethical then goto continue end - if ethical == 'only' and not data.ethical then goto continue end + if strict_ethical_bins and data.ethical_mixed then goto continue end + if ethical == 'hide' and data.has_ethical then goto continue end + if ethical == 'only' and not data.has_ethical then goto continue end end if not include_forbidden then if choice.item_id then @@ -579,6 +611,13 @@ function MoveGoods:get_choices() goto continue end end + if provenance ~= 'all' then + if (provenance == 'local' and data.has_foreign) or + (provenance == 'foreign' and not data.has_foreign) + then + goto continue + end + end if min_condition < data.wear then goto continue end if max_condition > data.wear then goto continue end if min_quality > data.quality then goto continue end @@ -717,6 +756,10 @@ function MoveGoodsModal:onDismiss() if dfhack.items.getHolderBuilding(item) == depot then item.flags.in_building = true else + -- TODO: if there is just one (ethical, if filtered) item inside of a bin, mark the item for + -- trade instead of the bin + -- TODO: give containers that have some items inside of them marked for trade a ":" marker in the UI + -- TODO: correlate items inside containers marked for trade across the cached choices so no choices are lost dfhack.items.markForTrade(item, depot) end elseif not item_data.pending and pending[item_id] then @@ -724,7 +767,7 @@ function MoveGoodsModal:onDismiss() if spec_ref then dfhack.job.removeJob(spec_ref.data.job) end - elseif not item_data.pending and item.flags.in_building then + elseif not item_data.pending and item.flags.in_building and dfhack.items.getHolderBuilding(item) == depot then item.flags.in_building = false end end diff --git a/internal/caravan/trade.lua b/internal/caravan/trade.lua index 2e6c22fec1..05e3adf3b1 100644 --- a/internal/caravan/trade.lua +++ b/internal/caravan/trade.lua @@ -34,9 +34,9 @@ local trade = df.global.game.main_interface.trade Trade = defclass(Trade, widgets.Window) Trade.ATTRS { frame_title='Select trade goods', - frame={w=84, h=47}, + frame={w=86, h=47}, resizable=true, - resize_min={w=48, h=27}, + resize_min={w=48, h=40}, } local TOGGLE_MAP = { @@ -139,7 +139,7 @@ end local STATUS_COL_WIDTH = 7 local VALUE_COL_WIDTH = 6 -local FILTER_HEIGHT = 15 +local FILTER_HEIGHT = 18 function Trade:init() self.cur_page = 1 @@ -174,8 +174,8 @@ function Trade:init() label='Bins:', key='CUSTOM_SHIFT_B', options={ - {label='trade bin with contents', value=true}, - {label='trade contents only', value=false}, + {label='Trade bin with contents', value=true, pen=COLOR_YELLOW}, + {label='Trade contents only', value=false, pen=COLOR_GREEN}, }, initial_option=false, on_change=function() self:refresh_list() end, @@ -215,23 +215,24 @@ function Trade:init() }, widgets.Panel{ frame={t=7, l=0, r=0, h=FILTER_HEIGHT}, + frame_style=gui.FRAME_INTERIOR, visible=function() return self.subviews.filters:getOptionValue() end, on_layout=function() local panel_frame = self.subviews.list_panel.frame if self.subviews.filters:getOptionValue() then - panel_frame.t = 7 + FILTER_HEIGHT + panel_frame.t = 7 + FILTER_HEIGHT + 1 else panel_frame.t = 7 end end, subviews={ widgets.Panel{ - frame={t=0, l=0, w=38, h=FILTER_HEIGHT}, + frame={t=0, l=0, w=38}, visible=function() return self.cur_page == 1 end, subviews=common.get_slider_widgets(self, '1'), }, widgets.Panel{ - frame={t=0, l=0, w=38, h=FILTER_HEIGHT}, + frame={t=0, l=0, w=38}, visible=function() return self.cur_page == 2 end, subviews=common.get_slider_widgets(self, '2'), }, @@ -241,15 +242,15 @@ function Trade:init() subviews=common.get_advanced_filter_widgets(self, self.predicate_contexts[1]), }, widgets.Panel{ - frame={t=2, l=40, r=0, h=FILTER_HEIGHT-2}, + frame={t=1, l=40, r=0}, visible=function() return self.cur_page == 2 end, - subviews=common.get_info_widgets(self, {trade.mer.buy_prices}, self.predicate_contexts[2]), + subviews=common.get_info_widgets(self, {trade.mer.buy_prices}, true, self.predicate_contexts[2]), }, }, }, widgets.Panel{ view_id='list_panel', - frame={t=7, l=0, r=0, b=4}, + frame={t=7, l=0, r=0, b=5}, subviews={ widgets.CycleHotkeyLabel{ view_id='sort_status', @@ -295,17 +296,23 @@ function Trade:init() }, } }, + widgets.Divider{ + frame={b=4, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Label{ + frame={b=2, l=0, r=0}, + text='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', + }, widgets.HotkeyLabel{ - frame={l=0, b=2}, + frame={l=0, b=0}, label='Select all/none', key='CUSTOM_CTRL_A', on_activate=self:callback('toggle_visible'), auto_width=true, }, - widgets.WrappedLabel{ - frame={b=0, l=0, r=0}, - text_to_wrap='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', - }, } -- replace the FilteredList's built-in EditField with our own @@ -384,10 +391,12 @@ function Trade:cache_choices(list_idx, trade_bins) item_idx=item_idx, quality=item.flags.artifact and 6 or item:getQuality(), wear=wear_level, + has_foreign=item.flags.foreign, has_banned=is_banned, has_risky=is_risky, has_requested=is_requested, - ethical=is_ethical, + has_ethical=is_ethical, + ethical_mixed=false, } if parent_data then data.update_container_fn = function(from, to) @@ -396,7 +405,8 @@ function Trade:cache_choices(list_idx, trade_bins) parent_data.has_banned = parent_data.has_banned or is_banned parent_data.has_risky = parent_data.has_risky or is_risky parent_data.has_requested = parent_data.has_requested or is_requested - parent_data.ethical = parent_data.ethical and is_ethical + parent_data.ethical_mixed = parent_data.ethical_mixed or (parent_data.has_ethical ~= is_ethical) + parent_data.has_ethical = parent_data.has_ethical or is_ethical end local is_container = df.item_binst:is_instance(item) local search_key @@ -427,9 +437,11 @@ end function Trade:get_choices() local raw_choices = self:cache_choices(self.cur_page-1, self.subviews.trade_bins:getOptionValue()) + local provenance = self.subviews.provenance:getOptionValue() local banned = self.cur_page == 1 and 'ignore' or self.subviews.banned:getOptionValue() local only_agreement = self.cur_page == 2 and self.subviews.only_agreement:getOptionValue() or false local ethical = self.cur_page == 1 and 'show' or self.subviews.ethical:getOptionValue() + local strict_ethical_bins = self.subviews.strict_ethical_bins:getOptionValue() local min_condition = self.subviews['min_condition'..self.cur_page]:getOptionValue() local max_condition = self.subviews['max_condition'..self.cur_page]:getOptionValue() local min_quality = self.subviews['min_quality'..self.cur_page]:getOptionValue() @@ -440,8 +452,16 @@ function Trade:get_choices() for _,choice in ipairs(raw_choices) do local data = choice.data if ethical ~= 'show' then - if ethical == 'hide' and data.ethical then goto continue end - if ethical == 'only' and not data.ethical then goto continue end + if strict_ethical_bins and data.ethical_mixed then goto continue end + if ethical == 'hide' and data.has_ethical then goto continue end + if ethical == 'only' and not data.has_ethical then goto continue end + end + if provenance ~= 'all' then + if (provenance == 'local' and data.has_foreign) or + (provenance == 'foreign' and not data.has_foreign) + then + goto continue + end end if min_condition < data.wear then goto continue end if max_condition > data.wear then goto continue end @@ -516,7 +536,7 @@ end -- TradeScreen -- -view = view or nil +trade_view = trade_view or nil TradeScreen = defclass(TradeScreen, gui.ZScreen) TradeScreen.ATTRS { @@ -540,7 +560,7 @@ end function TradeScreen:onRenderFrame() if not df.global.game.main_interface.trade.open then - if view then view:dismiss() end + if trade_view then trade_view:dismiss() end elseif self.reset_pending and (dfhack.gui.matchFocusString('dfhack/lua/caravan/trade') or dfhack.gui.matchFocusString('dwarfmode/Trade/Default')) @@ -551,7 +571,7 @@ function TradeScreen:onRenderFrame() end function TradeScreen:onDismiss() - view = nil + trade_view = nil end -- ------------------- @@ -822,7 +842,7 @@ function TradeBannerOverlay:init() label='DFHack trade UI', key='CUSTOM_CTRL_T', enabled=function() return trade.stillunloading == 0 and trade.havetalker == 1 end, - on_activate=function() view = view and view:raise() or TradeScreen{}:show() end, + on_activate=function() trade_view = trade_view and trade_view:raise() or TradeScreen{}:show() end, }, } end @@ -831,8 +851,213 @@ function TradeBannerOverlay:onInput(keys) if TradeBannerOverlay.super.onInput(self, keys) then return true end if keys._MOUSE_R or keys.LEAVESCREEN then - if view then - view:dismiss() + if trade_view then + trade_view:dismiss() + end + end +end + +-- ------------------- +-- Ethics +-- + +Ethics = defclass(Ethics, widgets.Window) +Ethics.ATTRS { + frame_title='Ethical transgressions', + frame={w=45, h=30}, + resizable=true, +} + +function Ethics:init() + self.choices = {} + self.animal_ethics = common.is_animal_lover_caravan(trade.mer) + self.wood_ethics = common.is_tree_lover_caravan(trade.mer) + + self:addviews{ + widgets.Label{ + frame={l=0, t=0}, + text={ + 'You have ', + {text=self:callback('get_transgression_count'), pen=self:callback('get_transgression_color')}, + ' item', + {text=function() return self:get_transgression_count() == 1 and '' or 's' end}, + ' selected for trade', NEWLINE, + 'that would offend the merchants:', + }, + }, + widgets.List{ + view_id='list', + frame={l=0, r=0, t=3, b=2}, + }, + widgets.HotkeyLabel{ + frame={l=0, b=0}, + key='CUSTOM_CTRL_A', + label='Deselect items in trade list', + auto_width=true, + on_activate=self:callback('deselect_transgressions'), + }, + } + + self:rescan() +end + +function Ethics:get_transgression_count() + return #self.choices +end + +function Ethics:get_transgression_color() + return next(self.choices) and COLOR_LIGHTRED or COLOR_LIGHTGREEN +end + +-- also used by confirm +function for_selected_item(list_idx, fn) + local goodflags = trade.goodflag[list_idx] + local in_selected_container = false + for item_idx, item in ipairs(trade.good[list_idx]) do + local goodflag = goodflags[item_idx] + if goodflag == GOODFLAG.UNCONTAINED_SELECTED or goodflag == GOODFLAG.CONTAINER_COLLAPSED_SELECTED then + in_selected_container = true + elseif goodflag == GOODFLAG.UNCONTAINED_UNSELECTED or goodflag == GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED then + in_selected_container = false + end + if in_selected_container or TARGET_REVMAP[goodflag] then + if fn(item_idx, item) then + return + end + end + end +end + +local function for_ethics_violation(fn, animal_ethics, wood_ethics) + if not animal_ethics and not wood_ethics then return end + for_selected_item(1, function(item_idx, item) + if not is_ethical_product(item, animal_ethics, wood_ethics) then + if fn(item_idx, item) then return true end + end + end) +end + +function Ethics:rescan() + local choices = {} + for_ethics_violation(function(item_idx, item) + local choice = { + text=dfhack.items.getReadableDescription(item), + data={item_idx=item_idx}, + } + table.insert(choices, choice) + end, self.animal_ethics, self.wood_ethics) + + self.subviews.list:setChoices(choices) + self.choices = choices +end + +function Ethics:deselect_transgressions() + local goodflags = trade.goodflag[1] + for _,choice in ipairs(self.choices) do + local goodflag = goodflags[choice.data.item_idx] + if TARGET_REVMAP[goodflag] then + goodflags[choice.data.item_idx] = TOGGLE_MAP[goodflag] + end + end + self:rescan() +end + +-- ------------------- +-- EthicsScreen +-- + +ethics_view = ethics_view or nil + +EthicsScreen = defclass(EthicsScreen, gui.ZScreen) +EthicsScreen.ATTRS { + focus_path='caravan/trade/ethics', +} + +function EthicsScreen:init() + self.ethics_window = Ethics{} + self:addviews{self.ethics_window} +end + +function EthicsScreen:onInput(keys) + if self.reset_pending then return false end + local handled = EthicsScreen.super.onInput(self, keys) + if keys._MOUSE_L and not self.ethics_window:getMouseFramePos() then + -- check for modified selection + self.reset_pending = true + end + return handled +end + +function EthicsScreen:onRenderFrame() + if not df.global.game.main_interface.trade.open then + if ethics_view then ethics_view:dismiss() end + elseif self.reset_pending and + (dfhack.gui.matchFocusString('dfhack/lua/caravan/trade') or + dfhack.gui.matchFocusString('dwarfmode/Trade/Default')) + then + self.reset_pending = nil + self.ethics_window:rescan() + end +end + +function EthicsScreen:onDismiss() + ethics_view = nil +end + +-- -------------------------- +-- TradeEthicsWarningOverlay +-- + +-- also called by confirm +function has_ethics_violation() + local violated = false + for_ethics_violation(function() + violated = true + return true + end, common.is_animal_lover_caravan(trade.mer), common.is_tree_lover_caravan(trade.mer)) + return violated +end + +TradeEthicsWarningOverlay = defclass(TradeEthicsWarningOverlay, overlay.OverlayWidget) +TradeEthicsWarningOverlay.ATTRS{ + desc='Adds warning to the trade screen when you are about to offend the elves.', + default_pos={x=-54,y=-5}, + default_enabled=true, + viewscreens='dwarfmode/Trade/Default', + frame={w=9, h=2}, + visible=has_ethics_violation, +} + +function TradeEthicsWarningOverlay:init() + self:addviews{ + widgets.BannerPanel{ + frame={l=0, w=9}, + subviews={ + widgets.Label{ + frame={l=1, r=1}, + text={ + 'Ethics', NEWLINE, + 'warning', + }, + on_click=function() ethics_view = ethics_view and ethics_view:raise() or EthicsScreen{}:show() end, + text_pen=COLOR_LIGHTRED, + auto_width=false, + }, + }, + }, + } +end + +function TradeEthicsWarningOverlay:preUpdateLayout(rect) + self.frame.w = (rect.width - 95) // 2 +end + +function TradeEthicsWarningOverlay:onInput(keys) + if TradeEthicsWarningOverlay.super.onInput(self, keys) then return true end + + if keys._MOUSE_R or keys.LEAVESCREEN then + if ethics_view then + ethics_view:dismiss() end end end diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index d3d12d94e5..cbb33cd90e 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -1,11 +1,12 @@ --@module = true --- if adding a new spec, just run `confirm` to load it and make it live +-- if adding a new spec, run `confirm` to load it and make it live -- -- remember to reload the overlay when adding/changing specs that have -- intercept_frames defined local json = require('json') +local trade_internal = reqscript('internal/caravan/trade') local CONFIG_FILE = 'dfhack-config/confirm.json' @@ -55,16 +56,21 @@ local mi = df.global.game.main_interface local plotinfo = df.global.plotinfo local function trade_goods_any_selected(which) - for _, sel in ipairs(mi.trade.goodflag[which]) do - if sel == 1 then return true end - end + local any_selected = false + trade_internal.for_selected_item(which, function() + any_selected = true + return true + end) + return any_selected end local function trade_goods_all_selected(which) - for _, sel in ipairs(mi.trade.goodflag[which]) do - if sel ~= 1 then return false end - end - return true + local num_selected = 0 + trade_internal.for_selected_item(which, function(idx) + print(idx) + num_selected = num_selected + 1 + end) + return #mi.trade.goodflag[which] == num_selected end local function trade_agreement_items_any_selected() @@ -102,7 +108,7 @@ ConfirmSpec{ title='Cancel trade', message='Are you sure you want leave this screen? Selected items will not be saved.', intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(0) or trade_goods_any_selected(1) end, } @@ -112,7 +118,7 @@ ConfirmSpec{ message='Are you sure you want mark all fortress goods at the depot? Your current fortress goods selections will be lost.', intercept_keys='_MOUSE_L', intercept_frame={r=47, b=7, w=12, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, pausable=true, } @@ -123,7 +129,7 @@ ConfirmSpec{ message='Are you sure you want unmark all fortress goods at the depot? Your current fortress goods selections will be lost.', intercept_keys='_MOUSE_L', intercept_frame={r=30, b=7, w=14, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, pausable=true, } @@ -134,7 +140,7 @@ ConfirmSpec{ message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', intercept_keys='_MOUSE_L', intercept_frame={l=0, r=72, b=7, w=12, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, pausable=true, } @@ -145,19 +151,28 @@ ConfirmSpec{ message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', intercept_keys='_MOUSE_L', intercept_frame={l=0, r=40, b=7, w=14, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, pausable=true, } +local function get_ethics_message(msg) + local lines = {msg} + if trade_internal.has_ethics_violation() then + table.insert(lines, '') + table.insert(lines, 'You have items selected that will offend the merchants. Proceeding with this trade will anger them. You can click on the Ethics warning badge to see which items the merchants will find offensive.') + end + return table.concat(lines, NEWLINE) +end + ConfirmSpec{ id='trade-confirm-trade', title='Confirm trade', - message="Are you sure you want to trade the selected goods?", + message=curry(get_ethics_message, 'Are you sure you want to trade the selected goods?'), intercept_keys='_MOUSE_L', intercept_frame={l=0, r=23, b=4, w=11, h=3}, - context='dwarfmode/Trade', - predicate=function() return trade_goods_any_selected(0) and trade_goods_any_selected(1) end, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(1) end, pausable=true, } @@ -167,7 +182,7 @@ ConfirmSpec{ message='Are you sure you want seize marked merchant goods? This will make the merchant unwilling to trade further and will damage relations with the merchant\'s civilization.', intercept_keys='_MOUSE_L', intercept_frame={l=0, r=73, b=4, w=11, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return mi.trade.mer.mood > 0 and trade_goods_any_selected(0) end, pausable=true, } @@ -175,10 +190,10 @@ ConfirmSpec{ ConfirmSpec{ id='trade-offer', title='Offer fortress goods', - message='Are you sure you want to offer these goods? You will receive no payment.', + message=curry(get_ethics_message, 'Are you sure you want to offer these goods? You will receive no payment.'), intercept_keys='_MOUSE_L', intercept_frame={l=40, r=5, b=4, w=19, h=3}, - context='dwarfmode/Trade', + context='dwarfmode/Trade/Default', predicate=function() return trade_goods_any_selected(1) end, pausable=true, }