diff --git a/code/__DEFINES/announcements.dm b/code/__DEFINES/announcements.dm index 3a2edf6d6f7..b40303514ea 100644 --- a/code/__DEFINES/announcements.dm +++ b/code/__DEFINES/announcements.dm @@ -5,3 +5,7 @@ #define ANNOUNCEMENT_TYPE_CAPTAIN "Captain" /// Make it sound like it's coming from the Syndicate #define ANNOUNCEMENT_TYPE_SYNDICATE "Syndicate" + +//Defines for newscaster news stations, the defined thing is what it'll be called in the Newscaster. +#define NEWSCASTER_STATION_ANNOUNCEMENTS "Station Announcements" +#define NEWSCASTER_SPACE_BETTING "SpaceBet" diff --git a/code/__HELPERS/priority_announce.dm b/code/__HELPERS/priority_announce.dm index 3e458bbb33f..e49001b4e72 100644 --- a/code/__HELPERS/priority_announce.dm +++ b/code/__HELPERS/priority_announce.dm @@ -63,7 +63,7 @@ header += SUBHEADER_ANNOUNCEMENT_TITLE(title) if(ANNOUNCEMENT_TYPE_CAPTAIN) header = MAJOR_ANNOUNCEMENT_TITLE("Captain's Announcement") - GLOB.news_network.submit_article(text, "Captain's Announcement", "Station Announcements", null) + GLOB.news_network.submit_article(text, "Captain's Announcement", NEWSCASTER_STATION_ANNOUNCEMENTS, null) if(ANNOUNCEMENT_TYPE_SYNDICATE) header = MAJOR_ANNOUNCEMENT_TITLE("Syndicate Captain's Announcement") else @@ -87,9 +87,9 @@ if(isnull(sender_override) && players == GLOB.player_list) if(length(title) > 0) - GLOB.news_network.submit_article(title + "

" + text, "[command_name()]", "Station Announcements", null) + GLOB.news_network.submit_article(title + "

