From bc18450afea24a121821045d138976065caa763c Mon Sep 17 00:00:00 2001 From: Profakos Date: Tue, 31 Oct 2023 02:49:56 +0100 Subject: [PATCH] Converts traders to basic mobs (#79187) ## About The Pull Request This PR converts the two trader mobs into basic mobs, these being the basic debug trader that buys ectoplasm and sells ghost burgers, and Mr Bones, who buys empty milk cartons and bones, and sells bone relate paraphernalia. Traders now use dynamic appearance generation. The old sprites still exist as hallucinations, and as shop signs. Trader UI is now summoned via `COMSIG_ATOM_ATTACK_HAND`, which properly cancels the attack chain, so there is no longer need to put it on Interact. I kept most of the original behaviour, but moved them off into a component. I have also cached all the images generated for the radials, I hope I have not overengineered it. I have also created a new datum, which stores the trader's wares, needs, and speech patterns. Admins can put the component along with the trader data on any living mobs with an AI controller, turning them into traders. Keep in mind that most AI has random idle movement, meaning they have a chance to walk off, closing your trader radial. ![image](https://github.com/tgstation/tgstation/assets/2676196/a2b216e9-f9bf-46e1-83fa-3a41c8447176) The trader AI consists of the following, first, when a trader sees someone, they will deploy their shop, if one does not already exists. The shop consists of a chair, and a holographic sign. If you attack them, they will chase you with their weapons, and then return to their chair when victorious. If the chair is somehow destroyed, they will create a new shop when they see a new potential customer. ![image](https://github.com/tgstation/tgstation/assets/2676196/ad5fcd5a-996c-490f-938b-6bc11c91c4ee) Mr Bones uses a variant of the AI, where they will run at you, and deploy their shop when they reach you. I call this the jumpscare variant. Below you can see me getting actually jumpscared because Mr Bones has stepped on a yelling frog when I opened the maintenance door. ![image](https://github.com/tgstation/tgstation/assets/2676196/f47a5baa-e32d-4454-97f9-d90d027003d2) I have also made an element that toggles an ai controlled combat mode when it gains a target, and when it loses it. I am using it to make Traders unable to trade while they are trying to kill a robber. To aid this, I a have made `/datum/ai_controller/proc/sig_remove_from_blackboard` send the `COMSIG_AI_BLACKBOARD_KEY_CLEARED` signal, in case the trader kills a mob that deletes itself on death. This means I could remove a signup `/datum/component/appearance_on_aggro` was doing towards Qdeleting. Below you can see Mr Bones shooting me with candy corn. ![image](https://github.com/tgstation/tgstation/assets/2676196/489e1072-e15a-412c-a8eb-9a3f0cca7bf6) ![image](https://github.com/tgstation/tgstation/assets/2676196/8f74b50f-ea35-467c-bb07-2ef38f84c453) Traders actually only shoot you until you are conscious, so I survived here in crit. Most mobs don't have crit state, so they just die, so I am sticking by this voice line. Thank you @CoiledLamb for help with the sale sign! ## Why It's Good For The Game Two more mobs off the list. The AI and Componentized behaviours allows us to set up new kind of traders. ## Changelog :cl: refactor: Traders are basic mobs now. Please alert us of any strange behaviours! code: If there is only one option, radial lists will autopick it. This behaviour can be turned off via a new argument. /:cl: --- code/__DEFINES/ai/trader.dm | 6 + code/__DEFINES/trader.dm | 16 + code/_onclick/hud/radial.dm | 6 +- code/datums/ai/_ai_controller.dm | 1 + .../set_travel_destination.dm | 13 + .../prepare_travel_to_destination.dm | 23 + code/datums/ai/generic/find_and_set.dm | 11 + .../ai/idle_behaviors/idle_random_walk.dm | 14 + code/datums/components/appearance_on_aggro.dm | 2 - code/datums/components/trader/trader.dm | 457 ++++++++++++++++ code/datums/elements/ai_swap_combat_mode.dm | 66 +++ code/modules/hallucination/body.dm | 4 +- .../modules/mob/living/basic/trader/trader.dm | 79 +++ .../mob/living/basic/trader/trader_actions.dm | 72 +++ .../mob/living/basic/trader/trader_ai.dm | 95 ++++ .../mob/living/basic/trader/trader_data.dm | 156 ++++++ .../mob/living/basic/trader/trader_items.dm | 36 ++ .../simple_animal/hostile/retaliate/trader.dm | 501 ------------------ .../unit_tests/simple_animal_freeze.dm | 2 - icons/mob/actions/actions_trader.dmi | Bin 0 -> 853 bytes icons/mob/simple/traders.dmi | Bin 3868 -> 0 bytes icons/obj/trader_signs.dmi | Bin 0 -> 3926 bytes tgstation.dme | 12 +- .../Scripts/79187_simple_to_basic_traders.txt | 2 + 24 files changed, 1065 insertions(+), 509 deletions(-) create mode 100644 code/__DEFINES/ai/trader.dm create mode 100644 code/__DEFINES/trader.dm create mode 100644 code/datums/ai/basic_mobs/basic_ai_behaviors/set_travel_destination.dm create mode 100644 code/datums/ai/basic_mobs/basic_subtrees/prepare_travel_to_destination.dm create mode 100644 code/datums/components/trader/trader.dm create mode 100644 code/datums/elements/ai_swap_combat_mode.dm create mode 100644 code/modules/mob/living/basic/trader/trader.dm create mode 100644 code/modules/mob/living/basic/trader/trader_actions.dm create mode 100644 code/modules/mob/living/basic/trader/trader_ai.dm create mode 100644 code/modules/mob/living/basic/trader/trader_data.dm create mode 100644 code/modules/mob/living/basic/trader/trader_items.dm delete mode 100644 code/modules/mob/living/simple_animal/hostile/retaliate/trader.dm create mode 100644 icons/mob/actions/actions_trader.dmi delete mode 100644 icons/mob/simple/traders.dmi create mode 100644 icons/obj/trader_signs.dmi create mode 100644 tools/UpdatePaths/Scripts/79187_simple_to_basic_traders.txt diff --git a/code/__DEFINES/ai/trader.dm b/code/__DEFINES/ai/trader.dm new file mode 100644 index 00000000000..853dd8736b6 --- /dev/null +++ b/code/__DEFINES/ai/trader.dm @@ -0,0 +1,6 @@ +///The ability to setup our "shop" +#define BB_SETUP_SHOP "BB_setup_shop" +///Reference to the plastic chair that is considered as our shop +#define BB_SHOP_SPOT "BB_shop_spot" +///Reference to our first customer to harass with deals +#define BB_FIRST_CUSTOMER "BB_first_customer" diff --git a/code/__DEFINES/trader.dm b/code/__DEFINES/trader.dm new file mode 100644 index 00000000000..aae6da26499 --- /dev/null +++ b/code/__DEFINES/trader.dm @@ -0,0 +1,16 @@ +#define ITEM_REJECTED_PHRASE "ITEM_REJECTED_PHRASE" +#define ITEM_SELLING_CANCELED_PHRASE "ITEM_SELLING_CANCELED_PHRASE" +#define ITEM_SELLING_ACCEPTED_PHRASE "ITEM_SELLING_ACCEPTED_PHRASE" +#define INTERESTED_PHRASE "INTERESTED_PHRASE" +#define BUY_PHRASE "BUY_PHRASE" +#define NO_CASH_PHRASE "NO_CASH_PHRASE" +#define NO_STOCK_PHRASE "NO_STOCK_PHRASE" +#define NOT_WILLING_TO_BUY_PHRASE "NOT_WILLING_TO_BUY_PHRASE" +#define ITEM_IS_WORTHLESS_PHRASE "ITEM_IS_WORTHLESS_PHRASE" +#define TRADER_HAS_ENOUGH_ITEM_PHRASE "TRADER_HAS_ENOUGH_ITEM_PHRASE" +#define TRADER_LORE_PHRASE "TRADER_LORE_PHRASE" +#define TRADER_BATTLE_START_PHRASE "TRADER_AGGRO_PHRASE" +#define TRADER_BATTLE_END_PHRASE "TRADER_DEAGGRO_PHRASE" +#define TRADER_NOT_BUYING_ANYTHING "TRADER_NOT_BUYING_ANYTHING" +#define TRADER_NOT_SELLING_ANYTHING "TRADER_NOT_SELLING_ANYTHING" +#define TRADER_SHOP_OPENING_PHRASE "TRADER_SHOP_OPENING_PHRASE" diff --git a/code/_onclick/hud/radial.dm b/code/_onclick/hud/radial.dm index 5bc75d85f47..c7a4cb9ab0a 100644 --- a/code/_onclick/hud/radial.dm +++ b/code/_onclick/hud/radial.dm @@ -345,9 +345,13 @@ GLOBAL_LIST_EMPTY(radial_menus) Choices should be a list where list keys are movables or text used for element names and return value and list values are movables/icons/images used for element icons */ -/proc/show_radial_menu(mob/user, atom/anchor, list/choices, uniqueid, radius, datum/callback/custom_check, require_near = FALSE, tooltips = FALSE, no_repeat_close = FALSE, radial_slice_icon = "radial_slice") +/proc/show_radial_menu(mob/user, atom/anchor, list/choices, uniqueid, radius, datum/callback/custom_check, require_near = FALSE, tooltips = FALSE, no_repeat_close = FALSE, radial_slice_icon = "radial_slice", autopick_single_option = TRUE) if(!user || !anchor || !length(choices)) return + + if(length(choices)==1 && autopick_single_option) + return choices[1] + if(!uniqueid) uniqueid = "defmenu_[REF(user)]_[REF(anchor)]" diff --git a/code/datums/ai/_ai_controller.dm b/code/datums/ai/_ai_controller.dm index f9938e08c88..04493b44e5d 100644 --- a/code/datums/ai/_ai_controller.dm +++ b/code/datums/ai/_ai_controller.dm @@ -619,6 +619,7 @@ multiple modular subtrees with behaviors // We found the value that's been deleted, it was an assoc value. Clear it out entirely else if(associated_value == source) next_to_clear -= inner_value + SEND_SIGNAL(pawn, COMSIG_AI_BLACKBOARD_KEY_CLEARED(inner_value)) index += 1 diff --git a/code/datums/ai/basic_mobs/basic_ai_behaviors/set_travel_destination.dm b/code/datums/ai/basic_mobs/basic_ai_behaviors/set_travel_destination.dm new file mode 100644 index 00000000000..207df442457 --- /dev/null +++ b/code/datums/ai/basic_mobs/basic_ai_behaviors/set_travel_destination.dm @@ -0,0 +1,13 @@ +/datum/ai_behavior/set_travel_destination + +/datum/ai_behavior/set_travel_destination/perform(seconds_per_tick, datum/ai_controller/controller, target_key, location_key) + . = ..() + var/atom/target = controller.blackboard[target_key] + + if(QDELETED(target)) + finish_action(controller, FALSE, target_key) + return + + controller.set_blackboard_key(location_key, target) + + finish_action(controller, TRUE, target_key) diff --git a/code/datums/ai/basic_mobs/basic_subtrees/prepare_travel_to_destination.dm b/code/datums/ai/basic_mobs/basic_subtrees/prepare_travel_to_destination.dm new file mode 100644 index 00000000000..2718ca630ff --- /dev/null +++ b/code/datums/ai/basic_mobs/basic_subtrees/prepare_travel_to_destination.dm @@ -0,0 +1,23 @@ + +///Subtree that checks if we are on the target atom's tile, and sets it as a travel target if not +///The target is taken from the blackboard. This one always requires a specific implementation. +/datum/ai_planning_subtree/prepare_travel_to_destination + var/target_key + var/travel_destination_key = BB_TRAVEL_DESTINATION + +/datum/ai_planning_subtree/prepare_travel_to_destination/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/atom/target = controller.blackboard[target_key] + + //Target is deleted, or we are already standing on it + if(QDELETED(target) || (isturf(target) && controller.pawn.loc == target) || (target.loc == controller.pawn.loc)) + return + + //Already set with this value, return + if(controller.blackboard[target_key] == controller.blackboard[travel_destination_key]) + return + + controller.queue_behavior(/datum/ai_behavior/set_travel_destination, target_key, travel_destination_key) + return //continue planning regardless of success + +/datum/ai_planning_subtree/prepare_travel_to_destination/trader + target_key = BB_SHOP_SPOT diff --git a/code/datums/ai/generic/find_and_set.dm b/code/datums/ai/generic/find_and_set.dm index 43337674969..6107b1664ee 100644 --- a/code/datums/ai/generic/find_and_set.dm +++ b/code/datums/ai/generic/find_and_set.dm @@ -135,3 +135,14 @@ if (nearby_bodies.len) return pick(nearby_bodies) + +/** + * A variant that looks for a human who is not dead or incapacitated, and has a mind + */ +/datum/ai_behavior/find_and_set/conscious_person + +/datum/ai_behavior/find_and_set/conscious_person/search_tactic(datum/ai_controller/controller, locate_path, search_range) + for(var/mob/living/carbon/human/target in oview(search_range, controller.pawn)) + if(IS_DEAD_OR_INCAP(target) || !target.mind) + continue + return target diff --git a/code/datums/ai/idle_behaviors/idle_random_walk.dm b/code/datums/ai/idle_behaviors/idle_random_walk.dm index d99957f419b..f189e89b423 100644 --- a/code/datums/ai/idle_behaviors/idle_random_walk.dm +++ b/code/datums/ai/idle_behaviors/idle_random_walk.dm @@ -25,6 +25,20 @@ return return ..() +/// Only walk if we are not on the target's location +/datum/idle_behavior/idle_random_walk/not_while_on_target + ///What is the spot we have to stand on? + var/target_key + +/datum/idle_behavior/idle_random_walk/not_while_on_target/perform_idle_behavior(seconds_per_tick, datum/ai_controller/controller) + var/atom/target = controller.blackboard[target_key] + + //Don't move, if we are are already standing on it + if(!QDELETED(target) && ((isturf(target) && controller.pawn.loc == target) || (target.loc == controller.pawn.loc))) + return + + return ..() + /// walk randomly however stick near a target /datum/idle_behavior/walk_near_target /// chance to walk diff --git a/code/datums/components/appearance_on_aggro.dm b/code/datums/components/appearance_on_aggro.dm index 33a3d7c2e90..8c0df88e6fd 100644 --- a/code/datums/components/appearance_on_aggro.dm +++ b/code/datums/components/appearance_on_aggro.dm @@ -45,7 +45,6 @@ return current_target = target - RegisterSignal(target, COMSIG_QDELETING, PROC_REF(on_clear_target)) if (!isnull(aggro_overlay) || !isnull(aggro_state)) source.update_appearance(UPDATE_ICON) if (!isnull(alpha_on_aggro)) @@ -61,7 +60,6 @@ revert_appearance(parent) /datum/component/appearance_on_aggro/proc/revert_appearance(mob/living/source) - UnregisterSignal(current_target, COMSIG_QDELETING) current_target = null if (!isnull(aggro_overlay) || !isnull(aggro_state)) source.update_appearance(UPDATE_ICON) diff --git a/code/datums/components/trader/trader.dm b/code/datums/components/trader/trader.dm new file mode 100644 index 00000000000..b1004138527 --- /dev/null +++ b/code/datums/components/trader/trader.dm @@ -0,0 +1,457 @@ +#define TRADER_RADIAL_BUY "TRADER_RADIAL_BUY" +#define TRADER_RADIAL_SELL "TRADER_RADIAL_SELL" +#define TRADER_RADIAL_TALK "TRADER_RADIAL_TALK" +#define TRADER_RADIAL_LORE "TRADER_RADIAL_LORE" +#define TRADER_RADIAL_NO "TRADER_RADIAL_NO" +#define TRADER_RADIAL_YES "TRADER_RADIAL_YES" +#define TRADER_RADIAL_OUT_OF_STOCK "TRADER_RADIAL_OUT_OF_STOCK" +#define TRADER_RADIAL_DISCUSS_BUY "TRADER_RADIAL_DISCUSS_BUY" +#define TRADER_RADIAL_DISCUSS_SELL "TRADER_RADIAL_DISCUSS_SELL" + +#define TRADER_OPTION_BUY "Buy" +#define TRADER_OPTION_SELL "Sell" +#define TRADER_OPTION_TALK "Talk" +#define TRADER_OPTION_LORE "Lore" +#define TRADER_OPTION_NO "No" +#define TRADER_OPTION_YES "Yes" +#define TRADER_OPTION_BUYING "Buying?" +#define TRADER_OPTION_SELLING "Selling?" + +//The defines below show the index the info is located in the product_info entry list + +#define TRADER_PRODUCT_INFO_PRICE 1 +#define TRADER_PRODUCT_INFO_QUANTITY 2 +//Only valid for wanted_items +#define TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION 3 + +/** + * # Trader NPC Component + * Manages the barks and the stocks of the traders + * Also manages the interactive radial menu + */ +/datum/component/trader + + /** + * Format; list(TYPEPATH = list(PRICE, QUANTITY)) + * Associated list of items the NPC sells with how much they cost and the quantity available before a restock + * This list is filled by Initialize(), if you want to change the starting products, modify initial_products() + * * + */ + var/list/obj/item/products = list() + /** + * A list of wanted items that the trader would wish to buy, each typepath has a assigned value, quantity and additional flavor text + * + * CHILDREN OF TYPEPATHS INCLUDED IN WANTED_ITEMS WILL BE TREATED AS THE PARENT IF NO ENTRY EXISTS FOR THE CHILDREN + * + * As an additional note; if you include multiple children of a typepath; the typepath with the most children should be placed after all other typepaths + * Bad; list(/obj/item/milk = list(100, 1, ""), /obj/item/milk/small = list(50, 2, "")) + * Good; list(/obj/item/milk/small = list(50, 2, ""), /obj/item/milk = list(100, 1, "")) + * This is mainly because sell_item() uses a istype(item_being_sold, item_in_entry) to determine what parent should the child be automatically considered as + * If /obj/item/milk/small/spooky was being sold; /obj/item/milk/small would be the first to check against rather than /obj/item/milk + * + * Format; list(TYPEPATH = list(PRICE, QUANTITY, ADDITIONAL_DESCRIPTION)) + * Associated list of items able to be sold to the NPC with the money given for them. + * The price given should be the "base" price; any price manipulation based on variables should be done with apply_sell_price_mods() + * ADDITIONAL_DESCRIPTION is any additional text added to explain how the variables of the item effect the price; if it's stack based, it's final price depends how much is in the stack + * EX; /obj/item/stack/sheet/mineral/diamond = list(500, INFINITY, ", per 100 cm3 sheet of diamond") + * This list is filled by Initialize(), if you want to change the starting wanted items, modify initial_wanteds() + */ + var/list/wanted_items = list() + + ///Contains images of all radial icons + var/static/list/radial_icons_cache = list() + + ///Contains information of a specific trader + var/datum/trader_data/trader_data + +/* +Can accept both a type path, and an instance of a datum. Type path has priority. +*/ +/datum/component/trader/Initialize(trader_data_path = null, trader_data = null) + . = ..() + if(!isliving(parent)) + return COMPONENT_INCOMPATIBLE + + if(ispath(trader_data_path, /datum/trader_data)) + trader_data = new trader_data_path + if(isnull(trader_data)) + CRASH("Initialised trader component with no trader data.") + + src.trader_data = trader_data + + radial_icons_cache = list( + TRADER_RADIAL_BUY = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_buy"), + TRADER_RADIAL_SELL = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_sell"), + TRADER_RADIAL_TALK = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_talk"), + TRADER_RADIAL_LORE = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_lore"), + TRADER_RADIAL_DISCUSS_BUY = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_buying"), + TRADER_RADIAL_DISCUSS_SELL = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_selling"), + TRADER_RADIAL_YES = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_yes"), + TRADER_RADIAL_NO = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_no"), + TRADER_RADIAL_OUT_OF_STOCK = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_center"), + ) + + restock_products() + renew_item_demands() + +/datum/component/trader/RegisterWithParent() + RegisterSignal(parent, COMSIG_ATOM_ATTACK_HAND, PROC_REF(on_attack_hand)) + +/datum/component/trader/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_ATOM_ATTACK_HAND) + +///If our trader is alive, and the customer left clicks them with an empty hand without combat mode +/datum/component/trader/proc/on_attack_hand(atom/source, mob/living/carbon/customer) + SIGNAL_HANDLER + if(!can_trade(customer) || customer.combat_mode) + return + var/list/npc_options = list() + if(length(products)) + npc_options[TRADER_OPTION_BUY] = radial_icons_cache[TRADER_RADIAL_BUY] + if(length(wanted_items)) + npc_options[TRADER_OPTION_SELL] = radial_icons_cache[TRADER_RADIAL_SELL] + if(length(trader_data.say_phrases)) + npc_options[TRADER_OPTION_TALK] = radial_icons_cache[TRADER_RADIAL_TALK] + if(!length(npc_options)) + return + + var/mob/living/trader = parent + trader.face_atom(customer) + + INVOKE_ASYNC(src, PROC_REF(open_npc_options), customer, npc_options) + + return COMPONENT_CANCEL_ATTACK_CHAIN + +/** + * Generates a radial of the initial radials of the NPC + * Called via asynch, due to the sleep caused by show_radial_menu + * Arguments: + * * customer - (Mob REF) The mob trying to buy something + */ +/datum/component/trader/proc/open_npc_options(mob/living/carbon/customer, list/npc_options) + if(!can_trade(customer)) + return + var/npc_result = show_radial_menu(customer, parent, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), customer), require_near = TRUE, tooltips = TRUE) + switch(npc_result) + if(TRADER_OPTION_BUY) + buy_item(customer) + if(TRADER_OPTION_SELL) + try_sell(customer) + if(TRADER_OPTION_TALK) + discuss(customer) + +/** + * Checks if the customer is ok to use the radial + * + * Checks if the customer is not a mob or is incapacitated or not adjacent to the source of the radial, in those cases returns FALSE, otherwise returns TRUE + * Arguments: + * * customer - (Mob REF) The mob checking the menu + */ +/datum/component/trader/proc/check_menu(mob/customer) + if(!istype(customer)) + return FALSE + if(IS_DEAD_OR_INCAP(customer) || !customer.Adjacent(parent)) + return FALSE + return TRUE + +/** + * Generates a radial of the items the NPC sells and lets the user try to buy one + * Arguments: + * * customer - (Mob REF) The mob trying to buy something + */ +/datum/component/trader/proc/buy_item(mob/customer) + if(!can_trade(customer)) + return + + if(!LAZYLEN(products)) + return + + var/list/display_names = list() + var/list/items = list() + var/list/product_info + + for(var/obj/item/thing as anything in products) + display_names["[initial(thing.name)]"] = thing + + if(!radial_icons_cache[thing]) + radial_icons_cache[thing] = image(icon = initial(thing.icon), icon_state = initial(thing.icon_state_preview) ? initial(thing.icon_state_preview) : initial(thing.icon_state)) + + var/image/item_image = radial_icons_cache[thing] + product_info = products[thing] + + if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //out of stock + item_image.overlays += radial_icons_cache[TRADER_RADIAL_OUT_OF_STOCK] + + items += list("[initial(thing.name)]" = item_image) + + var/pick = show_radial_menu(customer, parent, items, custom_check = CALLBACK(src, PROC_REF(check_menu), customer), require_near = TRUE, tooltips = TRUE) + if(!pick || !can_trade(customer)) + return + + var/obj/item/item_to_buy = display_names[pick] + var/mob/living/trader = parent + trader.face_atom(customer) + product_info = products[item_to_buy] + + if(!product_info[TRADER_PRODUCT_INFO_QUANTITY]) + trader.say("[initial(item_to_buy.name)] appears to be out of stock.") + return + + trader.say("It will cost you [product_info[TRADER_PRODUCT_INFO_PRICE]] [trader_data.currency_name] to buy \the [initial(item_to_buy.name)]. Are you sure you want to buy it?") + var/list/npc_options = list( + TRADER_OPTION_YES = radial_icons_cache[TRADER_RADIAL_YES], + TRADER_OPTION_NO = radial_icons_cache[TRADER_RADIAL_NO], + ) + + var/buyer_will_buy = show_radial_menu(customer, trader, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), customer), require_near = TRUE, tooltips = TRUE) + if(buyer_will_buy != TRADER_OPTION_YES || !can_trade(customer)) + return + + trader.face_atom(customer) + + if(!spend_buyer_offhand_money(customer, product_info[TRADER_PRODUCT_INFO_PRICE])) + trader.say(trader_data.return_trader_phrase(NO_CASH_PHRASE)) + return + + item_to_buy = new item_to_buy(get_turf(customer)) + customer.put_in_hands(item_to_buy) + playsound(trader, trader_data.sell_sound, 50, TRUE) + log_econ("[item_to_buy] has been sold to [customer] (typepath used for product info; [item_to_buy.type]) by [trader] for [product_info[TRADER_PRODUCT_INFO_PRICE]] cash.") + product_info[TRADER_PRODUCT_INFO_QUANTITY] -= 1 + trader.say(trader_data.return_trader_phrase(BUY_PHRASE)) + +///Calculates the value of money in the hand of the buyer and spends it if it's sufficient +/datum/component/trader/proc/spend_buyer_offhand_money(mob/customer, the_cost) + var/value = 0 + var/obj/item/holochip/cash = customer.is_holding_item_of_type(/obj/item/holochip) + if(cash) + value += cash.credits + if((value >= the_cost) && cash) + return cash.spend(the_cost) + return FALSE //Purchase unsuccessful + +/** + * Tries to call sell_item on one of the customer's held items, if fail gives a chat message + * + * Gets both items in the customer's hands, and then tries to call sell_item on them, if both fail, he gives a chat message + * Arguments: + * * customer - (Mob REF) The mob trying to sell something + */ +/datum/component/trader/proc/try_sell(mob/customer) + if(!can_trade(customer)) + return + var/sold_item = FALSE + for(var/obj/item/an_item in customer.held_items) + if(sell_item(customer, an_item)) + sold_item = TRUE + break + if(!sold_item && can_trade(customer)) //only talk if you are not dead or in combat + var/mob/living/trader = parent + trader.say(trader_data.return_trader_phrase(ITEM_REJECTED_PHRASE)) + + +/** + * Checks if an item is in the list of wanted items and if it is after a Yes/No radial returns generate_cash with the value of the item for the NPC + * Arguments: + * * customer - (Mob REF) The mob trying to sell something + * * selling - (Item REF) The item being sold + */ +/datum/component/trader/proc/sell_item(mob/customer, obj/item/selling) + if(isnull(selling)) + return FALSE + var/list/product_info + //Keep track of the typepath; rather mundane but it's required for correctly modifying the wanted_items + //should a product be sellable because even if it doesn't have a entry because it's a child of a parent that is present on the list + var/typepath_for_product_info + + if(selling.type in wanted_items) + product_info = wanted_items[selling.type] + typepath_for_product_info = selling.type + else //Assume wanted_items is setup in the correct way; read wanted_items documentation for more info + for(var/typepath in wanted_items) + if(!istype(selling, typepath)) + continue + + product_info = wanted_items[typepath] + typepath_for_product_info = typepath + break + + if(!product_info) //Nothing interesting to sell + return FALSE + + var/mob/living/trader = parent + + if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) + trader.say(trader_data.return_trader_phrase(TRADER_HAS_ENOUGH_ITEM_PHRASE)) + return FALSE + + var/cost = apply_sell_price_mods(selling, product_info[TRADER_PRODUCT_INFO_PRICE]) + if(cost <= 0) + trader.say(trader_data.return_trader_phrase(ITEM_IS_WORTHLESS_PHRASE)) + return FALSE + + trader.say(trader_data.return_trader_phrase(INTERESTED_PHRASE)) + trader.say("You will receive [cost] [trader_data.currency_name] for the [selling].") + var/list/npc_options = list( + TRADER_OPTION_YES = radial_icons_cache[TRADER_RADIAL_YES], + TRADER_OPTION_NO = radial_icons_cache[TRADER_RADIAL_NO], + ) + + trader.face_atom(customer) + + var/npc_result = show_radial_menu(customer, trader, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), customer), require_near = TRUE, tooltips = TRUE) + if(!can_trade(customer)) + return + if(npc_result != TRADER_OPTION_YES) + trader.say(trader_data.return_trader_phrase(ITEM_SELLING_CANCELED_PHRASE)) + return TRUE + + trader.say(trader_data.return_trader_phrase(ITEM_SELLING_ACCEPTED_PHRASE)) + playsound(trader, trader_data.sell_sound, 50, TRUE) + log_econ("[selling] has been sold to [trader] (typepath used for product info; [typepath_for_product_info]) by [customer] for [cost] cash.") + exchange_sold_items(selling, cost, typepath_for_product_info) + generate_cash(cost, customer) + return TRUE + +/** + * Modifies the 'base' price of a item based on certain variables + * + * Arguments: + * * Reference to the item; this is the item being sold + * * Original cost; the original cost of the item, to be manipulated depending on the variables of the item, one example is using item.amount if it's a stack + */ +/datum/component/trader/proc/apply_sell_price_mods(obj/item/selling, original_cost) + if(isstack(selling)) + var/obj/item/stack/stackoverflow = selling + original_cost *= stackoverflow.amount + return original_cost + +/** + * Handles modifying/deleting the items to ensure that a proper amount is converted into cash; put into it's own proc to make the children of this not override a 30+ line sell_item() + * + * Arguments: + * * selling - (Item REF) this is the item being sold + * * value_exchanged_for - (Number) the "value", useful for a scenario where you want to remove enough items equal to the value + * * original_typepath - (Typepath) For scenarios where a children of a parent is being sold but we want to modify the parent's product information + */ +/datum/component/trader/proc/exchange_sold_items(obj/item/selling, value_exchanged_for, original_typepath) + var/list/product_info = wanted_items[original_typepath] + if(isstack(selling)) + var/obj/item/stack/the_stack = selling + var/actually_sold = min(the_stack.amount, product_info[TRADER_PRODUCT_INFO_QUANTITY]) + the_stack.use(actually_sold) + product_info[TRADER_PRODUCT_INFO_QUANTITY] -= (actually_sold) + else + qdel(selling) + product_info[TRADER_PRODUCT_INFO_QUANTITY] -= 1 + +/** + * Creates an item equal to the value set by the proc and puts it in the user's hands if possible + * Arguments: + * * value - A number; The amount of cash that will be on the holochip + * * customer - Reference to a mob; The mob we put the holochip in hands of + */ +/datum/component/trader/proc/generate_cash(value, mob/customer) + var/obj/item/holochip/chip = new /obj/item/holochip(get_turf(customer), value) + customer.put_in_hands(chip) + +///Talk about what items are being sold/wanted by the trader and in what quantity or lore +/datum/component/trader/proc/discuss(mob/customer) + var/list/npc_options = list( + TRADER_OPTION_LORE = radial_icons_cache[TRADER_RADIAL_LORE], + TRADER_OPTION_SELLING = radial_icons_cache[TRADER_RADIAL_DISCUSS_SELL], + TRADER_OPTION_BUYING = radial_icons_cache[TRADER_RADIAL_DISCUSS_BUY], + ) + var/pick = show_radial_menu(customer, parent, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), customer), require_near = TRUE, tooltips = TRUE) + if(!can_trade(customer)) + return + switch(pick) + if(TRADER_OPTION_LORE) + var/mob/living/trader = parent + trader.say(trader_data.return_trader_phrase(TRADER_LORE_PHRASE)) + if(TRADER_OPTION_BUYING) + trader_buys_what(customer) + if(TRADER_OPTION_SELLING) + trader_sells_what(customer) + +///Displays to the customer what the trader is willing to buy and how much until a restock happens +/datum/component/trader/proc/trader_buys_what(mob/customer) + if(!can_trade(customer)) + return + if(!length(wanted_items)) + var/mob/living/trader = parent + trader.say(trader_data.return_trader_phrase(TRADER_NOT_BUYING_ANYTHING)) + return + + var/list/buy_info = list(span_green("I'm willing to buy the following:")) + + var/list/product_info + for(var/obj/item/thing as anything in wanted_items) + product_info = wanted_items[thing] + var/tern_op_result = (product_info[TRADER_PRODUCT_INFO_QUANTITY] == INFINITY ? "as many as I can." : "[product_info[TRADER_PRODUCT_INFO_QUANTITY]]") //Coder friendly string concat + if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //Zero demand + buy_info += span_notice("• [span_red("(DOESN'T WANT MORE)")] [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [trader_data.currency_name][product_info[TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION]]; willing to buy [span_red("[tern_op_result]")] more.") + else + buy_info += span_notice("• [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [trader_data.currency_name][product_info[TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION]]; willing to buy [span_green("[tern_op_result]")]") + + to_chat(customer, examine_block(buy_info.Join("\n"))) + +///Displays to the customer what the trader is selling and how much is in stock +/datum/component/trader/proc/trader_sells_what(mob/customer) + if(!can_trade(customer)) + return + var/mob/living/trader = parent + if(!length(products)) + trader.say(trader_data.return_trader_phrase(TRADER_NOT_SELLING_ANYTHING)) + return + var/list/sell_info = list(span_green("I'm currently selling the following:")) + var/list/product_info + for(var/obj/item/thing as anything in products) + product_info = products[thing] + var/tern_op_result = (product_info[TRADER_PRODUCT_INFO_QUANTITY] == INFINITY ? "an infinite amount" : "[product_info[TRADER_PRODUCT_INFO_QUANTITY]]") //Coder friendly string concat + if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //Out of stock + sell_info += span_notice("• [span_red("(OUT OF STOCK)")] [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [trader_data.currency_name]; [span_red("[tern_op_result]")] left in stock") + else + sell_info += span_notice("• [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [trader_data.currency_name]; [span_green("[tern_op_result]")] left in stock") + to_chat(customer, examine_block(sell_info.Join("\n"))) + +///Sets quantity of all products to initial(quanity); this proc is currently called during initialize +/datum/component/trader/proc/restock_products() + products = trader_data.initial_products.Copy() + +///Sets quantity of all wanted_items to initial(quanity); this proc is currently called during initialize +/datum/component/trader/proc/renew_item_demands() + wanted_items = trader_data.initial_wanteds.Copy() + +///Returns if the trader is conscious and its combat mode is disabled. +/datum/component/trader/proc/can_trade(mob/customer) + var/mob/living/trader = parent + if(trader.combat_mode) + trader.balloon_alert(customer, "in combat!") + return FALSE + if(IS_DEAD_OR_INCAP(trader)) + trader.balloon_alert(customer, "indisposed!") + return FALSE + return TRUE + +#undef TRADER_RADIAL_BUY +#undef TRADER_RADIAL_SELL +#undef TRADER_RADIAL_TALK +#undef TRADER_RADIAL_LORE +#undef TRADER_RADIAL_DISCUSS_BUY +#undef TRADER_RADIAL_DISCUSS_SELL +#undef TRADER_RADIAL_NO +#undef TRADER_RADIAL_YES +#undef TRADER_RADIAL_OUT_OF_STOCK +#undef TRADER_PRODUCT_INFO_PRICE +#undef TRADER_PRODUCT_INFO_QUANTITY +#undef TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION + +#undef TRADER_OPTION_BUY +#undef TRADER_OPTION_SELL +#undef TRADER_OPTION_TALK +#undef TRADER_OPTION_LORE +#undef TRADER_OPTION_NO +#undef TRADER_OPTION_YES +#undef TRADER_OPTION_BUYING +#undef TRADER_OPTION_SELLING diff --git a/code/datums/elements/ai_swap_combat_mode.dm b/code/datums/elements/ai_swap_combat_mode.dm new file mode 100644 index 00000000000..2b7167000fc --- /dev/null +++ b/code/datums/elements/ai_swap_combat_mode.dm @@ -0,0 +1,66 @@ +/** + * Attached to a mob with an AI controller, updates combat mode when the affected mob acquires or loses targets + */ +/datum/element/ai_swap_combat_mode + element_flags = ELEMENT_BESPOKE + argument_hash_start_idx = 2 + /// The message we yell when we enter combat mode + var/list/battle_start_barks + /// A one liner said when we exit combat mode + var/list/battle_end_barks + /// The chance to yell the above lines + var/speech_chance + /// Target key + var/target_key + +/datum/element/ai_swap_combat_mode/Attach(datum/target, target_key, list/battle_start_barks = null, list/battle_end_barks = null) + . = ..() + if(!isliving(target)) + return ELEMENT_INCOMPATIBLE + var/mob/living/living_target = target + if(!living_target.ai_controller) + return ELEMENT_INCOMPATIBLE + + if(isnull(battle_start_barks)) + battle_start_barks = list("En Garde!",) + + if(isnull(battle_end_barks)) + battle_end_barks = list("Never should have come here",) + + src.battle_start_barks = battle_start_barks + src.battle_end_barks = battle_end_barks + src.target_key = target_key + RegisterSignal(target, COMSIG_AI_BLACKBOARD_KEY_SET(target_key), PROC_REF(on_target_gained)) + RegisterSignal(target, COMSIG_AI_BLACKBOARD_KEY_CLEARED(target_key), PROC_REF(on_target_cleared)) + +/datum/element/ai_swap_combat_mode/Detach(datum/source) + . = ..() + UnregisterSignal(source, list( + COMSIG_AI_BLACKBOARD_KEY_SET(target_key), + COMSIG_AI_BLACKBOARD_KEY_CLEARED(target_key), + )) + +/// When the mob gains a target, and it was not already in combat mode, enter it +/datum/element/ai_swap_combat_mode/proc/on_target_gained(mob/living/source) + SIGNAL_HANDLER + + if(swap_mode(source, TRUE)) + INVOKE_ASYNC(src, PROC_REF(speak_bark), source, battle_start_barks) + +/// When the mob loses its target, and it was not already out of combat mode, exit it +/datum/element/ai_swap_combat_mode/proc/on_target_cleared(mob/living/source) + SIGNAL_HANDLER + + if(swap_mode(source, FALSE)) + INVOKE_ASYNC(src, PROC_REF(speak_bark), source, battle_end_barks) + +///Says a quip, if the RNG allows it +/datum/element/ai_swap_combat_mode/proc/speak_bark(mob/living/source, line) + source.say(pick(line)) + +///If the combat mode would be changed into a different state, updates it and returns TRUE, otherwise returns FALSE +/datum/element/ai_swap_combat_mode/proc/swap_mode(mob/living/source, new_mode) + if(source.combat_mode == new_mode) + return FALSE + source.set_combat_mode(new_mode) + return TRUE diff --git a/code/modules/hallucination/body.dm b/code/modules/hallucination/body.dm index 5167b63c5ee..2d017f96967 100644 --- a/code/modules/hallucination/body.dm +++ b/code/modules/hallucination/body.dm @@ -151,11 +151,11 @@ body_floats = TRUE /datum/hallucination/body/weird/faceless - body_image_file = 'icons/mob/simple/traders.dmi' + body_image_file = 'icons/obj/trader_signs.dmi' body_image_state = "faceless" /datum/hallucination/body/weird/bones - body_image_file = 'icons/mob/simple/traders.dmi' + body_image_file = 'icons/obj/trader_signs.dmi' body_image_state = "mrbones" /datum/hallucination/body/weird/freezer diff --git a/code/modules/mob/living/basic/trader/trader.dm b/code/modules/mob/living/basic/trader/trader.dm new file mode 100644 index 00000000000..29a2bda4199 --- /dev/null +++ b/code/modules/mob/living/basic/trader/trader.dm @@ -0,0 +1,79 @@ +/mob/living/basic/trader + name = "Trader" + desc = "Come buy some!" + unique_name = FALSE + icon = 'icons/mob/simple/simple_human.dmi' + maxHealth = 200 + health = 200 + melee_damage_lower = 10 + melee_damage_upper = 10 + attack_verb_continuous = "punches" + attack_verb_simple = "punch" + attack_sound = 'sound/weapons/punch1.ogg' + basic_mob_flags = DEL_ON_DEATH + unsuitable_atmos_damage = 2.5 + combat_mode = FALSE + move_resist = MOVE_FORCE_STRONG + mob_biotypes = MOB_ORGANIC|MOB_HUMANOID + sentience_type = SENTIENCE_HUMANOID + speed = 0 + + ai_controller = /datum/ai_controller/basic_controller/trader + + ///Sound used when item sold/bought + var/sell_sound = 'sound/effects/cashregister.ogg' + ///The currency name + var/currency_name = "credits" + ///The spawner we use to create our look + var/spawner_path = /obj/effect/mob_spawn/corpse/human/generic_assistant + ///Our species to create our look + var/species_path = /datum/species/human + ///The loot we drop when we die + var/loot = list(/obj/effect/mob_spawn/corpse/human/generic_assistant) + ///Casing used to shoot during retaliation + var/ranged_attack_casing = /obj/item/ammo_casing/shotgun/buckshot + ///Sound to make while doing a retalitory attack + var/ranged_attack_sound = 'sound/weapons/gun/pistol/shot.ogg' + ///Weapon path, for visuals + var/held_weapon_visual = /obj/item/gun/ballistic/shotgun + + ///Type path for the trader datum to use for retrieving the traders wares, speech, etc + var/trader_data_path = /datum/trader_data + + +/mob/living/basic/trader/Initialize(mapload) + . = ..() + apply_dynamic_human_appearance(src, species_path = species_path, mob_spawn_path = spawner_path, r_hand = held_weapon_visual) + + var/datum/trader_data/trader_data = new trader_data_path + AddComponent(/datum/component/trader, trader_data = trader_data) + AddComponent(/datum/component/ranged_attacks, casing_type = ranged_attack_casing, projectile_sound = ranged_attack_sound, cooldown_time = 3 SECONDS) + AddElement(/datum/element/ai_retaliate) + AddElement(/datum/element/ai_swap_combat_mode, BB_BASIC_MOB_CURRENT_TARGET, string_list(trader_data.say_phrases[TRADER_BATTLE_START_PHRASE]), string_list(trader_data.say_phrases[TRADER_BATTLE_END_PHRASE])) + if(LAZYLEN(loot)) + loot = string_list(loot) + AddElement(/datum/element/death_drops, loot) + + var/datum/action/setup_shop/setup_shop = new (src, trader_data.shop_spot_type, trader_data.sign_type, trader_data.sell_sound, trader_data.say_phrases[TRADER_SHOP_OPENING_PHRASE]) + setup_shop.Grant(src) + ai_controller.set_blackboard_key(BB_SETUP_SHOP, setup_shop) + +/mob/living/basic/trader/mrbones + name = "Mr. Bones" + desc = "A skeleton merchant, he seems very humerus." + speak_emote = list("rattles") + speech_span = SPAN_SANS + mob_biotypes = MOB_UNDEAD|MOB_HUMANOID + icon_state = "mrbones" + gender = MALE + + ai_controller = /datum/ai_controller/basic_controller/trader/jumpscare + + sell_sound = 'sound/voice/hiss2.ogg' + species_path = /datum/species/skeleton + spawner_path = /obj/effect/mob_spawn/corpse/human/skeleton/mrbones + loot = list(/obj/effect/decal/remains/human) + ranged_attack_casing = /obj/item/ammo_casing/energy/bolt/halloween + held_weapon_visual = /obj/item/gun/ballistic/revolver + + trader_data_path = /datum/trader_data/mr_bones diff --git a/code/modules/mob/living/basic/trader/trader_actions.dm b/code/modules/mob/living/basic/trader/trader_actions.dm new file mode 100644 index 00000000000..ded9fbd46d0 --- /dev/null +++ b/code/modules/mob/living/basic/trader/trader_actions.dm @@ -0,0 +1,72 @@ +/datum/action/setup_shop + name = "Setup shop" + desc = "Summons a wacky sales sign, and a comfy sitting spot to conduct your business from." + button_icon = 'icons/mob/actions/actions_trader.dmi' + button_icon_state = "setup_shop" + /// The shop spot + var/datum/weakref/shop_spot_ref + /// The server this console is connected to. + var/datum/weakref/sign_ref + /// The type of the chair we sit on + var/shop_spot_type + /// The type of our advertising sign + var/sign_type + /// The sound we make when we summon our shop gear + var/shop_sound + /// Lines we say when we open our shop + var/opening_lines + +/datum/action/setup_shop/IsAvailable(feedback = FALSE) + . = ..() + if (!.) + return FALSE + if(shop_spot_ref?.resolve()) + if(feedback) + owner.balloon_alert(owner, "already set up!") + return FALSE + return TRUE + +/datum/action/setup_shop/New(Target, shop_spot_type = /obj/structure/chair/plastic, sign_type = /obj/structure/trader_sign, sell_sound = 'sound/effects/cashregister.ogg', opening_lines = list("Welcome to my shop, friend!")) + . = ..() + + src.shop_spot_type = shop_spot_type + src.sign_type = sign_type + src.shop_sound = sell_sound + src.opening_lines = opening_lines + +/datum/action/setup_shop/Trigger(trigger_flags) + . = ..() + if(!.) + return + + owner.say(pick(opening_lines)) + var/obj/shop_spot = new shop_spot_type(owner.loc) + shop_spot.dir = owner.dir + shop_spot_ref = WEAKREF(shop_spot) + owner.ai_controller?.set_blackboard_key(BB_SHOP_SPOT, shop_spot) + + playsound(owner, shop_sound, 50, TRUE) + + var/turf/sign_turf + + sign_turf = try_find_valid_spot(owner.loc, turn(shop_spot.dir, -90)) + if(isnull(sign_turf)) //No space to my left, lets try right + sign_turf = try_find_valid_spot(owner.loc, turn(shop_spot.dir, 90)) + + if(isnull(sign_turf)) + return + + var/obj/sign = sign_ref?.resolve() + if(QDELETED(sign)) + var/obj/new_sign = new sign_type(sign_turf) + sign_ref = WEAKREF(sign) + do_sparks(3, FALSE, new_sign) + else + do_teleport(sign,sign_turf) + +///Look for a spot we can place our sign on +/datum/action/setup_shop/proc/try_find_valid_spot(origin_turf, direction_to_check) + var/turf/sign_turf = get_step(origin_turf, direction_to_check) + if(sign_turf && !isgroundlessturf(sign_turf) && !isclosedturf(sign_turf) && !sign_turf.is_blocked_turf()) + return sign_turf + return null diff --git a/code/modules/mob/living/basic/trader/trader_ai.dm b/code/modules/mob/living/basic/trader/trader_ai.dm new file mode 100644 index 00000000000..d6cf0095c7d --- /dev/null +++ b/code/modules/mob/living/basic/trader/trader_ai.dm @@ -0,0 +1,95 @@ +/datum/ai_controller/basic_controller/trader + blackboard = list( + BB_TARGETTING_DATUM = new /datum/targetting_datum/basic, + ) + + ai_movement = /datum/ai_movement/basic_avoidance + idle_behavior = /datum/idle_behavior/idle_random_walk/not_while_on_target/trader + planning_subtrees = list( + /datum/ai_planning_subtree/target_retaliate, + /datum/ai_planning_subtree/basic_ranged_attack_subtree/trader, + /datum/ai_planning_subtree/prepare_travel_to_destination/trader, + /datum/ai_planning_subtree/travel_to_point/and_clear_target, + /datum/ai_planning_subtree/setup_shop, + ) + +/datum/ai_controller/basic_controller/trader/jumpscare + planning_subtrees = list( + /datum/ai_planning_subtree/target_retaliate, + /datum/ai_planning_subtree/basic_ranged_attack_subtree/trader, + /datum/ai_planning_subtree/prepare_travel_to_destination/trader, + /datum/ai_planning_subtree/travel_to_point/and_clear_target, + /datum/ai_planning_subtree/setup_shop/jumpscare, + ) + +/datum/ai_planning_subtree/basic_ranged_attack_subtree/trader + ranged_attack_behavior = /datum/ai_behavior/basic_ranged_attack/trader + +/datum/ai_behavior/basic_ranged_attack/trader + action_cooldown = 3 SECONDS + avoid_friendly_fire = TRUE + +///Subtree to find our very first customer and set up our shop after walking right into their face +/datum/ai_planning_subtree/setup_shop + ///What do we do in order to offer our deals? + var/datum/ai_behavior/setup_shop/setup_shop_behavior = /datum/ai_behavior/setup_shop + +/datum/ai_planning_subtree/setup_shop/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + + //If we don't have our ability, return + if(!controller.blackboard_key_exists(BB_SETUP_SHOP)) + return + + //If we already have a shop spot, return + if(controller.blackboard_key_exists(BB_SHOP_SPOT)) + return + + //If we don't have a costurmer to greet, look for one + if(!controller.blackboard_key_exists(BB_FIRST_CUSTOMER)) + controller.queue_behavior(/datum/ai_behavior/find_and_set/conscious_person, BB_FIRST_CUSTOMER, /mob/living/carbon/human) + return + + //We have our first customer, time to tell them about incredible deals + controller.queue_behavior(setup_shop_behavior, BB_FIRST_CUSTOMER) + return SUBTREE_RETURN_FINISH_PLANNING + +///The ai will create a shop the moment they see a potential costumer +/datum/ai_behavior/setup_shop + +/datum/ai_behavior/setup_shop/setup(datum/ai_controller/controller, target_key) + var/obj/target = controller.blackboard[target_key] + return !QDELETED(target) + +/datum/ai_behavior/setup_shop/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + . = ..() + + //We lost track of our costumer or our ability, abort + if(!controller.blackboard_key_exists(target_key) || !controller.blackboard_key_exists(BB_SETUP_SHOP)) + finish_action(controller, FALSE, target_key) + return + + var/datum/action/setup_shop/shop = controller.blackboard[BB_SETUP_SHOP] + shop.Trigger() + + controller.clear_blackboard_key(BB_FIRST_CUSTOMER) + + finish_action(controller, TRUE, target_key) + +/datum/idle_behavior/idle_random_walk/not_while_on_target/trader + target_key = BB_SHOP_SPOT + +///Version of setup show where the trader will run at you to assault you with incredible deals +/datum/ai_planning_subtree/setup_shop/jumpscare + setup_shop_behavior = /datum/ai_behavior/setup_shop/jumpscare + +/datum/ai_behavior/setup_shop/jumpscare + behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_REQUIRE_REACH + +/datum/ai_behavior/setup_shop/jumpscare/setup(datum/ai_controller/controller, target_key) + . = ..() + if(.) + set_movement_target(controller, controller.blackboard[target_key]) + +/datum/ai_behavior/setup_shop/finish_action(datum/ai_controller/controller, succeeded, target_key) + . = ..() + controller.clear_blackboard_key(target_key) diff --git a/code/modules/mob/living/basic/trader/trader_data.dm b/code/modules/mob/living/basic/trader/trader_data.dm new file mode 100644 index 00000000000..9762dc02be5 --- /dev/null +++ b/code/modules/mob/living/basic/trader/trader_data.dm @@ -0,0 +1,156 @@ +///Used to contain the traders initial wares, and speech +/datum/trader_data + + ///The item that marks the shopkeeper will sit on + var/shop_spot_type = /obj/structure/chair/plastic + ///The sign that will greet the customers + var/sign_type = /obj/structure/trader_sign + ///Sound used when item sold/bought + var/sell_sound = 'sound/effects/cashregister.ogg' + ///The currency name + var/currency_name = "credits" + ///The initial products that the trader offers + var/list/initial_products = list( + /obj/item/food/burger/ghost = list(PAYCHECK_CREW * 4, INFINITY), + ) + ///The initial products that the trader buys + var/list/initial_wanteds = list( + /obj/item/ectoplasm = list(PAYCHECK_CREW * 2, INFINITY, ""), + ) + ///The speech data of the trader + var/list/say_phrases = list( + ITEM_REJECTED_PHRASE = list( + "Sorry, I'm not a fan of anything you're showing me. Give me something better and we'll talk.", + ), + ITEM_SELLING_CANCELED_PHRASE = list( + "What a shame, tell me if you changed your mind.", + ), + ITEM_SELLING_ACCEPTED_PHRASE = list( + "Pleasure doing business with you.", + ), + INTERESTED_PHRASE = list( + "Hey, you've got an item that interests me, I'd like to buy it, I'll give you some cash for it, deal?", + ), + BUY_PHRASE = list( + "Pleasure doing business with you.", + ), + NO_CASH_PHRASE = list( + "Sorry adventurer, I can't give credit! Come back when you're a little mmmmm... richer!", + ), + NO_STOCK_PHRASE = list( + "Sorry adventurer, but that item is not in stock at the moment.", + ), + NOT_WILLING_TO_BUY_PHRASE = list( + "I don't want to buy that item for the time being, check back another time.", + ), + ITEM_IS_WORTHLESS_PHRASE = list( + "This item seems to be worthless on a closer look, I won't buy this.", + ), + TRADER_HAS_ENOUGH_ITEM_PHRASE = list( + "I already bought enough of this for the time being.", + ), + TRADER_LORE_PHRASE = list( + "Hello! I am the test trader.", + "Oooooooo~!", + ), + TRADER_NOT_BUYING_ANYTHING = list( + "I'm currently buying nothing at the moment.", + ), + TRADER_NOT_SELLING_ANYTHING = list( + "I'm currently selling nothing at the moment.", + ), + TRADER_BATTLE_START_PHRASE = list( + "Thief!", + ), + TRADER_BATTLE_END_PHRASE = list( + "That is a discount I call death.", + ), + TRADER_SHOP_OPENING_PHRASE = list( + "Welcome to my shop, friend!", + ), + ) + +/** + * Depending on the passed parameter/override, returns a randomly picked string out of a list + * + * Do note when overriding this argument, you will need to ensure pick(the list) doesn't get supplied with a list of zero length + * Arguments: + * * say_text - (String) a define that matches the key of a entry in say_phrases + */ +/datum/trader_data/proc/return_trader_phrase(say_text) + if(!length(say_phrases[say_text])) + return + return pick(say_phrases[say_text]) + +/datum/trader_data/mr_bones + shop_spot_type = /obj/structure/chair/wood/wings + sign_type = /obj/structure/trader_sign/mrbones + sell_sound = 'sound/voice/hiss2.ogg' + + initial_products = list( + /obj/item/clothing/head/helmet/skull = list(PAYCHECK_CREW * 3, INFINITY), + /obj/item/clothing/mask/bandana/skull/black = list(PAYCHECK_CREW, INFINITY), + /obj/item/food/cookie/sugar/spookyskull = list(PAYCHECK_CREW * 0.2, INFINITY), + /obj/item/instrument/trombone/spectral = list(PAYCHECK_CREW * 200, INFINITY), + /obj/item/shovel/serrated = list(PAYCHECK_CREW * 3, INFINITY), + ) + + initial_wanteds = list( + /obj/item/reagent_containers/condiment/milk = list(PAYCHECK_CREW * 20, INFINITY, ""), + /obj/item/stack/sheet/bone = list(PAYCHECK_CREW * 8.4, INFINITY, ", per sheet of bone"), + ) + + say_phrases = list( + ITEM_REJECTED_PHRASE = list( + "Sorry, I'm not a fan of anything you're showing me. Give me something better and we'll talk.", + ), + ITEM_SELLING_CANCELED_PHRASE = list( + "What a shame, tell me if you changed your mind.", + ), + ITEM_SELLING_ACCEPTED_PHRASE = list( + "Pleasure doing business with you.", + ), + INTERESTED_PHRASE = list( + "Hey, you've got an item that interests me, I'd like to buy it, I'll give you some cash for it, deal?", + ), + BUY_PHRASE = list( + "Bone appetit!", + ), + NO_CASH_PHRASE = list( + "Sorry adventurer, I can't give credit! Come back when you're a little mmmmm... richer!", + ), + NO_STOCK_PHRASE = list( + "Sorry adventurer, but that item is not in stock at the moment.", + ), + NOT_WILLING_TO_BUY_PHRASE = list( + "I don't want to buy that item for the time being, check back another time.", + ), + ITEM_IS_WORTHLESS_PHRASE = list( + "This item seems to be worthless on a closer look, I won't buy this.", + ), + TRADER_HAS_ENOUGH_ITEM_PHRASE = list( + "I already bought enough of this for the time being.", + ), + TRADER_LORE_PHRASE = list( + "Hello, I am Mr. Bones!", + "The ride never ends!", + "I'd really like a refreshing carton of milk!", + "I'm willing to play big prices for BONES! Need materials to make merch, eh?", + "It's a beautiful day outside. Birds are singing, Flowers are blooming... On days like these, kids like you... Should be buying my wares!", + ), + TRADER_NOT_BUYING_ANYTHING = list( + "I'm currently buying nothing at the moment.", + ), + TRADER_NOT_SELLING_ANYTHING = list( + "I'm currently selling nothing at the moment.", + ), + TRADER_BATTLE_START_PHRASE = list( + "The ride ends for you!", + ), + TRADER_BATTLE_END_PHRASE = list( + "Mr. Bones never misses!", + ), + TRADER_SHOP_OPENING_PHRASE = list( + "My wild ride is open!", + ), + ) diff --git a/code/modules/mob/living/basic/trader/trader_items.dm b/code/modules/mob/living/basic/trader/trader_items.dm new file mode 100644 index 00000000000..173e33d4c2f --- /dev/null +++ b/code/modules/mob/living/basic/trader/trader_items.dm @@ -0,0 +1,36 @@ +///Sale signs +/obj/structure/trader_sign + name = "holographic store sign" + desc = "A holographic sign that promises great deals." + icon = 'icons/obj/trader_signs.dmi' + icon_state = "faceless" + anchored = TRUE + armor_type = /datum/armor/trader_sign + max_integrity = 15 + layer = FLY_LAYER + +/datum/armor/trader_sign + bullet = 50 + laser = 50 + energy = 50 + fire = 20 + acid = 20 + +/obj/structure/trader_sign/Initialize(mapload) + . = ..() + add_overlay("sign") + makeHologram() + + +/obj/structure/trader_sign/mrbones + icon_state = "mrbones" + + +///Spawners for outfits +/obj/effect/mob_spawn/corpse/human/skeleton/mrbones + mob_species = /datum/species/skeleton + outfit = /datum/outfit/mrbonescorpse + +/datum/outfit/mrbonescorpse + name = "Mr Bones' Corpse" + head = /obj/item/clothing/head/hats/tophat diff --git a/code/modules/mob/living/simple_animal/hostile/retaliate/trader.dm b/code/modules/mob/living/simple_animal/hostile/retaliate/trader.dm deleted file mode 100644 index 5be6fc9575d..00000000000 --- a/code/modules/mob/living/simple_animal/hostile/retaliate/trader.dm +++ /dev/null @@ -1,501 +0,0 @@ -#define ITEM_REJECTED_PHRASE "ITEM_REJECTED_PHRASE" -#define ITEM_SELLING_CANCELED_PHRASE "ITEM_SELLING_CANCELED_PHRASE" -#define ITEM_SELLING_ACCEPTED_PHRASE "ITEM_SELLING_ACCEPTED_PHRASE" -#define INTERESTED_PHRASE "INTERESTED_PHRASE" -#define BUY_PHRASE "BUY_PHRASE" -#define NO_CASH_PHRASE "NO_CASH_PHRASE" -#define NO_STOCK_PHRASE "NO_STOCK_PHRASE" -#define NOT_WILLING_TO_BUY_PHRASE "NOT_WILLING_TO_BUY_PHRASE" -#define ITEM_IS_WORTHLESS_PHRASE "ITEM_IS_WORTHLESS_PHRASE" -#define TRADER_HAS_ENOUGH_ITEM_PHRASE "TRADER_HAS_ENOUGH_ITEM_PHRASE" -#define TRADER_LORE_PHRASE "TRADER_LORE_PHRASE" -#define TRADER_NOT_BUYING_ANYTHING "TRADER_NOT_BUYING_ANYTHING" -#define TRADER_NOT_SELLING_ANYTHING "TRADER_NOT_SELLING_ANYTHING" - -#define TRADER_PRODUCT_INFO_PRICE 1 -#define TRADER_PRODUCT_INFO_QUANTITY 2 -//Only valid for wanted_items -#define TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION 3 - -/** - * # Trader - * - * A mob that has some dialogue options with radials, allows for selling items and buying em' - * - */ -/mob/living/simple_animal/hostile/retaliate/trader - name = "Trader" - desc = "Come buy some!" - icon = 'icons/mob/simple/traders.dmi' - icon_state = "faceless" - maxHealth = 200 - health = 200 - melee_damage_lower = 10 - melee_damage_upper = 10 - attack_verb_continuous = "punches" - attack_verb_simple = "punch" - attack_sound = 'sound/weapons/punch1.ogg' - del_on_death = TRUE - loot = list(/obj/effect/mob_spawn/corpse/human) - atmos_requirements = list("min_oxy" = 5, "max_oxy" = 0, "min_plas" = 0, "max_plas" = 1, "min_co2" = 0, "max_co2" = 5, "min_n2" = 0, "max_n2" = 0) - unsuitable_atmos_damage = 2.5 - casingtype = /obj/item/ammo_casing/shotgun/buckshot - wander = FALSE - ranged = TRUE - combat_mode = TRUE - move_resist = MOVE_FORCE_STRONG - mob_biotypes = MOB_ORGANIC|MOB_HUMANOID - sentience_type = SENTIENCE_HUMANOID - speed = 0 - stat_attack = HARD_CRIT - robust_searching = TRUE - check_friendly_fire = TRUE - interaction_flags_atom = INTERACT_ATOM_NO_FINGERPRINT_ATTACK_HAND|INTERACT_ATOM_ATTACK_HAND|INTERACT_ATOM_NO_FINGERPRINT_INTERACT - ///Sound used when item sold/bought - var/sell_sound = 'sound/effects/cashregister.ogg' - /** - * Format; list(TYPEPATH = list(PRICE, QUANTITY)) - * Associated list of items the NPC sells with how much they cost and the quantity available before a restock - * This list is filled by Initialize(), if you want to change the starting products, modify initial_products() - * * - */ - var/list/products - /** - * A list of wanted items that the trader would wish to buy, each typepath has a assigned value, quantity and additional flavor text - * - * CHILDREN OF TYPEPATHS INCLUDED IN WANTED_ITEMS WILL BE TREATED AS THE PARENT IF NO ENTRY EXISTS FOR THE CHILDREN - * - * As an additional note; if you include multiple children of a typepath; the typepath with the most children should be placed after all other typepaths - * Bad; list(/obj/item/milk = list(100, 1, ""), /obj/item/milk/small = list(50, 2, "")) - * Good; list(/obj/item/milk/small = list(50, 2, ""), /obj/item/milk = list(100, 1, "")) - * This is mainly because sell_item() uses a istype(item_being_sold, item_in_entry) to determine what parent should the child be automatically considered as - * If /obj/item/milk/small/spooky was being sold; /obj/item/milk/small would be the first to check against rather than /obj/item/milk - * - * Format; list(TYPEPATH = list(PRICE, QUANTITY, ADDITIONAL_DESCRIPTION)) - * Associated list of items able to be sold to the NPC with the money given for them. - * The price given should be the "base" price; any price manipulation based on variables should be done with apply_sell_price_mods() - * ADDITIONAL_DESCRIPTION is any additional text added to explain how the variables of the item effect the price; if it's stack based, it's final price depends how much is in the stack - * EX; /obj/item/stack/sheet/mineral/diamond = list(500, INFINITY, ", per 100 cm3 sheet of diamond") - * This list is filled by Initialize(), if you want to change the starting wanted items, modify initial_wanteds() - */ - var/list/wanted_items - ///Associated list of defines matched with list of phrases; phrase to be said is dealt by return_trader_phrase() - var/list/say_phrases = list( - ITEM_REJECTED_PHRASE = list( - "Sorry, I'm not a fan of anything you're showing me. Give me something better and we'll talk." - ), - ITEM_SELLING_CANCELED_PHRASE = list( - "What a shame, tell me if you changed your mind." - ), - ITEM_SELLING_ACCEPTED_PHRASE = list( - "Pleasure doing business with you." - ), - INTERESTED_PHRASE = list( - "Hey, you've got an item that interests me, I'd like to buy it, I'll give you some cash for it, deal?" - ), - BUY_PHRASE = list( - "Pleasure doing business with you." - ), - NO_CASH_PHRASE = list( - "Sorry adventurer, I can't give credit! Come back when you're a little mmmmm... richer!" - ), - NO_STOCK_PHRASE = list( - "Sorry adventurer, but that item is not in stock at the moment." - ), - NOT_WILLING_TO_BUY_PHRASE = list( - "I don't want to buy that item for the time being, check back another time." - ), - ITEM_IS_WORTHLESS_PHRASE = list( - "This item seems to be worthless on a closer look, I won't buy this." - ), - TRADER_HAS_ENOUGH_ITEM_PHRASE = list( - "I already bought enough of this for the time being." - ), - TRADER_LORE_PHRASE = list( - "Hello! I am the test trader.", - "Oooooooo~!" - ), - TRADER_NOT_BUYING_ANYTHING = list( - "I'm currently buying nothing at the moment." - ), - TRADER_NOT_SELLING_ANYTHING = list( - "I'm currently selling nothing at the moment." - ), - ) - ///The name of the currency that is used when buying or selling items - var/currency_name = "credits" - -///Initializes the products and item demands of the trader -/mob/living/simple_animal/hostile/retaliate/trader/Initialize(mapload) - . = ..() - restock_products() - renew_item_demands() - -///Returns a list of the starting price/quanity/fluff text about the product listings; products = initial(products) doesn't work so this exists mainly for restock_products() -/mob/living/simple_animal/hostile/retaliate/trader/proc/initial_products() - return list(/obj/item/food/burger/ghost = list(200, INFINITY), - ) - -///Returns a list of the starting price/quanity/fluff text about the wanted items; wanted_items = initial(wanted_items) doesn't work so this exists mainly for renew_item_demands() -/mob/living/simple_animal/hostile/retaliate/trader/proc/initial_wanteds() - return list(/obj/item/ectoplasm = list(100, INFINITY, ""), - ) - -/** - * Depending on the passed parameter/override, returns a randomly picked string out of a list - * - * Do note when overriding this argument, you will need to ensure pick(the list) doesn't get supplied with a list of zero length - * Arguments: - * * say_text - (String) a define that matches the key of a entry in say_phrases - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/return_trader_phrase(say_text) - if(!length(say_phrases[say_text])) - return - return pick(say_phrases[say_text]) - //return (length(say_phrases[say_text]) ? pick(say_phrases[say_text]) : "") - -///Sets up the radials for the user and calls procs related to the actions the user wants to take -/mob/living/simple_animal/hostile/retaliate/trader/interact(mob/user) - if(user == target) - return FALSE - var/list/npc_options = list() - if(products.len) - npc_options["Buy"] = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_buy") - if(length(say_phrases[TRADER_LORE_PHRASE])) - npc_options["Talk"] = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_talk") - if(wanted_items.len) - npc_options["Sell"] = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_sell") - if(!npc_options.len) - return FALSE - var/npc_result = show_radial_menu(user, src, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), user), require_near = TRUE, tooltips = TRUE) - switch(npc_result) - if("Buy") - buy_item(user) - if("Sell") - try_sell(user) - if("Talk") - discuss(user) - face_atom(user) - return TRUE - -/** - * Checks if the user is ok to use the radial - * - * Checks if the user is not a mob or is incapacitated or not adjacent to the source of the radial, in those cases returns FALSE, otherwise returns TRUE - * Arguments: - * * user - (Mob REF) The mob checking the menu - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/check_menu(mob/user) - if(!istype(user)) - return FALSE - if(user.incapacitated() || !user.Adjacent(src)) - return FALSE - return TRUE - -///Talk about what items are being sold/wanted by the trader and in what quantity or lore -/mob/living/simple_animal/hostile/retaliate/trader/proc/discuss(mob/user) - var/list/npc_options = list( - "Lore" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_lore"), - "Selling?" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_selling"), - "Buying?" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_buying"), - ) - var/pick = show_radial_menu(user, src, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), user), require_near = TRUE, tooltips = TRUE) - switch(pick) - if("Lore") - say(return_trader_phrase(TRADER_LORE_PHRASE)) - if("Buying?") - trader_buys_what(user) - if("Selling?") - trader_sells_what(user) - -///Displays to the user what the trader is willing to buy and how much until a restock happens -/mob/living/simple_animal/hostile/retaliate/trader/proc/trader_buys_what(mob/user) - if(!wanted_items.len) - say(return_trader_phrase(TRADER_NOT_BUYING_ANYTHING)) - return - var/list/product_info - to_chat(user, span_green("I'm willing to buy the following; ")) - for(var/obj/item/thing as anything in wanted_items) - product_info = wanted_items[thing] - var/tern_op_result = (product_info[TRADER_PRODUCT_INFO_QUANTITY] == INFINITY ? "as many as I can." : "[product_info[TRADER_PRODUCT_INFO_QUANTITY]]") //Coder friendly string concat - if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //Zero demand - to_chat(user, span_notice("[span_red("(DOESN'T WANT MORE)")] [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [currency_name][product_info[TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION]]; willing to buy [span_red("[tern_op_result]")] more.")) - else - to_chat(user, span_notice("[initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [currency_name][product_info[TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION]]; willing to buy [span_green("[tern_op_result]")]")) - -///Displays to the user what the trader is selling and how much is in stock -/mob/living/simple_animal/hostile/retaliate/trader/proc/trader_sells_what(mob/user) - if(!products.len) - say(return_trader_phrase(TRADER_NOT_SELLING_ANYTHING)) - return - var/list/product_info - to_chat(user, span_green("I'm currently selling the following; ")) - for(var/obj/item/thing as anything in products) - product_info = products[thing] - var/tern_op_result = (product_info[TRADER_PRODUCT_INFO_QUANTITY] == INFINITY ? "an infinite amount" : "[product_info[TRADER_PRODUCT_INFO_QUANTITY]]") //Coder friendly string concat - if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //Out of stock - to_chat(user, span_notice("[span_red("(OUT OF STOCK)")] [initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [currency_name]; [span_red("[tern_op_result]")] left in stock")) - else - to_chat(user, span_notice("[initial(thing.name)] for [product_info[TRADER_PRODUCT_INFO_PRICE]] [currency_name]; [span_green("[tern_op_result]")] left in stock")) - -/** - * Generates a radial of the items the NPC sells and lets the user try to buy one - * Arguments: - * * user - (Mob REF) The mob trying to buy something - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/buy_item(mob/user) - if(!LAZYLEN(products)) - return - - var/list/display_names = list() - var/list/items = list() - var/list/product_info - for(var/obj/item/thing as anything in products) - display_names["[initial(thing.name)]"] = thing - var/image/item_image = image(icon = initial(thing.icon), icon_state = initial(thing.icon_state)) - product_info = products[thing] - if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) //out of stock - item_image.overlays += image(icon = 'icons/hud/radial.dmi', icon_state = "radial_center") - items += list("[initial(thing.name)]" = item_image) - var/pick = show_radial_menu(user, src, items, custom_check = CALLBACK(src, PROC_REF(check_menu), user), require_near = TRUE, tooltips = TRUE) - if(!pick) - return - var/obj/item/item_to_buy = display_names[pick] - face_atom(user) - product_info = products[item_to_buy] - if(!product_info[TRADER_PRODUCT_INFO_QUANTITY]) - say("[initial(item_to_buy.name)] appears to be out of stock.") - return - say("It will cost you [product_info[TRADER_PRODUCT_INFO_PRICE]] [currency_name] to buy \the [initial(item_to_buy.name)]. Are you sure you want to buy it?") - var/list/npc_options = list( - "Yes" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_yes"), - "No" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_no") - ) - var/buyer_will_buy = show_radial_menu(user, src, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), user), require_near = TRUE, tooltips = TRUE) - if(buyer_will_buy != "Yes") - return - face_atom(user) - if(!spend_buyer_offhand_money(user, product_info[TRADER_PRODUCT_INFO_PRICE])) - say(return_trader_phrase(NO_CASH_PHRASE)) - return - item_to_buy = new item_to_buy(get_turf(user)) - user.put_in_hands(item_to_buy) - playsound(src, sell_sound, 50, TRUE) - product_info[TRADER_PRODUCT_INFO_QUANTITY] -= 1 - say(return_trader_phrase(BUY_PHRASE)) - -///Calculates the value of money in the hand of the buyer and spends it if it's sufficient -/mob/living/simple_animal/hostile/retaliate/trader/proc/spend_buyer_offhand_money(mob/user, the_cost) - var/value = 0 - var/obj/item/holochip/cash = user.is_holding_item_of_type(/obj/item/holochip) - if(cash) - value += cash.credits - if((value >= the_cost) && cash) - return cash.spend(the_cost) - return FALSE //Purchase unsuccessful - -/** - * Tries to call sell_item on one of the user's held items, if fail gives a chat message - * - * Gets both items in the user's hands, and then tries to call sell_item on them, if both fail, he gives a chat message - * Arguments: - * * user - (Mob REF) The mob trying to sell something - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/try_sell(mob/user) - var/sold_item = FALSE - for(var/obj/item/an_item in user.held_items) - if(sell_item(user, an_item)) - sold_item = TRUE - break - if(!sold_item) - say(return_trader_phrase(ITEM_REJECTED_PHRASE)) - -/** - * Checks if an item is in the list of wanted items and if it is after a Yes/No radial returns generate_cash with the value of the item for the NPC - * Arguments: - * * user - (Mob REF) The mob trying to sell something - * * selling - (Item REF) The item being sold - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/sell_item(mob/user, obj/item/selling) - var/cost - if(!selling) - return FALSE - var/list/product_info - //Keep track of the typepath; rather mundane but it's required for correctly modifying the wanted_items - //should a product be sellable because even if it doesn't have a entry because it's a child of a parent that is present on the list - var/typepath_for_product_info - if(selling.type in wanted_items) - product_info = wanted_items[selling.type] - typepath_for_product_info = selling.type - else //Assume wanted_items is setup in the correct way; read wanted_items documentation for more info - for(var/typepath in wanted_items) - if(istype(selling, typepath)) - product_info = wanted_items[typepath] - typepath_for_product_info = typepath - break - - if(!product_info) //Nothing interesting to sell - return FALSE - if(product_info[TRADER_PRODUCT_INFO_QUANTITY] <= 0) - say(return_trader_phrase(TRADER_HAS_ENOUGH_ITEM_PHRASE)) - return FALSE - cost = apply_sell_price_mods(selling, product_info[TRADER_PRODUCT_INFO_PRICE]) - if(cost <= 0) - say(return_trader_phrase(ITEM_IS_WORTHLESS_PHRASE)) - return FALSE - say(return_trader_phrase(INTERESTED_PHRASE)) - say("You will receive [cost] [currency_name] for the [selling].") - var/list/npc_options = list( - "Yes" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_yes"), - "No" = image(icon = 'icons/hud/radial.dmi', icon_state = "radial_no"), - ) - face_atom(user) - var/npc_result = show_radial_menu(user, src, npc_options, custom_check = CALLBACK(src, PROC_REF(check_menu), user), require_near = TRUE, tooltips = TRUE) - if(npc_result != "Yes") - say(return_trader_phrase(ITEM_SELLING_CANCELED_PHRASE)) - return TRUE - say(return_trader_phrase(ITEM_SELLING_ACCEPTED_PHRASE)) - playsound(src, sell_sound, 50, TRUE) - log_econ("[selling] has been sold to [src] (typepath used for product info; [typepath_for_product_info]) by [user] for [cost] cash.") - exchange_sold_items(selling, cost, typepath_for_product_info) - generate_cash(cost, user) - return TRUE - -/** - * Handles modifying/deleting the items to ensure that a proper amount is converted into cash; put into it's own proc to make the children of this not override a 30+ line sell_item() - * - * Arguments: - * * selling - (Item REF) this is the item being sold - * * value_exchanged_for - (Number) the "value", useful for a scenario where you want to remove enough items equal to the value - * * original_typepath - (Typepath) For scenarios where a children of a parent is being sold but we want to modify the parent's product information - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/exchange_sold_items(obj/item/selling, value_exchanged_for, original_typepath) - var/list/product_info = wanted_items[original_typepath] - if(isstack(selling)) - var/obj/item/stack/the_stack = selling - var/actually_sold = min(the_stack.amount, product_info[TRADER_PRODUCT_INFO_QUANTITY]) - the_stack.use(actually_sold) - product_info[TRADER_PRODUCT_INFO_QUANTITY] -= (actually_sold) - else - qdel(selling) - product_info[TRADER_PRODUCT_INFO_QUANTITY] -= 1 - -/** - * Modifies the 'base' price of a item based on certain variables - * - * Arguments: - * * Reference to the item; this is the item being sold - * * Original cost; the original cost of the item, to be manipulated depending on the variables of the item, one example is using item.amount if it's a stack - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/apply_sell_price_mods(obj/item/selling, original_cost) - if(isstack(selling)) - var/obj/item/stack/stackoverflow = selling - original_cost *= stackoverflow.amount - return original_cost - -/** - * Creates an item equal to the value set by the proc and puts it in the user's hands if possible - * Arguments: - * * value - A number; The amount of cash that will be on the holochip - * * user - Reference to a mob; The mob we put the holochip in hands of - */ -/mob/living/simple_animal/hostile/retaliate/trader/proc/generate_cash(value, mob/user) - var/obj/item/holochip/chip = new /obj/item/holochip(get_turf(user), value) - user.put_in_hands(chip) - -///Sets quantity of all products to initial(quanity); this proc is currently not called anywhere on the base class of traders -/mob/living/simple_animal/hostile/retaliate/trader/proc/restock_products() - products = initial_products() - -///Sets quantity of all wanted_items to initial(quanity); this proc is currently not called anywhere on the base class of traders -/mob/living/simple_animal/hostile/retaliate/trader/proc/renew_item_demands() - wanted_items = initial_wanteds() - -/mob/living/simple_animal/hostile/retaliate/trader/mrbones - name = "Mr. Bones" - desc = "A skeleton merchant, he seems very humerus." - speak_emote = list("rattles") - speech_span = SPAN_SANS - sell_sound = 'sound/voice/hiss2.ogg' - mob_biotypes = MOB_UNDEAD|MOB_HUMANOID - icon_state = "mrbones" - gender = MALE - loot = list(/obj/effect/decal/remains/human) - - say_phrases = list( - ITEM_REJECTED_PHRASE = list( - "Sorry, I'm not a fan of anything you're showing me. Give me something better and we'll talk." - ), - ITEM_SELLING_CANCELED_PHRASE = list( - "What a shame, tell me if you changed your mind." - ), - ITEM_SELLING_ACCEPTED_PHRASE = list( - "Pleasure doing business with you." - ), - INTERESTED_PHRASE = list( - "Hey, you've got an item that interests me, I'd like to buy it, I'll give you some cash for it, deal?" - ), - BUY_PHRASE = list( - "Bone appetit!" - ), - NO_CASH_PHRASE = list( - "Sorry adventurer, I can't give credit! Come back when you're a little mmmmm... richer!" - ), - NO_STOCK_PHRASE = list( - "Sorry adventurer, but that item is not in stock at the moment." - ), - NOT_WILLING_TO_BUY_PHRASE = list( - "I don't want to buy that item for the time being, check back another time." - ), - ITEM_IS_WORTHLESS_PHRASE = list( - "This item seems to be worthless on a closer look, I won't buy this." - ), - TRADER_HAS_ENOUGH_ITEM_PHRASE = list( - "I already bought enough of this for the time being." - ), - TRADER_LORE_PHRASE = list( - "Hello, I am Mr. Bones!", - "The ride never ends!", - "I'd really like a refreshing carton of milk!", - "I'm willing to play big prices for BONES! Need materials to make merch, eh?", - "It's a beautiful day outside. Birds are singing, Flowers are blooming... On days like these, kids like you... Should be buying my wares!" - ), - TRADER_NOT_BUYING_ANYTHING = list( - "I'm currently buying nothing at the moment." - ), - TRADER_NOT_SELLING_ANYTHING = list( - "I'm currently selling nothing at the moment." - ), - ) - -/mob/living/simple_animal/hostile/retaliate/trader/mrbones/initial_products() - return list( - /obj/item/clothing/head/helmet/skull = list(150, INFINITY), - /obj/item/clothing/mask/bandana/skull/black = list(50, INFINITY), - /obj/item/food/cookie/sugar/spookyskull = list(10, INFINITY), - /obj/item/instrument/trombone/spectral = list(10000, INFINITY), - /obj/item/shovel/serrated = list(150, INFINITY), - ) - -/mob/living/simple_animal/hostile/retaliate/trader/mrbones/initial_wanteds() - return list( - /obj/item/reagent_containers/condiment/milk = list(1000, INFINITY, ""), - /obj/item/stack/sheet/bone = list(420, INFINITY, ", per sheet of bone"), - ) - -#undef ITEM_REJECTED_PHRASE -#undef ITEM_SELLING_CANCELED_PHRASE -#undef ITEM_SELLING_ACCEPTED_PHRASE -#undef INTERESTED_PHRASE -#undef BUY_PHRASE -#undef NO_CASH_PHRASE -#undef NO_STOCK_PHRASE -#undef NOT_WILLING_TO_BUY_PHRASE -#undef ITEM_IS_WORTHLESS_PHRASE -#undef TRADER_HAS_ENOUGH_ITEM_PHRASE -#undef TRADER_LORE_PHRASE -#undef TRADER_NOT_BUYING_ANYTHING -#undef TRADER_NOT_SELLING_ANYTHING -#undef TRADER_PRODUCT_INFO_PRICE -#undef TRADER_PRODUCT_INFO_QUANTITY -#undef TRADER_PRODUCT_INFO_PRICE_MOD_DESCRIPTION diff --git a/code/modules/unit_tests/simple_animal_freeze.dm b/code/modules/unit_tests/simple_animal_freeze.dm index d6d864ca51c..2a163b2f535 100644 --- a/code/modules/unit_tests/simple_animal_freeze.dm +++ b/code/modules/unit_tests/simple_animal_freeze.dm @@ -118,8 +118,6 @@ /mob/living/simple_animal/hostile/retaliate, /mob/living/simple_animal/hostile/retaliate/goose, /mob/living/simple_animal/hostile/retaliate/goose/vomit, - /mob/living/simple_animal/hostile/retaliate/trader, - /mob/living/simple_animal/hostile/retaliate/trader/mrbones, /mob/living/simple_animal/hostile/vatbeast, /mob/living/simple_animal/hostile/wizard, /mob/living/simple_animal/hostile/zombie, diff --git a/icons/mob/actions/actions_trader.dmi b/icons/mob/actions/actions_trader.dmi new file mode 100644 index 0000000000000000000000000000000000000000..59330b4aac83270635f2631d74363c2050885329 GIT binary patch literal 853 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 S;R*cig7mdbw5l|mpZ_1K!+&Z3 diff --git a/icons/obj/trader_signs.dmi b/icons/obj/trader_signs.dmi new file mode 100644 index 0000000000000000000000000000000000000000..a6789e0bb4c5f2b574dd5fd226ca9d98524dee2e GIT binary patch literal 3926 zcmV-c52^5pP)V=-0C=2JR&a84_w-Y6@%7{?OD!tS%+FJ>RWQ*r;NmRLOex6#a*U0*I5Sc+ z(=$pSoZ^zil2jm5DJ?NMH7B*WSc!`>C9|j)q>qa;ttc@!6~s0~D9kNN%FjzBtgbjS zJrAMC5Ji!)f~%hk*qH!}zA-u=@KMJA01lE#L_t(|ob8-{j8(-I$7fq>L5=Pz1*?{~ z7Pf_DQOs_YN_A1BqL?;@7QuvqtrhH|@P| zTTx(lTeRhgm0!y*qXJR$dOyRS+u7@VKW5&&_p!QP@|Zh6?)}cpnK@@>?zvl*$z)VF zN{6qMx&mKzbPJ%wILYiNr7VO=-SBJq_AcN3G6J74$_f(ZuU z(W6JLpJ%lSYG`Q43?4jKjUPYWpN4?SELpNdZP~I#&6_tbwvK>bu1Ag>>8Gz=z1qKy zli#cd{V-+96df-{0~9<1gcQt}F+8GDo*%m0dP|zv}(rd1{MxA%wdHP2M=ydFWbiU+K&K#*xAjmH~@P?&F&2QGj zY^(IeBab|yh2X-QR{+v@6GH6e`JHbf%1#L5&xbgLV7B$e7hmio1Q!&%4}k2w_ul(l zMa&A3Pq{b{me#V8Ep&n5)Yqvp@XBaluP zb>xD)Ge8K%u$dJw*W$U#6@%b&O@uW%YW`exiUK)TLEE-%(?46VLUFk-Dt_1d3W2Ew zQ-4-!ub_QEaMuT<(ZSB3EWhpBx7*YsCqDY%UK{?7t$#3Gi z>#p;!X{R|0Kt)A`)${TSKn~y7>22P;SsPw-e|2@Wnl^2kRyQpzEjoSIu3dVEEJ84M z%dUX`WXKSI zdeQ0p1PFy+@E)t$Qqzxu=*&ODO_TY{7a zj2NM2&6=gIzy5k{`1d2-~NPNzf5kS=AC@(*flZPg)ycWrmU`v6?;vVmP^+%R=~ zP~RuPABN99`z&X_6oP^TkW0TM;SLhy=-s<_rbmw+s%OuhnIlJzXm^mkd-o>h8;T0- z;8(@+cP~RrjBWvx7?FG*0FrbMZ`ij_eN)wX|C+sjwQ865f0BrjKa%eQ6_SCErb{H}{;aF6^^gZTe?gO&N1T`1&*k z{>5u|q~H}$k4Ymco%o$>fU7TjRG{-Osu`L-4 z*HUAz9hD{|o+MstYs(q8fwc{2Zf?%h*4B1PV>2S}V3V)|6UNl-SiEeLKkO%sy(InA z@}|nTc}@Pi?w+0c`0E1{vvBT%GoAUZZ2+5&;8!w9c0Op{(Ci6)xmHDte&R_2zWulF zRzv%id!v7MQT*06fKF!XZ+?=rcI{gIf;w(gf1_*%qp$YzT5WWsb${pO`-{MJLb!gv3cFpK?*FB2)_;sGBWKYqTJq z_}!yvmY;;gNhhCjHmb-;06`a!IDPVI3>&YTf7PH@8eFsXCr$?t5+|K};#|Av<1ir1 z+wNPQx?=cfg|0`?bTl)L0YFHcbn+=j`9cSQlK@PAcvBcrzn7K9FZ%66{xVODE+dT& zAQYoaKIJHH(+RownPHFKKPLTZYr9sfk3Y9Kg)KteF#rgOlTJS6D9?R`@H-#I1rf(f zaXOYpn#;UpFT9kh@|J&$daf_3IldV~2MZ|AeYW~I3<%leXSB;T9Y5*Xp?Q*H0|N;3H&0;-VsDxy*fq3;-(un7;2a`ps_#OyBt^Af1f5kxtOZ5Z<-S z4npFx5#w48=!#WA+~2cexK?NPK5&QTfBmg==GdN18BueD#7QTga-?%#A^cV{>s=bwLGf4RtyS-=qlLipf=50sY|>9<*^6;t(a z-J|>xB@IUR>&;k4hlJe|HNWJs(qW}VC~2Y!ZTPK?FuPpy%XJ{ zbQk`jyduoCzv(j{7icIdC=8^>eJq{t`7ZoLyQL)Ul^nLg3aX&Ez-(i^FRL!nWK~AG zi|Q*%07-uxLjy6qta!xG5fUdoO5L9g=JGQvZVP7t#Pub5KG4uJdvenEY5X~Wl`B_jVZ`-k0R*TT zer&SL6_2cpNS88k)&ZWdJmI%}rt=?e6=y8*k&hG7a5i16b~t7*bue+#$#?CDeEb_W zEX#24ma823l(XS?QbEP~#Jhkq{wW=YaAErZNsntUAnzgcbrybLYm1zGLk<7@qW$At z5G8+ES>k{9yA!(I(@TtQ0hAa{HsFiopI>TN+kh{ce}1WLfb|T(Kc3}Q&H24_{Pp*H zsf*8PR}&^rPQ*p|>dX43pIW#uCA?tkf7~prs0_klKMgg9#MDVlH&S-+=&2d8-M|P^{;={D-cFr(_flX zNq!y#;DZ-_>e15Ll%ifq)*&ib%>YK;!j;eXOHP)9gnCtSFq4=vl~s_3l+?wmmwru{ z^k66!1A0edfC;9eyB?wI%a_02%bC0OPlp@WFFB;qF@1-p31y~J#c7~nsEO^X$Vb9oe4ZH~?}(#tM>KKQo9`EGSLR-LPEOTqWdr2hC!gc=8#dLq0iGV##@M-* z7Jvs8B;3q^EgK)}RKKXu_SV03xOw3pbvR`5&-{3{Ybj@@=Yo|A;@vCWzc`@Y`+!PK zobj;En>FVSypE$qzIESGOB)*f(R`r!HMP|H>3lDbIH8$GSu;HstoH%vSyT|0T)FdMLP z!VhxxiS=O|2XTaQ9)#|Fz_SHOZ^T6Ri#G&tQ9ro^aKf#E04JU-1u=wPRF6IkqhtM? z(7~sVBBUO1EP?ZNUKDV8AHaw^chL9zFzL+?d?tU=e=Q|SI0?XX3k0S3b1`A{=!&ow zfT$n&AmYE<{vJoY@&aD@V1xQ3^+qmgSla*^?d?Ln-R~H^`C%QNgkQc1`TIQZ^N}38 zV$UEaS%t{p?3h(*{ab4OsNf_3&R;;O)Q>>;r9NE|)&jub@qx5_luVlJ4U&SK`O7{) z>eChBXaoAG9``Lz@j7rr%R72Ej1ZFYiXdbce0g<0-!W`T7ga^p0?2%N{54>yy%Bf= kwn+WoD}s8JtTIL5f0^k9(3!78`Tzg`07*qoM6N<$f}4n*G5`Po literal 0 HcmV?d00001 diff --git a/tgstation.dme b/tgstation.dme index 5b46203c027..36b5687e146 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -231,6 +231,7 @@ #include "code\__DEFINES\time.dm" #include "code\__DEFINES\tools.dm" #include "code\__DEFINES\toys.dm" +#include "code\__DEFINES\trader.dm" #include "code\__DEFINES\traits.dm" #include "code\__DEFINES\transport.dm" #include "code\__DEFINES\tts.dm" @@ -259,6 +260,7 @@ #include "code\__DEFINES\ai\pets.dm" #include "code\__DEFINES\ai\simplemob.dm" #include "code\__DEFINES\ai\tourist.dm" +#include "code\__DEFINES\ai\trader.dm" #include "code\__DEFINES\ai\vending.dm" #include "code\__DEFINES\ai\ventcrawling.dm" #include "code\__DEFINES\atmospherics\atmos_core.dm" @@ -821,6 +823,7 @@ #include "code\datums\ai\basic_mobs\basic_ai_behaviors\nearest_targetting.dm" #include "code\datums\ai\basic_mobs\basic_ai_behaviors\pick_up_item.dm" #include "code\datums\ai\basic_mobs\basic_ai_behaviors\run_away_from_target.dm" +#include "code\datums\ai\basic_mobs\basic_ai_behaviors\set_travel_destination.dm" #include "code\datums\ai\basic_mobs\basic_ai_behaviors\step_towards_turf.dm" #include "code\datums\ai\basic_mobs\basic_ai_behaviors\stop_and_stare.dm" #include "code\datums\ai\basic_mobs\basic_ai_behaviors\targeted_mob_ability.dm" @@ -843,6 +846,7 @@ #include "code\datums\ai\basic_mobs\basic_subtrees\mine_walls.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\move_to_cardinal.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\opportunistic_ventcrawler.dm" +#include "code\datums\ai\basic_mobs\basic_subtrees\prepare_travel_to_destination.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\ranged_skirmish.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\run_emote.dm" #include "code\datums\ai\basic_mobs\basic_subtrees\shapechange_ambush.dm" @@ -1208,6 +1212,7 @@ #include "code\datums\components\riding\riding_vehicle.dm" #include "code\datums\components\style\style.dm" #include "code\datums\components\style\style_meter.dm" +#include "code\datums\components\trader\trader.dm" #include "code\datums\diseases\_disease.dm" #include "code\datums\diseases\_MobProcs.dm" #include "code\datums\diseases\adrenal_crisis.dm" @@ -1276,6 +1281,7 @@ #include "code\datums\elements\ai_flee_while_injured.dm" #include "code\datums\elements\ai_held_item.dm" #include "code\datums\elements\ai_retaliate.dm" +#include "code\datums\elements\ai_swap_combat_mode.dm" #include "code\datums\elements\ai_target_damagesource.dm" #include "code\datums\elements\amputating_limbs.dm" #include "code\datums\elements\animal_variety.dm" @@ -4577,6 +4583,11 @@ #include "code\modules\mob\living\basic\space_fauna\wumborian_fugu\inflation.dm" #include "code\modules\mob\living\basic\space_fauna\wumborian_fugu\wumborian_ai.dm" #include "code\modules\mob\living\basic\space_fauna\wumborian_fugu\wumborian_fugu.dm" +#include "code\modules\mob\living\basic\trader\trader.dm" +#include "code\modules\mob\living\basic\trader\trader_actions.dm" +#include "code\modules\mob\living\basic\trader\trader_ai.dm" +#include "code\modules\mob\living\basic\trader\trader_data.dm" +#include "code\modules\mob\living\basic\trader\trader_items.dm" #include "code\modules\mob\living\basic\trooper\nanotrasen.dm" #include "code\modules\mob\living\basic\trooper\pirate.dm" #include "code\modules\mob\living\basic\trooper\russian.dm" @@ -4803,7 +4814,6 @@ #include "code\modules\mob\living\simple_animal\hostile\mining_mobs\elites\pandora.dm" #include "code\modules\mob\living\simple_animal\hostile\retaliate\goose.dm" #include "code\modules\mob\living\simple_animal\hostile\retaliate\retaliate.dm" -#include "code\modules\mob\living\simple_animal\hostile\retaliate\trader.dm" #include "code\modules\mob\living\simple_animal\slime\death.dm" #include "code\modules\mob\living\simple_animal\slime\emote.dm" #include "code\modules\mob\living\simple_animal\slime\life.dm" diff --git a/tools/UpdatePaths/Scripts/79187_simple_to_basic_traders.txt b/tools/UpdatePaths/Scripts/79187_simple_to_basic_traders.txt new file mode 100644 index 00000000000..77731b53c5d --- /dev/null +++ b/tools/UpdatePaths/Scripts/79187_simple_to_basic_traders.txt @@ -0,0 +1,2 @@ +/mob/living/simple_animal/hostile/retaliate/trader : /mob/living/basic/trader{@OLD} +/mob/living/simple_animal/hostile/retaliate/trader/mrbones : /mob/living/basic/trader/mrbones{@OLD} \ No newline at end of file