Files
Bubberstation/code/datums/components/trader/trader.dm
Aylong 7a817300fd Improve and extend fieldset_block and examine_block (#88678)
## About The Pull Request
Maked `fieldset_block` and `examine_block` more stylish and neat, also
`fieldset_block` no longer has a centred title.
Renamed `examine_block` to `boxed_message` and adds
`custom_boxed_message` which can be colored.

- AdminPMs, admin tickets and vote results has been wrapped into
`fieldset_block` for comfort and visibility
- Health Analyzer results painted to blue
- Vote notice and tips of the round wrapped to purple
`custom_boxed_message`
- Tooltip text border color, now uses text color, not just white

## Why It's Good For The Game
Demonstration in both themes

<details><summary>Dark</summary>


![image](https://github.com/user-attachments/assets/7175379b-b053-4fb7-bd25-65c744a21c56)

![image](https://github.com/user-attachments/assets/1728e72b-0110-4b81-9d61-8779f5fdc3a0)

![image](https://github.com/user-attachments/assets/5e6f9604-35b8-4840-b6b4-35a68f49a997)

</details>

<details><summary>Light</summary>


![image](https://github.com/user-attachments/assets/6a3d693b-e0dc-4a4b-b4d7-2ded54ce0d67)

![image](https://github.com/user-attachments/assets/c4f5e089-180f-4d13-806a-fa64f01740a3)

![image](https://github.com/user-attachments/assets/a46d52c4-ad37-4637-8cae-c4b00139efc1)

</details>

## Changelog

🆑
qol: AdminPMs, admin tickets, vote results and started vote notification
are now much more visible in the chat.
qol: Boxed messages in chat (like examine), has been restyled.
/🆑
2024-12-24 11:32:38 -08:00

458 lines
19 KiB
Plaintext

#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, its 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 its 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("&bull; [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("&bull; [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, boxed_message(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("&bull; [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("&bull; [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, boxed_message(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