" + text, "[command_name()]", NEWSCASTER_STATION_ANNOUNCEMENTS, null) else - GLOB.news_network.submit_article(text, "[command_name()] Update", "Station Announcements", null) + GLOB.news_network.submit_article(text, "[command_name()] Update", NEWSCASTER_STATION_ANNOUNCEMENTS, null) /proc/print_command_report(text = "", title = null, announce=TRUE) if(!title) diff --git a/code/controllers/subsystem/economy.dm b/code/controllers/subsystem/economy.dm index aed9222b40d..9aff9693914 100644 --- a/code/controllers/subsystem/economy.dm +++ b/code/controllers/subsystem/economy.dm @@ -191,7 +191,7 @@ SUBSYSTEM_DEF(economy) update_alerts = TRUE inflict_moneybags(moneybags) earning_report += "That's all from the Nanotrasen Economist Division." - GLOB.news_network.submit_article(earning_report, "Station Earnings Report", "Station Announcements", null, update_alert = update_alerts) + GLOB.news_network.submit_article(earning_report, "Station Earnings Report", NEWSCASTER_STATION_ANNOUNCEMENTS, null, update_alert = update_alerts) return TRUE /** diff --git a/code/game/machinery/newscaster/newscaster_data.dm b/code/game/machinery/newscaster/newscaster_data.dm index 3b3287b2513..d67e7e99365 100644 --- a/code/game/machinery/newscaster/newscaster_data.dm +++ b/code/game/machinery/newscaster/newscaster_data.dm @@ -185,7 +185,8 @@ GLOBAL_LIST_EMPTY(request_list) var/message_count = 0 /datum/feed_network/New() - create_feed_channel("Station Announcements", "SS13", "Company news, staff announcements, and all the latest information. Have a secure shift!", locked = TRUE, hardset_channel = 1000) + create_feed_channel(NEWSCASTER_STATION_ANNOUNCEMENTS, "SS13", "Company news, staff announcements, and all the latest information. Have a secure shift!", locked = TRUE, hardset_channel = 1000) + create_feed_channel(NEWSCASTER_SPACE_BETTING, "NtOS", "News from the SpaceBet PDA App! Download now and make your own bets!", locked = TRUE, hardset_channel = 1001) wanted_issue = new /datum/wanted_message /datum/feed_network/proc/create_feed_channel(channel_name, author, desc, locked, adminChannel = FALSE, hardset_channel = null, author_ckey = null, cross_sector = FALSE, cross_sector_delay = null, receiving_cross_sector = FALSE) @@ -236,7 +237,6 @@ GLOBAL_LIST_EMPTY(request_list) newMsg.parent_ID = channel.channel_ID if (!channel.cross_sector) break - // Newscaster articles could be huge, and usefulness of first 50 symbols is dubious message_admins(span_adminnotice("Outgoing cross-sector newscaster article by [key_name(author_mob) || author] in channel [channel_name].")) var/list/payload = list( @@ -249,6 +249,18 @@ GLOBAL_LIST_EMPTY(request_list) for(var/obj/machinery/newscaster/caster in GLOB.allCasters) caster.news_alert(channel_name, update_alert) + return newMsg + +///Submits a comment on the news network +/datum/feed_network/proc/submit_comment(mob/user, comment_text, newscaster_username, datum/feed_message/current_message) + var/datum/feed_comment/new_feed_comment = new/datum/feed_comment + new_feed_comment.author = newscaster_username + new_feed_comment.body = comment_text + new_feed_comment.time_stamp = station_time_timestamp() + GLOB.news_network.last_action ++ + current_message.comments += new_feed_comment + if(user) + user.log_message("(as [newscaster_username]) commented on message [current_message.return_body(-1)] -- [current_message.body]", LOG_COMMENT) /datum/feed_network/proc/submit_wanted(criminal, body, scanned_user, datum/picture/picture, adminMsg = FALSE, newMessage = FALSE) wanted_issue.active = TRUE diff --git a/code/game/machinery/newscaster/newscaster_machine.dm b/code/game/machinery/newscaster/newscaster_machine.dm index da243f81a35..208166ef231 100644 --- a/code/game/machinery/newscaster/newscaster_machine.dm +++ b/code/game/machinery/newscaster/newscaster_machine.dm @@ -741,13 +741,7 @@ MAPPING_DIRECTIONAL_HELPERS(/obj/machinery/newscaster, 30) if(!newscaster_username) creating_comment = FALSE return TRUE - var/datum/feed_comment/new_feed_comment = new/datum/feed_comment - new_feed_comment.author = newscaster_username - new_feed_comment.body = comment_text - new_feed_comment.time_stamp = station_time_timestamp() - GLOB.news_network.last_action ++ - current_message.comments += new_feed_comment - user.log_message("(as [newscaster_username]) commented on message [current_message.return_body(-1)] -- [current_message.body]", LOG_COMMENT) + GLOB.news_network.submit_comment(user, comment_text, newscaster_username, current_message) creating_comment = FALSE /** diff --git a/code/game/machinery/requests_console.dm b/code/game/machinery/requests_console.dm index 394ed99e9dd..60ad61f8cf5 100644 --- a/code/game/machinery/requests_console.dm +++ b/code/game/machinery/requests_console.dm @@ -191,7 +191,7 @@ GLOBAL_LIST_EMPTY(req_console_ckey_departments) message = L.treat_message(message)["message"] minor_announce(message, "[department] Announcement:", html_encode = FALSE, sound_override = ANNOUNCER_DEPARTMENTAL) // SKYRAT EDIT CHANGE - Announcer Sounds - GLOB.news_network.submit_article(message, department, "Station Announcements", null) + GLOB.news_network.submit_article(message, department, NEWSCASTER_STATION_ANNOUNCEMENTS, null) usr.log_talk(message, LOG_SAY, tag="station announcement from [src]") message_admins("[ADMIN_LOOKUPFLW(usr)] has made a station announcement from [src] at [AREACOORD(usr)].") deadchat_broadcast(" made a station announcement from [span_name("[get_area_name(usr, TRUE)]")].", span_name("[usr.real_name]"), usr, message_type=DEADCHAT_ANNOUNCEMENT) diff --git a/code/modules/modular_computers/file_system/programs/betting.dm b/code/modules/modular_computers/file_system/programs/betting.dm new file mode 100644 index 00000000000..19b47dd89d2 --- /dev/null +++ b/code/modules/modular_computers/file_system/programs/betting.dm @@ -0,0 +1,285 @@ +GLOBAL_LIST_EMPTY_TYPED(active_bets, /datum/active_bet) + +///Max amount of characters you can have in an active bet's title +#define MAX_LENGTH_TITLE 64 +///Max amount of characters you can have in an active bet's description +#define MAX_LENGTH_DESCRIPTION 200 + +/datum/computer_file/program/betting + filename = "betting" + filedesc = "SpaceBet" + downloader_category = PROGRAM_CATEGORY_GAMES + program_open_overlay = "gambling" + extended_desc = "A multi-platform network for placing requests across the station, with payment across the network being possible." + program_flags = PROGRAM_ON_NTNET_STORE | PROGRAM_REQUIRES_NTNET + can_run_on_flags = PROGRAM_PDA + size = 4 + tgui_id = "NtosSpaceBetting" + program_icon = "dice" + + ///The active bet this program made, as we can only have 1 going at a time to prevent flooding/spam. + var/datum/active_bet/created_bet + +/datum/computer_file/program/betting/New() + . = ..() + RegisterSignal(src, COMSIG_COMPUTER_FILE_DELETE, PROC_REF(on_delete)) + +///Called when we're deleted, we'll be taking the bet with us. +/datum/computer_file/program/betting/proc/on_delete(datum/source, obj/item/modular_computer/computer_uninstalling) + SIGNAL_HANDLER + + created_bet.payout() + QDEL_NULL(created_bet) + +/datum/computer_file/program/betting/ui_data(mob/user) + var/list/data = list() + data["active_bets"] = list() + for(var/datum/active_bet/bets as anything in GLOB.active_bets) + data["active_bets"] += list(list( + "name" = bets.name, + "description" = bets.description, + "owner" = bets == created_bet, + "creator" = bets.bet_owner, + "current_bets" = bets.get_bets(computer.computer_id_slot?.registered_account), + "locked" = bets.locked, + )) + + data["can_create_bet"] = !!isnull(created_bet) + if(isnull(computer.computer_id_slot)) + data["bank_name"] = null + data["bank_money"] = null + else + data["bank_name"] = computer.computer_id_slot.registered_account.account_holder + data["bank_money"] = computer.computer_id_slot.registered_account.account_balance + + return data + +/datum/computer_file/program/betting/ui_static_data(mob/user) + var/list/data = list() + data["max_title_length"] = MAX_LENGTH_TITLE + data["max_description_length"] = MAX_LENGTH_DESCRIPTION + return data + +/datum/computer_file/program/betting/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + var/mob/user = ui.user + if(isnull(computer.computer_id_slot)) + to_chat(user, span_danger("\The [computer] flashes an \"RFID Error - Unable to scan ID\" warning.")) + return + switch(action) + if("create_bet") + var/title = reject_bad_name(params["title"], allow_numbers = TRUE, max_length = MAX_LENGTH_TITLE, cap_after_symbols = FALSE) + var/description = reject_bad_name(params["description"], allow_numbers = TRUE, max_length = MAX_LENGTH_DESCRIPTION, cap_after_symbols = FALSE) + if(isnull(title) || isnull(description)) + return + var/list/options = list(params["option1"], params["option2"], params["option3"], params["option4"]) + for(var/option in options) + options -= option + //remove nulls, empty, and duplicates. + if(isnull(option) || option == "" || options.Find(option)) + continue + options += option + option = reject_bad_name(option, allow_numbers = TRUE, max_length = MAX_LENGTH_TITLE, cap_after_symbols = FALSE) + if(length(options) < 2) + to_chat(user, span_danger("2 options minimum required to start a bet.")) + return + created_bet = new(user, title, description, options) + return TRUE + if("place_bet") + var/datum/active_bet/bet_placed_on + for(var/datum/active_bet/bets as anything in GLOB.active_bets) + if(bets.name == params["bet_selected"]) + bet_placed_on = bets + //can't bet on your own bet + if(isnull(bet_placed_on)) + return + if(bet_placed_on == created_bet) + to_chat(user, span_danger("You can't bet on your own poll!")) + return + var/money_betting = params["money_betting"] + if(!isnum(money_betting)) + return + var/option = params["option_selected"] + if(isnull(bet_placed_on)) + return + bet_placed_on.bet_money(computer.computer_id_slot.registered_account, money_betting, option) + return TRUE + if("cancel_bet") + var/datum/active_bet/bet_cancelling + for(var/datum/active_bet/bets as anything in GLOB.active_bets) + if(bets.name == params["bet_selected"]) + bet_cancelling = bets + bet_cancelling.cancel_bet(computer.computer_id_slot.registered_account) + return TRUE + if("select_winner") + var/datum/active_bet/bets_ending + for(var/datum/active_bet/bets as anything in GLOB.active_bets) + if(bets.name == params["bet_selected"]) + bets_ending = bets + if(isnull(bets_ending) || bets_ending != created_bet) + return + created_bet.payout(params["winning_answer"]) + QDEL_NULL(created_bet) + return TRUE + if("lock_betting") + var/datum/active_bet/bet_locking + for(var/datum/active_bet/bets as anything in GLOB.active_bets) + if(bets.name == params["bet_selected"]) + bet_locking = bets + if(bet_locking != created_bet) + return + bet_locking.locked = TRUE + +/** + * The active bet that our app will create & use, handles following who owns the bet, + * who is betting, and also takes care of paying out at the end. + */ +/datum/active_bet + ///The person owning the bet, who will choose which option has won. + var/bet_owner + ///The name of the bet + var/name + ///The description of the bet + var/description + ///Boolean on whether the bet is locked from getting new betters, or current ones from taking their money out. + var/locked + ///Total amount of money that has been bet. + var/total_amount_bet + /** Assoc list of options, with each option having a list of people betting and the amount they've bet. + options = list( + OPTION_A = list( + PERSON_1_ACCOUNT = bet_amount, + PERSON_2_ACCOUNT = bet_amount, + ), + OPTION_B = list( + PERSON_3_ACCOUNT = bet_amount, + ), + ) + */ + var/list/options + + ///The message we sent to the newscaster, which we'll then reply to once the betting is over. + var/datum/feed_message/newscaster_message + +/datum/active_bet/New(creator, name, description, options) + src.bet_owner = creator + src.name = name + src.description = description + src.options = options + GLOB.active_bets += src + for(var/option in options) + if(!length(options[option])) + options[option] = list() + //we'll only advertise it on the first bet of the round, as to not make this overly annoying. + var/should_alert = FALSE + for(var/datum/feed_channel/FC in GLOB.news_network.network_channels) + if(FC.channel_name == NEWSCASTER_SPACE_BETTING) + if(!length(FC.messages)) + should_alert = TRUE + newscaster_message = GLOB.news_network.submit_article("The bet [name] has started, place your bets now!", "NtOS Space Betting App", NEWSCASTER_SPACE_BETTING, null, update_alert = should_alert) + +/datum/active_bet/Destroy(force) + GLOB.active_bets -= src + newscaster_message = null + return ..() + +///Returns how many bets there is per option +/datum/active_bet/proc/get_bets(datum/bank_account/user_account) + var/list/bets_per_option = list() + for(var/option in options) + var/amount_personally_invested = 0 + var/total_amount = 0 + for(var/list/existing_bets in options[option]) + var/existing_bet_amount = text2num(existing_bets[2]) + if(user_account && (existing_bets[1] == user_account)) + amount_personally_invested = existing_bet_amount + total_amount += existing_bet_amount + bets_per_option += list(list("option_name" = option, "amount" = total_amount, "personally_invested" = amount_personally_invested)) + return bets_per_option + +///Pays out the loser's money equally to all the winners, or refunds it all if no winning option was given. +/datum/active_bet/proc/payout(winning_option) + if(isnull(winning_option) || !(winning_option in options)) + //no winner was selected (likely the host's PDA was destroyed or attempted href exploit), so let's refund everyone. + for(var/list/option in options) + for(var/list/existing_bets in options[option]) + var/datum/bank_account/refunded_account = existing_bets[1] + refunded_account.adjust_money(text2num(existing_bets[2]), "Refund: [name] gamble cancelled.") + return + GLOB.news_network.submit_comment( + comment_text = "The bet [name] has ended, the winner was [winning_option]!", + newscaster_username = "NtOS Betting Results", + current_message = newscaster_message, + ) + var/list/winners = options[winning_option] + if(!length(winners)) + return + for(var/list/winner in winners) + //they aren't winning their own money, so people betting a ton of money won't lose their money to those who bet few. + total_amount_bet -= text2num(winner[2]) + for(var/list/winner in winners) + var/datum/bank_account/winner_account = winner[1] + var/money_won = text2num(winner[2]) + total_amount_bet / length(winners) + winner_account.adjust_money(money_won, "Won gamble: [name]") //give them their money back & whatever they won. + //they only made their money back, don't tell them they won anything. + if((money_won - text2num(winner[2])) == 0) + continue + winner_account.bank_card_talk("You won [money_won]cr from having a correct guess on [name]!") + +///Puts a bank account's money bet on a given option. +/datum/active_bet/proc/bet_money(datum/bank_account/better, money_betting, option_betting) + if(locked) + return + for(var/option in options) + for(var/list/existing_bets in options[option]) + if(existing_bets[1] == better) + //We're already betting, but now we're betting on another one, clear our previous and we'll bet on the new. + if(option != option_betting) + better.adjust_money(text2num(existing_bets[2]), "Refunded: changed bet for [name].") + options[option] -= list(existing_bets) + //We're already betting on the same one, we'll add it together instead of making it a separate bet, or the user is taking money out. + else + //putting more money in + if(text2num(existing_bets[2]) < money_betting) + if(better.account_balance < money_betting) + return + var/money_adding_in = money_betting - text2num(existing_bets[2]) + total_amount_bet += money_adding_in + better.bank_card_talk("Additional [money_adding_in]cr deducted for your bet on [name].") + better.adjust_money(-money_adding_in, "Gambling on [name].") + existing_bets[2] = "[money_betting]" + return + //taking it all out, we remove them from the list so they aren't a winner with bets of 0. + if(money_betting == 0) + var/money_taking_out = text2num(existing_bets[2]) + total_amount_bet -= money_taking_out + better.adjust_money(money_taking_out, "Refunded: changed bet for [name].") + options[option] -= list(existing_bets) + return + //taking money out + if(text2num(existing_bets[2]) > money_betting) + var/money_taking_out = text2num(existing_bets[2]) - money_betting + total_amount_bet -= money_taking_out + better.bank_card_talk("Refunded [money_taking_out]cr for taking money out of your bet on [name].") + better.adjust_money(money_taking_out, "Refund from gambling on [name].") + existing_bets[2] = "[money_betting]" + return + + total_amount_bet += money_betting + options[option_betting] += list(list(better, "[money_betting]")) + better.adjust_money(-money_betting, "Gambling on [name]") + better.bank_card_talk("Deducted [money_betting]cr for your bet on [name].") + +///Cancels your bet, removing your bet and refunding your money. +/datum/active_bet/proc/cancel_bet(datum/bank_account/better) + for(var/option in options) + for(var/list/existing_bets in options[option]) + if(existing_bets[1] == better) + var/money_refunding = text2num(existing_bets[2]) + total_amount_bet -= money_refunding + better.bank_card_talk("Refunded [money_refunding]cr for cancelling your bet on [name].") + better.adjust_money(money_refunding, "Refunded: changed bet for [name].") + options[option] -= list(existing_bets) + +#undef MAX_LENGTH_TITLE +#undef MAX_LENGTH_DESCRIPTION diff --git a/icons/obj/devices/modular_pda.dmi b/icons/obj/devices/modular_pda.dmi index 6981b1de37f..a427eb143ce 100644 Binary files a/icons/obj/devices/modular_pda.dmi and b/icons/obj/devices/modular_pda.dmi differ diff --git a/tgstation.dme b/tgstation.dme index 3600bc307d7..64838712168 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -5648,6 +5648,7 @@ #include "code\modules\modular_computers\file_system\programs\alarm.dm" #include "code\modules\modular_computers\file_system\programs\arcade.dm" #include "code\modules\modular_computers\file_system\programs\atmosscan.dm" +#include "code\modules\modular_computers\file_system\programs\betting.dm" #include "code\modules\modular_computers\file_system\programs\borg_monitor.dm" #include "code\modules\modular_computers\file_system\programs\bounty_board.dm" #include "code\modules\modular_computers\file_system\programs\budgetordering.dm" diff --git a/tgui/packages/tgui/interfaces/NtosSpaceBetting.tsx b/tgui/packages/tgui/interfaces/NtosSpaceBetting.tsx new file mode 100644 index 00000000000..c8bdb11ccfd --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosSpaceBetting.tsx @@ -0,0 +1,263 @@ +import { useState } from 'react'; +import { + Box, + Button, + Collapsible, + Divider, + Icon, + Input, + NumberInput, + Section, + Stack, + TextArea, +} from 'tgui-core/components'; +import { BooleanLike } from 'tgui-core/react'; + +import { useBackend } from '../backend'; +import { NtosWindow } from '../layouts'; + +type Data = { + active_bets: ActiveBets[]; + bank_name: string; + bank_money: number; + can_create_bet: BooleanLike; + max_title_length: number; + max_description_length: number; +}; + +type ActiveBets = { + name: string; + description: string; + owner: BooleanLike; + creator: string; + current_bets: CurrentBets[]; + locked: BooleanLike; +}; + +type CurrentBets = { + option_name: string; + total_amount: number; + personally_invested: number; +}; + +export const NtosSpaceBetting = () => { + const { act, data } = useBackend(); + const { bank_name, bank_money, can_create_bet } = data; + return ( + + +
+ + + + + + Username: {bank_name} + Money Available: {bank_money}cr + + +
+ + {!!can_create_bet && } +
+
+ ); +}; + +export const PollsSection = () => { + const { act, data } = useBackend(); + const { active_bets = [] } = data; + const [Winner, set_winner] = useState(''); + return ( +
+ {!active_bets.length ? ( + + There's currently no active polls to bet on, create one below! + + ) : ( + active_bets.map( + ( + { name, description, owner, creator, current_bets = [], locked }, + index, + ) => ( +
+ + + {description} + + {current_bets.map( + ( + { option_name, total_amount, personally_invested }, + index, + ) => ( + + + + {option_name} (Has {total_amount || 0}cr bet on it) + {!owner ? ( + + act('place_bet', { + bet_selected: name, + option_selected: option_name, + money_betting: value, + }) + } + /> + ) : ( + set_winner(option_name)} + /> + )} + + + + ), + )} + {!!owner && + (!locked ? ( + + + act('lock_betting', { bet_selected: name }) + } + > + Lock Betting + + + ) : ( + + act('select_winner', { + bet_selected: name, + winning_answer: Winner, + }) + } + > + Finalize Results + + ))} + + + + + +
+ ), + ) + )} +
+ ); +}; + +export const BettingCreation = () => { + const { act, data } = useBackend(); + const { max_title_length, max_description_length } = data; + const [Title, setTitle] = useState(''); + const [Desc, setDesc] = useState(''); + const [Option1, setOption1] = useState(''); + const [Option2, setOption2] = useState(''); + const [Option3, setOption3] = useState(''); + const [Option4, setOption4] = useState(''); + return ( + + + + setTitle(value)} + /> + + +