diff --git a/_maps/RandomRuins/SpaceRuins/hellfactory.dmm b/_maps/RandomRuins/SpaceRuins/hellfactory.dmm index ec709f93ef0..df0146b7db5 100644 --- a/_maps/RandomRuins/SpaceRuins/hellfactory.dmm +++ b/_maps/RandomRuins/SpaceRuins/hellfactory.dmm @@ -72,10 +72,6 @@ /obj/item/storage/cans/sixbeer, /turf/open/floor/iron/grimy, /area/ruin/space/has_grav/hellfactoryoffice) -"am" = ( -/obj/machinery/paystand, -/turf/open/floor/iron/grimy, -/area/ruin/space/has_grav/hellfactoryoffice) "an" = ( /obj/effect/decal/remains/human, /obj/effect/decal/cleanable/blood/old, @@ -1367,7 +1363,7 @@ aa aa aa ah -am +ao ao aH ao diff --git a/_maps/map_files/Deltastation/DeltaStation2.dmm b/_maps/map_files/Deltastation/DeltaStation2.dmm index 3c1144cd81f..9e2e15945dc 100644 --- a/_maps/map_files/Deltastation/DeltaStation2.dmm +++ b/_maps/map_files/Deltastation/DeltaStation2.dmm @@ -55557,7 +55557,6 @@ "kbN" = ( /obj/structure/table, /obj/item/stack/sheet/iron/five, -/obj/item/circuitboard/machine/paystand, /obj/effect/turf_decal/tile/brown{ dir = 4 }, @@ -82182,7 +82181,6 @@ /area/cargo/office) "qPc" = ( /obj/structure/table/reinforced, -/obj/item/circuitboard/machine/paystand, /obj/item/stack/cable_coil/five, /obj/machinery/airalarm/directional/north, /turf/open/floor/plating, @@ -97226,7 +97224,6 @@ dir = 4 }, /obj/structure/table, -/obj/item/circuitboard/machine/paystand, /obj/item/wrench, /obj/item/screwdriver, /obj/effect/turf_decal/tile/neutral{ diff --git a/_maps/map_files/IceBoxStation/IceBoxStation.dmm b/_maps/map_files/IceBoxStation/IceBoxStation.dmm index e349126b7ad..b458bfc8049 100644 --- a/_maps/map_files/IceBoxStation/IceBoxStation.dmm +++ b/_maps/map_files/IceBoxStation/IceBoxStation.dmm @@ -21575,7 +21575,6 @@ /obj/structure/table, /obj/machinery/firealarm/directional/north, /obj/item/stack/sheet/iron/five, -/obj/item/circuitboard/machine/paystand, /obj/item/stack/cable_coil/five, /obj/effect/turf_decal/tile/brown{ dir = 4 diff --git a/_maps/map_files/KiloStation/KiloStation.dmm b/_maps/map_files/KiloStation/KiloStation.dmm index ea645f26a51..ad7a924fd83 100644 --- a/_maps/map_files/KiloStation/KiloStation.dmm +++ b/_maps/map_files/KiloStation/KiloStation.dmm @@ -15451,7 +15451,6 @@ }, /obj/effect/decal/cleanable/dirt, /obj/item/folder/red, -/obj/item/circuitboard/machine/paystand, /obj/structure/spider/stickyweb, /turf/open/floor/iron/dark, /area/maintenance/port) @@ -61919,7 +61918,6 @@ /obj/machinery/firealarm/directional/west, /obj/machinery/newscaster/directional/south, /obj/item/clipboard, -/obj/item/circuitboard/machine/paystand, /turf/open/floor/iron/dark, /area/commons/vacant_room/commissary) "qeZ" = ( diff --git a/_maps/map_files/MetaStation/MetaStation.dmm b/_maps/map_files/MetaStation/MetaStation.dmm index abcc33c180d..ec538ac1c89 100644 --- a/_maps/map_files/MetaStation/MetaStation.dmm +++ b/_maps/map_files/MetaStation/MetaStation.dmm @@ -38480,7 +38480,6 @@ /obj/item/radio/intercom/directional/east{ pixel_y = 8 }, -/obj/item/circuitboard/machine/paystand, /obj/item/stack/cable_coil/five, /obj/effect/turf_decal/tile/brown{ dir = 4 diff --git a/_maps/map_files/tramstation/tramstation.dmm b/_maps/map_files/tramstation/tramstation.dmm index b188a42948c..d366b740acd 100644 --- a/_maps/map_files/tramstation/tramstation.dmm +++ b/_maps/map_files/tramstation/tramstation.dmm @@ -61970,7 +61970,6 @@ /obj/effect/turf_decal/trimline/neutral/filled/line{ dir = 4 }, -/obj/item/circuitboard/machine/paystand, /obj/item/stack/sheet/iron/five, /obj/item/stack/cable_coil/five, /turf/open/floor/iron, diff --git a/code/game/objects/items/cards_ids.dm b/code/game/objects/items/cards_ids.dm index 93d39e6204b..68ecbf133f3 100644 --- a/code/game/objects/items/cards_ids.dm +++ b/code/game/objects/items/cards_ids.dm @@ -7,6 +7,9 @@ /// Fallback time if none of the config entries are set for USE_LOW_LIVING_HOUR_INTERN #define INTERN_THRESHOLD_FALLBACK_HOURS 15 +/// Max time interval between projecting holopays +#define HOLOPAY_PROJECTION_INTERVAL 7 SECONDS + /* Cards * Contains: * DATA CARD @@ -59,8 +62,28 @@ var/registered_name = null /// Linked bank account. var/datum/bank_account/registered_account - /// Linked paystand. - var/obj/machinery/paystand/my_store + + /// Linked holopay. + var/datum/weakref/holopay_ref + /// Cooldown between projecting holopays + COOLDOWN_DECLARE(last_holopay_projection) + /// List of logos available for holopay customization - via font awesome 5 + var/static/list/available_logos = list("angry", "ankh", "bacon", "band-aid", "cannabis", "cat", "cocktail", "coins", "comments-dollar", + "cross", "cut", "dog", "donate", "dna", "fist-raised", "flask", "glass-cheers", "glass-martini-alt", "hamburger", "hand-holding-usd", + "hat-wizard", "head-side-cough-slash", "heart", "heart-broken", "laugh-beam", "leaf", "money-check-alt", "music", "piggy-bank", + "pizza-slice", "prescription-bottle-alt", "radiation", "robot", "smile", "skull-crossbones", "smoking", "space-shuttle", "tram", + "trash", "user-ninja", "utensils", "wrench") + /// Replaces the "pay whatever" functionality with a set amount when non-zero. + var/holopay_fee = 0 + /// The holopay icon chosen by the user + var/holopay_logo = "donate" + /// Maximum forced fee. It's unlikely for a user to encounter this type of money, much less pay it willingly. + var/holopay_max_fee = 5000 + /// Minimum forced fee for holopay stations. Registers as "pay what you want." + var/holopay_min_fee = 0 + /// The holopay name chosen by the user + var/holopay_name = "holographic pay stand" + /// Registered owner's age. var/registered_age = 30 @@ -94,8 +117,8 @@ /obj/item/card/id/Destroy() if (registered_account) registered_account.bank_cards -= src - if (my_store && my_store.my_card == src) - my_store.my_card = null + if (holopay_ref) + QDEL_NULL(holopay_ref) return ..() /obj/item/card/id/get_id_examine_strings(mob/user) @@ -383,6 +406,103 @@ user.visible_message(span_notice("[user] shows you: [icon2html(src, viewers(user))] [src.name][minor]."), span_notice("You show \the [src.name][minor].")) add_fingerprint(user) +/obj/item/card/id/attack_hand_secondary(mob/user, list/modifiers) + . = ..() + if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN) + return + /// Sanity checks + var/obj/structure/holopay/my_store = holopay_ref?.resolve() + if(my_store) + my_store.dissapate() + return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN + if(!COOLDOWN_FINISHED(src, last_holopay_projection)) + balloon_alert(user, "still recharging") + return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN + if(!registered_account || !registered_account.account_job) + balloon_alert(user, "no account") + to_chat(user, span_warning("You need a valid bank account to do this.")) + return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN + /// Determines where the holopay will be placed based on tile contents + var/turf/projection + var/turf/step_ahead = get_step(user, user.dir) + var/turf/user_loc = user.loc + if(can_proj_holopay(step_ahead)) + projection = step_ahead + else if(can_proj_holopay(user_loc)) + projection = user_loc + if(!projection) + balloon_alert(user, "no space") + to_chat(user, span_warning("You need to be standing on or near an open tile to do this.")) + return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN + /// Success: Valid tile for holopay placement + var/obj/structure/holopay/new_store = new(projection) + if(new_store?.assign_card(projection, src)) + COOLDOWN_START(src, last_holopay_projection, HOLOPAY_PROJECTION_INTERVAL) + playsound(projection, "sound/effects/empulse.ogg", 40, TRUE) + holopay_ref = WEAKREF(new_store) + return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN + +/** + * Determines whether a new holopay can be placed on the given turf. + * Checks if there are dense contents, too many contents, or another + * holopay already exists on the turf. + * + * Arguments: + * * turf/target - The target turf to be checked for dense contents + * Returns: + * * TRUE if the target is a valid holopay location, FALSE otherwise. + */ +/obj/item/card/id/proc/can_proj_holopay(turf/target) + if(!isfloorturf(target)) + return FALSE + if(target.density) + return FALSE + if(length(target.contents) > 5) + return FALSE + for(var/obj/checked_obj in target.contents) + if(checked_obj.density) + return FALSE + if(istype(checked_obj, /obj/structure/holopay)) + return FALSE + return TRUE + +/** + * Setter for the shop logo on linked holopays + * + * Arguments: + * * new_logo - The new logo to be set. + */ +/obj/item/card/id/proc/set_holopay_logo(new_logo) + if(!available_logos.Find(new_logo)) + CRASH("User input a holopay shop logo that didn't exist.") + holopay_logo = new_logo + +/** + * Setter for changing the force fee on a holopay. + * + * Arguments: + * * new_fee - The new fee to be set. + */ +/obj/item/card/id/proc/set_holopay_fee(new_fee) + if(!isnum(new_fee)) + CRASH("User input a non number into the holopay fee field.") + if(new_fee < holopay_min_fee || new_fee > holopay_max_fee) + CRASH("User input a number outside of the valid range into the holopay fee field.") + holopay_fee = new_fee + +/** + * Setter for changing the holopay name. + * + * Arguments: + * * new_name - The new name to be set. + */ +/obj/item/card/id/proc/set_holopay_name(name) + if(length(name) < 3 || length(name) > MAX_NAME_LEN) + to_chat(usr, span_warning("Must be between 3 - 42 characters.")) + else + holopay_name = html_encode(trim(name, MAX_NAME_LEN)) + + /obj/item/card/id/vv_edit_var(var_name, var_value) . = ..() if(.) diff --git a/code/game/objects/items/circuitboards/machine_circuitboards.dm b/code/game/objects/items/circuitboards/machine_circuitboards.dm index a9e777793f1..cfaefdec9f5 100644 --- a/code/game/objects/items/circuitboards/machine_circuitboards.dm +++ b/code/game/objects/items/circuitboards/machine_circuitboards.dm @@ -397,12 +397,6 @@ /obj/item/stock_parts/manipulator = 1) def_components = list(/obj/item/stack/ore/bluespace_crystal = /obj/item/stack/ore/bluespace_crystal/artificial) -/obj/item/circuitboard/machine/paystand - name = "Pay Stand (Machine Board)" - greyscale_colors = CIRCUIT_COLOR_GENERIC - build_path = /obj/machinery/paystand - req_components = list() - /obj/item/circuitboard/machine/protolathe name = "Protolathe (Machine Board)" greyscale_colors = CIRCUIT_COLOR_GENERIC diff --git a/code/modules/art/paintings.dm b/code/modules/art/paintings.dm index b536ece86ef..a4f76b2a225 100644 --- a/code/modules/art/paintings.dm +++ b/code/modules/art/paintings.dm @@ -173,7 +173,7 @@ to_chat(user,span_notice("You don't even have a id and you want to be an art patron?")) return if(!id_card.registered_account || !id_card.registered_account.account_job) - to_chat(user,span_notice("No valid non-departamental account found.")) + to_chat(user,span_notice("No valid non-departmental account found.")) return var/datum/bank_account/account = id_card.registered_account if(account.account_balance < painting_metadata.credit_value) diff --git a/code/modules/economy/holopay.dm b/code/modules/economy/holopay.dm new file mode 100644 index 00000000000..fd1bd1e4e6b --- /dev/null +++ b/code/modules/economy/holopay.dm @@ -0,0 +1,268 @@ +/obj/structure/holopay + name = "holographic pay stand" + desc = "an unregistered pay stand" + icon = 'icons/obj/economy.dmi' + icon_state = "card_scanner" + alpha = 150 + anchored = TRUE + armor = list(MELEE = 0, BULLET = 50, LASER = 50, ENERGY = 50, BOMB = 0, BIO = 0, FIRE = 20, ACID = 20) + max_integrity = 15 + layer = FLY_LAYER + /// ID linked to the holopay + var/datum/weakref/card_ref + /// Max range at which the hologram can be projected before it deletes + var/max_holo_range = 4 + /// The holopay shop icon displayed in the UI + var/shop_logo = "donate" + /// Replaces the "pay whatever" functionality with a set amount when non-zero. + var/force_fee = 0 + +/obj/structure/holopay/examine(mob/user) + . = ..() + if(force_fee) + . += span_boldnotice("This holopay forces a payment of [force_fee] credit\s per swipe instead of a variable amount.") + +/obj/structure/holopay/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(.) + return + if(!user.combat_mode) + ui_interact(user) + return . + user.do_attack_animation(src, ATTACK_EFFECT_PUNCH) + user.changeNext_move(CLICK_CD_MELEE) + take_damage(5, BRUTE, MELEE, 1) + +/obj/structure/holopay/play_attack_sound(damage_amount, damage_type = BRUTE, damage_flag = 0) + switch(damage_type) + if(BRUTE) + playsound(loc, 'sound/weapons/egloves.ogg', 80, TRUE) + if(BURN) + playsound(loc, 'sound/weapons/egloves.ogg', 80, TRUE) + +/obj/structure/holopay/deconstruct() + dissapate() + return ..() + +/obj/structure/holopay/attackby(obj/item/held_item, mob/holder, params) + var/mob/living/user = holder + if(!isliving(user)) + return ..() + /// Users can pay with an ID to skip the UI + if(istype(held_item, /obj/item/card/id)) + if(force_fee && tgui_alert(holder, "This holopay has a [force_fee] cr fee. Confirm?", "Holopay Fee", list("Pay", "Cancel")) != "Pay") + return TRUE + process_payment(user) + return TRUE + /// Users can also pay by holochip + if(istype(held_item, /obj/item/holochip)) + /// Account checks + var/obj/item/holochip/chip = held_item + if(!chip.credits) + balloon_alert(user, "holochip is empty") + to_chat(user, span_warning("There doesn't seem to be any credits here.")) + return FALSE + /// Charges force fee or uses pay what you want + var/cash_deposit = force_fee || tgui_input_number(user, "How much? (Max: [chip.credits])", "Patronage", max_value = chip.credits) + /// Exit sanity checks + if(!cash_deposit) + return TRUE + if(QDELETED(held_item) || QDELETED(user) || QDELETED(src) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK)) + return FALSE + if(!chip.spend(cash_deposit, FALSE)) + balloon_alert(user, "insufficient credits") + to_chat(user, span_warning("You don't have enough credits to pay with this chip.")) + return FALSE + /// Success: Alert buyer + alert_buyer(user, cash_deposit) + return TRUE + /// Throws errors if they try to use space cash + if(istype(held_item, /obj/item/stack/spacecash)) + to_chat(user, "What is this, the 2000s? We only take card here.") + return TRUE + if(istype(held_item, /obj/item/coin)) + to_chat(user, "What is this, the 1800s? We only take card here.") + return TRUE + return ..() + +/obj/structure/holopay/ui_interact(mob/user, datum/tgui/ui) + . = ..() + if(.) + return FALSE + var/mob/living/interactor = user + if(isliving(interactor) && interactor.combat_mode) + return FALSE + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "HoloPay") + ui.open() + +/obj/structure/holopay/ui_status(mob/user) + . = ..() + if(!in_range(user, src) && !isobserver(user)) + return UI_CLOSE + +/obj/structure/holopay/ui_static_data(mob/user) + . = list() + /// Sanity checks + var/obj/item/card/id/linked_card = get_card() + .["available_logos"] = linked_card.available_logos + .["description"] = desc + .["max_fee"] = linked_card.holopay_max_fee + .["owner"] = linked_card.registered_account?.account_holder || null + .["shop_logo"] = shop_logo + +/obj/structure/holopay/ui_data(mob/user) + . = list() + .["force_fee"] = force_fee + .["name"] = name + if(!isliving(user)) + return . + var/mob/living/card_holder = user + var/obj/item/card/id/id_card = card_holder.get_idcard(TRUE) + var/datum/bank_account/account = id_card?.registered_account || null + if(account) + .["user"] = list() + .["user"]["name"] = account.account_holder + .["user"]["balance"] = account.account_balance + +/obj/structure/holopay/ui_act(action, list/params, datum/tgui/ui) + . = ..() + if(.) + return FALSE + var/obj/item/card/id/linked_card = get_card() + switch(action) + if("done") + ui.send_full_update() + return TRUE + if("fee") + linked_card.set_holopay_fee(params["amount"]) + force_fee = linked_card.holopay_fee + if("logo") + linked_card.set_holopay_logo(params["logo"]) + shop_logo = linked_card.holopay_logo + if("pay") + ui.close() + process_payment(usr) + return TRUE + if("rename") + linked_card.set_holopay_name(params["name"]) + name = linked_card.holopay_name + return FALSE + +/** + * Links the source card to the holopay. Begins checking if its in range. + * + * Parameters: + * * turf/target - The tile to project the holopay onto + * * obj/item/card/id/card - The card to link to the holopay + * Returns: + * * TRUE - the card was linked + */ +/obj/structure/holopay/proc/assign_card(turf/target, obj/item/card/id/card) + card_ref = WEAKREF(card) + desc = "Pays directly into [card.registered_account.account_holder]'s bank account." + force_fee = card.holopay_fee + shop_logo = card.holopay_logo + name = card.holopay_name + add_atom_colour("#77abff", FIXED_COLOUR_PRIORITY) + set_light(2) + visible_message(span_notice("A holographic pay stand appears.")) + /// Start checking if the source projection is in range + RegisterSignal(card, COMSIG_MOVABLE_MOVED, .proc/check_operation) + if(card.loc) + RegisterSignal(card.loc, COMSIG_MOVABLE_MOVED, .proc/check_operation) + return TRUE + +/** + * A periodic check to see if the projecting card is nearby. + * Deletes the holopay if true. + */ +/obj/structure/holopay/proc/check_operation() + SIGNAL_HANDLER + var/obj/item/card/id/linked_card = get_card() + if(!IN_GIVEN_RANGE(src, linked_card, max_holo_range) || !IN_GIVEN_RANGE(src, linked_card.loc, max_holo_range)) + dissapate() + +/** + * Creates holopay vanishing effects. + * Deletes the holopay thereafter. + */ +/obj/structure/holopay/proc/dissapate() + var/obj/item/card/id/linked_card = get_card() + playsound(loc, "sound/effects/empulse.ogg", 40, TRUE) + visible_message(span_notice("The pay stand vanishes.")) + QDEL_NULL(linked_card.holopay_ref) + +/** + * Checks that the card is still linked. + * Deletes the holopay if not. + * + * Returns: + * * /obj/item/card/id/card - The card that is linked to the holopay + */ +/obj/structure/holopay/proc/get_card() + var/obj/item/card/id/linked_card = card_ref?.resolve() + if(!linked_card || !istype(linked_card, /obj/item/card/id)) + stack_trace("Could not link a holopay to a valid card.") + qdel(src) + return linked_card + +/** + * Initiates a transaction between accounts. + * + * Parameters: + * * mob/living/user - The user who initiated the transaction. + * Returns: + * * TRUE - transaction was successful + */ +/obj/structure/holopay/proc/process_payment(mob/living/user) + // Preliminary sanity checks + var/obj/item/card/id/linked_card = get_card() + /// Account checks + var/obj/item/card/id/id_card + id_card = user.get_idcard(TRUE) + if(!id_card || !id_card.registered_account || !id_card.registered_account.account_job) + balloon_alert(user, "invalid account") + to_chat(user, span_warning("You don't have a valid account.")) + return FALSE + var/datum/bank_account/payee = id_card.registered_account + if(payee == linked_card.registered_account) + balloon_alert(user, "invalid transaction") + to_chat(user, span_warning("You can't pay yourself.")) + return FALSE + /// If the user has enough money, ask them the amount or charge the force fee + var/amount = force_fee || tgui_input_number(user, "How much? (Max: [payee.account_balance])", "Patronage", max_value = payee.account_balance) + /// Exit checks in case the user cancelled or entered an invalid amount + if(!amount || QDELETED(user) || QDELETED(src) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK)) + return FALSE + if(!payee.adjust_money(-amount)) + balloon_alert(user, "insufficient credits") + to_chat(user, span_warning("You don't have the money to pay for this.")) + return FALSE + /// Success: Alert the buyer + alert_buyer(user, amount) + return TRUE + +/** + * Alerts the owner of the transaction. + * + * Parameters: + * * payee - The user who initiated the transaction. + * * amount - The amount of money that was paid. + * Returns: + * * TRUE - alert was successful. + */ +/obj/structure/holopay/proc/alert_buyer(payee, amount) + /// Sanity checks + var/obj/item/card/id/linked_card = get_card() + /// Pay the owner + linked_card.registered_account.adjust_money(amount) + /// Make alerts + linked_card.registered_account.bank_card_talk("[payee] has deposited [amount] cr at your holographic pay stand.") + say("Thank you for your patronage, [payee]!") + playsound(src, 'sound/effects/cashregister.ogg', 20, TRUE) + /// Log the event + log_econ("[amount] credits were transferred from [payee]'s transaction to [linked_card.registered_account.account_holder]") + SSblackbox.record_feedback("amount", "credits_transferred", amount) + return TRUE diff --git a/code/modules/economy/pay_stand.dm b/code/modules/economy/pay_stand.dm deleted file mode 100644 index fc84bab90dc..00000000000 --- a/code/modules/economy/pay_stand.dm +++ /dev/null @@ -1,152 +0,0 @@ -/obj/machinery/paystand - name = "unregistered pay stand" - desc = "See title." - icon = 'icons/obj/economy.dmi' - icon_state = "card_scanner" - density = TRUE - anchored = TRUE - var/locked = FALSE - var/obj/item/card/id/my_card - var/obj/item/assembly/signaler/signaler //attached signaler, let people attach signalers that get activated if the user's transaction limit is achieved. - var/signaler_threshold = 0 //signaler threshold amount - var/amount_deposited = 0 //keep track of the amount deposited over time so you can pay multiple times to reach the signaler threshold - var/force_fee = 0 //replaces the "pay whatever" functionality with a set amount when non-zero. - -/obj/machinery/paystand/attackby(obj/item/W, mob/user, params) - if(istype(W, /obj/item/card/id)) - if(W == my_card) - var/list/items = list( - "Rename" = image(icon = 'icons/obj/economy.dmi', icon_state = "name"), - "Set the fee" = image(icon = 'icons/obj/economy.dmi', icon_state = "fee") - ) - var/choice = show_radial_menu(user, src, items, null, require_near = TRUE, tooltips = TRUE) - if(choice == "Rename") - var/rename_msg = tgui_input_text(user, "Rename the Paystand", "Paystand Name", name) - if(!rename_msg || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK)) - return - name = rename_msg - return - else if(choice == "Set the fee") - var/force_fee_input = tgui_input_number(user, "Set the fee", "Fee", max_value = 10000) - if(isnull(force_fee_input) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK)) - return - force_fee = round(force_fee_input) - return - locked = !locked - to_chat(user, span_notice("You [src.locked ? "lock" : "unlock"] the paystand, protecting the bolts from [anchored ? "loosening" : "tightening"].")) - return - if(!my_card) - var/obj/item/card/id/new_card = W - if(!new_card.registered_account) - return - var/msg = tgui_input_text(user, "Name of pay stand", "Paystand Naming", "Paystand (owned by [new_card.registered_account.account_holder])") - if(!msg) - return - name = msg - desc = "Owned by [new_card.registered_account.account_holder], pays directly into [user.p_their()] account." - my_card = new_card - to_chat(user, "You link the stand to your account.") - return - var/obj/item/card/id/pay_card = W - if(pay_card.registered_account) - if(!pay_card.registered_account.account_job)//Departmental budget cards like cargo's fall under this - to_chat(user, span_warning("ERROR: Personal use of department budgets is not authorized.")) - return - var/credit_amount = 0 - if(!force_fee) - credit_amount = tgui_input_number(user, "How much would you like to deposit?", "Money Deposit") - if(isnull(credit_amount)) - return - credit_amount = round(credit_amount) - else - credit_amount = force_fee - if(!user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK)) - return - if(credit_amount < 1) - to_chat(user, span_warning("ERROR: Invalid amount designated.")) - return - if(pay_card.registered_account.adjust_money(-credit_amount)) - purchase(pay_card.registered_account.account_holder, credit_amount) - say("Thank you for your patronage, [pay_card.registered_account.account_holder]!") - playsound(src, 'sound/effects/cashregister.ogg', 20, TRUE) - return - else - to_chat(user, span_warning("ERROR: Account has insufficient funds to make transaction.")) - return - else - to_chat(user, span_warning("ERROR: No bank account assigned to identification card.")) - return - if(istype(W, /obj/item/holochip)) - var/obj/item/holochip/H = W - var/cashmoney = round(tgui_input_number(user, "How much would you like to deposit?", "Money Deposit")) - if(isnull(cashmoney)) - return - cashmoney = round(cashmoney) - if(H.spend(cashmoney, FALSE)) - purchase(user, cashmoney) - to_chat(user, "Thanks for purchasing! The vendor has been informed.") - return - else - to_chat(user, span_warning("ERROR: Insufficient funds to make transaction.")) - return - if(istype(W, /obj/item/stack/spacecash)) - to_chat(user, "What is this, the 2000s? We only take card here.") - return - if(istype(W, /obj/item/coin)) - to_chat(user, "What is this, the 1800s? We only take card here.") - return - if(istype(W, /obj/item/assembly/signaler)) - var/obj/item/assembly/signaler/S = W - if(S.secured) - to_chat(user, span_warning("The signaler needs to be in attachable mode to add it to the paystand!")) - return - if(!my_card) - to_chat(user, span_warning("ERROR: No identification card has been assigned to this paystand yet!")) - return - if(!isnull(signaler)) - to_chat(user, span_warning("A signaler is already attached to this unit!")) - return - var/cash_limit = tgui_input_number(user, "Enter the minimum amount of cash needed to deposit before the signaler is activated.", "Signaler Activation Threshold", 1, min_value = 1) - if(isnull(cash_limit)) - return - cash_limit = round(cash_limit) - S.forceMove(src) - signaler = S - signaler_threshold = cash_limit - to_chat(user, "You attach the signaler to the paystand.") - desc += " A signaler appears to be attached to the scanner." - - if(default_deconstruction_screwdriver(user, "card_scanner", "card_scanner", W)) - return - - else if(default_pry_open(W)) - return - - else if(default_unfasten_wrench(user, W)) - return - - else if(default_deconstruction_crowbar(W)) - return - else - return ..() - -/obj/machinery/paystand/proc/purchase(buyer, price) - my_card.registered_account.adjust_money(price) - my_card.registered_account.bank_card_talk("Purchase made at your vendor by [buyer] for [price] credits.") - amount_deposited = amount_deposited + price - if(signaler && amount_deposited >= signaler_threshold) - signaler.signal() - amount_deposited = 0 - -/obj/machinery/paystand/default_unfasten_wrench(mob/user, obj/item/I, time = 20) - if(locked) - to_chat(user, span_warning("The bolts on this paystand are currently covered!")) - return FALSE - . = ..() - -/obj/machinery/paystand/examine(mob/user) - . = ..() - if(force_fee) - . += span_warning("This paystand forces a payment of [force_fee] credit\s per swipe instead of a variable amount.") - if(user.get_active_held_item() == my_card) - . += span_notice("Paystands can be edited through swiping your card.") diff --git a/code/modules/research/designs/machine_designs.dm b/code/modules/research/designs/machine_designs.dm index 4d17f148dec..e1787215787 100644 --- a/code/modules/research/designs/machine_designs.dm +++ b/code/modules/research/designs/machine_designs.dm @@ -572,14 +572,6 @@ category = list ("Research Machinery") departmental_flags = DEPARTMENTAL_FLAG_CARGO -/datum/design/board/paystand - name = "Machine Design (Pay Stand)" - desc = "The circuit board for a paystand." - id = "paystand" - build_path = /obj/item/circuitboard/machine/paystand - category = list ("Misc. Machinery") - - /datum/design/board/fat_sucker name = "Machine Design (Lipid Extractor)" desc = "The circuit board for a lipid extractor." diff --git a/code/modules/research/techweb/all_nodes.dm b/code/modules/research/techweb/all_nodes.dm index 771f0811a01..a29538a3bec 100644 --- a/code/modules/research/techweb/all_nodes.dm +++ b/code/modules/research/techweb/all_nodes.dm @@ -35,7 +35,6 @@ "micro_mani", "oven_tray", "packagewrap", - "paystand", "plasmaglass", "plasmareinforcedglass", "plasteel", diff --git a/code/modules/vending/_vending.dm b/code/modules/vending/_vending.dm index b3c06a23d68..dbbd5df74d6 100644 --- a/code/modules/vending/_vending.dm +++ b/code/modules/vending/_vending.dm @@ -1128,7 +1128,7 @@ GLOBAL_LIST_EMPTY(vending_products) light_mask = "custom-light-mask" refill_canister = /obj/item/vending_refill/custom /// where the money is sent - var/datum/bank_account/private_a + var/datum/bank_account/linked_account /// max number of items that the custom vendor can hold var/max_loaded_items = 20 /// Base64 cache of custom icons. @@ -1139,9 +1139,9 @@ GLOBAL_LIST_EMPTY(vending_products) . = FALSE if(!isliving(user)) return FALSE - var/mob/living/L = user - var/obj/item/card/id/C = L.get_idcard(FALSE) - if(C?.registered_account && C.registered_account == private_a) + var/mob/living/living_user = user + var/obj/item/card/id/id_card = living_user.get_idcard(FALSE) + if(id_card?.registered_account && id_card.registered_account == linked_account) return TRUE /obj/machinery/vending/custom/canLoadItem(obj/item/I, mob/user) @@ -1158,6 +1158,12 @@ GLOBAL_LIST_EMPTY(vending_products) if(I.custom_price) return TRUE +/obj/machinery/vending/custom/ui_interact(mob/user) + if(!linked_account) + balloon_alert(user, "no registered owner") + return FALSE + return ..() + /obj/machinery/vending/custom/ui_data(mob/user) . = ..() .["access"] = compartmentLoadAccessCheck(user) @@ -1191,68 +1197,18 @@ GLOBAL_LIST_EMPTY(vending_products) return switch(action) if("dispense") - . = TRUE - if(!vend_ready) - return - var/N = params["item"] - var/obj/item/S - vend_ready = FALSE - var/obj/item/card/id/C if(isliving(usr)) - var/mob/living/L = usr - C = L.get_idcard(TRUE) - if(!C) - say("No card found.") - flick(icon_deny,src) - vend_ready = TRUE - return - else if (!C.registered_account) - say("No account found.") - flick(icon_deny,src) - vend_ready = TRUE - return - var/datum/bank_account/account = C.registered_account - for(var/obj/O in contents) - if(format_text(O.name) == N) - S = O - break - if(S) - if(compartmentLoadAccessCheck(usr)) - vending_machine_input[N] = max(vending_machine_input[N] - 1, 0) - S.forceMove(drop_location()) - loaded_items-- - use_power(5) - vend_ready = TRUE - updateUsrDialog() - return - if(account.has_money(S.custom_price)) - account.adjust_money(-S.custom_price) - var/datum/bank_account/owner = private_a - if(owner) - owner.adjust_money(S.custom_price) - SSblackbox.record_feedback("amount", "vending_spent", S.custom_price) - log_econ("[S.custom_price] credits were spent on [src] buying a [S] by [owner.account_holder], owned by [private_a.account_holder].") - vending_machine_input[N] = max(vending_machine_input[N] - 1, 0) - S.forceMove(drop_location()) - loaded_items-- - use_power(5) - if(last_shopper != REF(usr) || purchase_message_cooldown < world.time) - say("Thank you for buying local and purchasing [S]!") - purchase_message_cooldown = world.time + 5 SECONDS - last_shopper = REF(usr) - vend_ready = TRUE - updateUsrDialog() - return - else - say("You do not possess the funds to purchase this.") + vend_act(usr, params["item"]) vend_ready = TRUE + updateUsrDialog() + return TRUE /obj/machinery/vending/custom/attackby(obj/item/I, mob/user, params) - if(!private_a && isliving(user)) + if(!linked_account && isliving(user)) var/mob/living/L = user var/obj/item/card/id/C = L.get_idcard(TRUE) if(C?.registered_account) - private_a = C.registered_account + linked_account = C.registered_account say("\The [src] has been linked to [C].") if(compartmentLoadAccessCheck(user)) @@ -1277,6 +1233,57 @@ GLOBAL_LIST_EMPTY(vending_products) explosion(src, devastation_range = -1, light_impact_range = 3) return ..() +/** + * Vends an item to the user. Handles all the logic: + * Updating stock, account transactions, alerting users. + * @return -- TRUE if a valid condition was met, FALSE otherwise. + */ +/obj/machinery/vending/custom/proc/vend_act(mob/living/user, choice) + if(!vend_ready) + return + var/obj/item/dispensed_item + var/obj/item/card/id/id_card = user.get_idcard(TRUE) + vend_ready = FALSE + if(!id_card || !id_card.registered_account || !id_card.registered_account.account_job) + balloon_alert(usr, "no card found") + flick(icon_deny, src) + return TRUE + var/datum/bank_account/payee = id_card.registered_account + for(var/obj/stock in contents) + if(format_text(stock.name) == choice) + dispensed_item = stock + break + if(!dispensed_item) + return FALSE + /// Charges the user if its not the owner + if(!compartmentLoadAccessCheck(user)) + if(!payee.has_money(dispensed_item.custom_price)) + balloon_alert(user, "insufficient funds") + return TRUE + /// Make the transaction + payee.adjust_money(-dispensed_item.custom_price) + linked_account.adjust_money(dispensed_item.custom_price) + linked_account.bank_card_talk("[payee.account_holder] made a [dispensed_item.custom_price] \ + cr purchase at your custom vendor.") + /// Log the transaction + SSblackbox.record_feedback("amount", "vending_spent", dispensed_item.custom_price) + log_econ("[dispensed_item.custom_price] credits were spent on [src] buying a \ + [dispensed_item] by [payee.account_holder], owned by [linked_account.account_holder].") + /// Make an alert + if(last_shopper != REF(usr) || purchase_message_cooldown < world.time) + say("Thank you for your patronage [user]!") + purchase_message_cooldown = world.time + 5 SECONDS + last_shopper = REF(usr) + /// Remove the item + loaded_items-- + use_power(5) + vending_machine_input[choice] = max(vending_machine_input[choice] - 1, 0) + if(user.CanReach(src) && user.put_in_hands(dispensed_item)) + to_chat(user, span_notice("You take [dispensed_item.name] out of the slot.")) + else + to_chat(user, span_warning("[capitalize(dispensed_item.name)] falls onto the floor!")) + return TRUE + /obj/machinery/vending/custom/unbreakable name = "Indestructible Vendor" resistance_flags = INDESTRUCTIBLE diff --git a/tgstation.dme b/tgstation.dme index 4a16e2e5cb6..1f45bf7de61 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -2505,7 +2505,7 @@ #include "code\modules\discord\toggle_notify.dm" #include "code\modules\economy\_economy.dm" #include "code\modules\economy\account.dm" -#include "code\modules\economy\pay_stand.dm" +#include "code\modules\economy\holopay.dm" #include "code\modules\emoji\emoji_parse.dm" #include "code\modules\error_handler\error_handler.dm" #include "code\modules\error_handler\error_viewer.dm" diff --git a/tgui/packages/tgui/components/RestrictedInput.js b/tgui/packages/tgui/components/RestrictedInput.js new file mode 100644 index 00000000000..c4ee1ea1a0d --- /dev/null +++ b/tgui/packages/tgui/components/RestrictedInput.js @@ -0,0 +1,154 @@ +import { classes } from 'common/react'; +import { clamp } from 'common/math'; +import { Component, createRef } from 'inferno'; +import { Box } from './Box'; +import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes'; + +const DEFAULT_MIN = 0; +const DEFAULT_MAX = 10000; + +/** + * Takes a string input and parses integers from it. + * If none: Minimum is set. + * Else: Clamps it to the given range. + */ +const getClampedNumber = (value, minValue, maxValue) => { + const minimum = minValue || DEFAULT_MIN; + const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX; + if (!value || !value.length) { + return String(minimum); + } + let parsedValue = parseInt(value.replace(/\D/g, ''), 10); + if (isNaN(parsedValue)) { + return String(minimum); + } else { + return String(clamp(parsedValue, minimum, maximum)); + } +}; + +export class RestrictedInput extends Component { + constructor() { + super(); + this.inputRef = createRef(); + this.state = { + editing: false, + }; + this.handleBlur = (e) => { + const { editing } = this.state; + if (editing) { + this.setEditing(false); + } + }; + this.handleChange = (e) => { + const { maxValue, minValue, onChange } = this.props; + e.target.value = getClampedNumber(e.target.value, minValue, maxValue); + if (onChange) { + onChange(e, +e.target.value); + } + }; + this.handleFocus = (e) => { + const { editing } = this.state; + if (!editing) { + this.setEditing(true); + } + }; + this.handleInput = (e) => { + const { editing } = this.state; + const { onInput } = this.props; + if (!editing) { + this.setEditing(true); + } + if (onInput) { + onInput(e, +e.target.value); + } + }; + this.handleKeyDown = (e) => { + const { maxValue, minValue, onChange, onEnter } = this.props; + if (e.keyCode === KEY_ENTER) { + const safeNum = getClampedNumber(e.target.value, minValue, maxValue); + this.setEditing(false); + if (onChange) { + onChange(e, safeNum); + } + if (onEnter) { + onEnter(e, safeNum); + } + e.target.blur(); + return; + } + if (e.keyCode === KEY_ESCAPE) { + if (this.props.onEscape) { + this.props.onEscape(e); + return; + } + this.setEditing(false); + e.target.value = this.props.value; + e.target.blur(); + return; + } + }; + } + + componentDidMount() { + const { maxValue, minValue } = this.props; + const nextValue = this.props.value?.toString(); + const input = this.inputRef.current; + if (input) { + input.value = getClampedNumber(nextValue, minValue, maxValue); + } + if (this.props.autoFocus || this.props.autoSelect) { + setTimeout(() => { + input.focus(); + + if (this.props.autoSelect) { + input.select(); + } + }, 1); + } + } + + componentDidUpdate(prevProps, _) { + const { maxValue, minValue } = this.props; + const { editing } = this.state; + const prevValue = prevProps.value?.toString(); + const nextValue = this.props.value?.toString(); + const input = this.inputRef.current; + if (input && !editing) { + if (nextValue !== prevValue && nextValue !== input.value) { + input.value = getClampedNumber(nextValue, minValue, maxValue); + } + } + } + + setEditing(editing) { + this.setState({ editing }); + } + + render() { + const { props } = this; + const { onChange, onEnter, onInput, value, ...boxProps } = props; + const { className, fluid, monospace, ...rest } = boxProps; + return ( + +
.
+ +
+ ); + } +} diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js index 48cd147df39..121d2bfa4e2 100644 --- a/tgui/packages/tgui/components/index.js +++ b/tgui/packages/tgui/components/index.js @@ -33,6 +33,7 @@ export { NoticeBox } from './NoticeBox'; export { NumberInput } from './NumberInput'; export { ProgressBar } from './ProgressBar'; export { Popper } from './Popper'; +export { RestrictedInput } from './RestrictedInput'; export { RoundGauge } from './RoundGauge'; export { Section } from './Section'; export { Slider } from './Slider'; diff --git a/tgui/packages/tgui/interfaces/HoloPay.tsx b/tgui/packages/tgui/interfaces/HoloPay.tsx new file mode 100644 index 00000000000..13fe55b4fec --- /dev/null +++ b/tgui/packages/tgui/interfaces/HoloPay.tsx @@ -0,0 +1,237 @@ +import { useBackend, useLocalState } from '../backend'; +import { Box, Button, Dropdown, Icon, NoticeBox, RestrictedInput, Section, Stack, Table, TextArea, Tooltip } from '../components'; +import { Window } from '../layouts'; + +type HoloPayData = { + available_logos: string[]; + balance: number; + description: string; + force_fee: number; + max_fee: number; + name: string; + owner: string; + shop_logo: string; + user: { name: string; balance: number }; +}; + +const COPYRIGHT_SCROLLER = `Nanotrasen (c) 2525-2562. All sales final. +Use of departmental funds is prohibited. For more information, visit +the Head of Personnel. All rights reserved. All trademarks are property +of their respective owners.`; + +export const HoloPay = (_, context) => { + const { data } = useBackend(context); + const { owner } = data; + const [setupMode, setSetupMode] = useLocalState(context, 'setupMode', false); + // User clicked the "Setup" or "Done" button. + const onClick = () => { + setSetupMode(!setupMode); + }; + + return ( + + + {!owner ? ( + Error! Swipe an ID first. + ) : ( + + + + + + {!setupMode ? ( + + ) : ( + + )} + + + )} + + + ); +}; + +/** + * Displays the current user's bank information (if any) + */ +const AccountDisplay = (_, context) => { + const { data } = useBackend(context); + const { user } = data; + if (!user) { + return Error! No account detected.; + } + + return ( +
+ + + + + + {user?.name} + + + + + {user?.balance} cr + + + +
+
+ ); +}; + +/** + * Displays the payment processor. This is the main display. + * Shows icon, name, payment button. + */ +const TerminalDisplay = (props, context) => { + const { act, data } = useBackend(context); + const { description, force_fee, name, owner, user, shop_logo } = data; + const { onClick } = props; + const is_owner = owner === user?.name; + const cannot_pay + = is_owner || !user || user?.balance < 1 || user?.balance < force_fee; + const decodedName = name.replace(/&#(\d+);/g, (_, dec) => { + return String.fromCharCode(dec); + }); + + return ( +
+ Setup + ) + } + fill + title="Terminal"> + + + + + + + + {decodedName} + + + + + {force_fee ? ( + + + Pay {force_fee + ' cr'} + + } + disabled={cannot_pay} + fluid + height="2rem" + onClick={() => act('pay')} + pt={0.2} + textAlign="center" + /> + ) : ( +
+ ); +}; + +/** + * User has clicked "setup" button. Changes vars on the holopay. + */ +const SetupDisplay = (props, context) => { + const { act, data } = useBackend(context); + const { available_logos = [], force_fee, max_fee, name, shop_logo } = data; + const { onClick } = props; + const decodedName = name.replace(/&#(\d+);/g, (_, dec) => { + return String.fromCharCode(dec); + }); + + return ( +
{ + act('done'); + onClick(); + }}> + Done + + } + fill + scrollable + title="Settings"> + + + + Shop Logo + + act('logo', { logo: value })} + options={available_logos} + selected={shop_logo} + width="100%" + /> + + + + Name (3 - 42 chars) + +