# frozen_string_literal: true
# New command? Make sure to create a migration to add the configuration to all communities
module ESM
module Command
module Server
class Reward < ApplicationCommand
SPAWN_LOCATIONS = ["virtual_garage", "player_decides"].freeze
# TODO:
# - Add cooldown support
# - Skip collecting VG data if vg_enabled is false
# - Improve embeds and user experience
# - Change separator to space for vehicle spawn locations
# - Add translations for all strings
#
# Command entry points
# 1. Command is executed (#on_execute)
# Ask for vehicle spawn locations (#request_spawn_locations)
# Ask for confirmation (#await_for_reply)
#
# 2. User replies back (#on_reply)
# Accept word -> Send request to arma (#on_accept)
# Cancel word -> Confirm cancellation (#on_cancel)
# Territory and spawn location -> Store data and repeat (#store_vehicle_location)
#
# 3. Server response back (#on_response)
# TODO
#
command_type :player
change_attribute :cooldown_time, modifiable: false, default: 2.seconds # Don't allow adjusting this cooldown. Each reward has it's own cooldown
argument :server_id
argument :reward_id, regex: ESM::Regex::REWARD_ID, default: nil
# FOR DEV ONLY
# skip_action :cooldown, :connected_server
#
# The entry point for when the bot first receives a command
#
def on_execute
check_for_in_progress!
# Check to see if the server has any rewards for the user before even sending the request
check_for_valid_reward_id!
check_for_reward_items!
# Notify the user that we're sending them a direct message only if the command was from a text channel
if current_channel.text?
embed = ESM::Embed.new(:success, description: t("errors.check_pm", user: current_user.mention))
reply(embed)
end
@nearby_vehicles = []
@vehicles = selected_reward.vehicles.clone
@locations = selected_reward.reward_vehicles.group_by { |v| v["spawn_location"] }.transform_values(&:size)
send_request_description
request_spawn_locations if spawn_locations_needed?
await_for_reply
end
#
# Called when an Arma 3 server is responding to a message sent from this command
#
# @param incoming_message [ESM::Message] The incoming message from the server
# @param _outgoing_message [ESM::Message] The outgoing message from the command
#
def on_response
results = incoming_message.data
embed = ESM::Embed.build do |e|
e.title = t("receipt_embed.title")
e.description = t("receipt_embed.description")
e.add_field(name: t("receipt_embed.fields.player_poptabs.name"), value: t("receipt_embed.fields.player_poptabs.value", poptabs: results.player_poptabs.to_poptab), inline: true) if results.player_poptabs
e.add_field(name: t("receipt_embed.fields.locker_poptabs.name"), value: t("receipt_embed.fields.locker_poptabs.value", poptabs: results.locker_poptabs.to_poptab), inline: true) if results.locker_poptabs
e.add_field(name: t("receipt_embed.fields.respect.name"), value: t("receipt_embed.fields.respect.value", respect: results.respect), inline: true) if results.respect
e.add_field(name: t("receipt_embed.fields.items.name"), value: "") if results.items
e.add_field(name: t("receipt_embed.fields.vehicles.name"), value: "") if results.vehicles
# TODO: - Add cooldowns
end
reply(embed)
end
private
def on_reply(event)
if event.nil?
embed = ESM::Embed.new(:error, description: t("commands.reward.no_reply", user: current_user.mention))
return reply(embed)
end
content = event.message.content.squish
return on_cancel if %w[quit exit stop cancel reject].include?(content.downcase)
return on_accept if %w[done finish accept].include?(content.downcase)
return await_for_reply if @vehicles.blank? # on_reply is called even if there are no vehicles
# The message is formatted correctly
if !content.match?(/(\d+:\w+)+/i)
await_for_reply
event.message.react("❌")
return
end
store_vehicle_location(event)
end
def on_cancel
embed = ESM::Embed.build(:success, title: "Cancelled", description: "You may re-run the command to start over")
reply(embed)
end
def on_accept
vehicles = @vehicles + @nearby_vehicles
if vehicles.present?
# Check to see if all vehicles have the required information
invalid_vehicle_numbers = vehicles.map.with_index do |vehicle, index|
next if vehicle[:territory_id].present? || (vehicle[:chosen_location] || vehicle[:spawn_location]).casecmp?("nearby")
index + 1
end.compact
if invalid_vehicle_numbers.present?
reply("Yo, you forgot some. #{invalid_vehicle_numbers}")
await_for_reply
return
end
vehicles = vehicles.map do |vehicle|
{class_name: vehicle[:class_name], spawn_location: vehicle[:territory_id] || "nearby"}
end
end
data = {}
data[:player_poptabs] = selected_reward.player_poptabs if selected_reward.player_poptabs.positive?
data[:locker_poptabs] = selected_reward.locker_poptabs if selected_reward.locker_poptabs.positive?
data[:respect] = selected_reward.respect if selected_reward.respect.positive?
data[:vehicles] = vehicles.to_json if vehicles.present?
data[:items] = selected_reward.reward_items if selected_reward.reward_items.present?
send_to_target_server!(data: data)
end
def send_request_description
embed = ESM::Embed.new do |e|
e.color = :green
e.description = t(
"information_embed.description",
user: current_user.mention,
server_id: target_server.server_id,
reward_id: selected_reward.reward_id || "default"
)
if selected_reward.player_poptabs.positive?
translation_name = "information_embed.fields.player_poptabs"
e.add_field(name: t("#{translation_name}.name"), value: t("#{translation_name}.value", poptabs: selected_reward.player_poptabs.to_poptab), inline: true)
end
if selected_reward.locker_poptabs.positive?
translation_name = "information_embed.fields.locker_poptabs"
e.add_field(name: t("#{translation_name}.name"), value: t("#{translation_name}.value", poptabs: selected_reward.locker_poptabs.to_poptab), inline: true)
end
if selected_reward.respect.positive?
translation_name = "information_embed.fields.respect"
e.add_field(name: t("#{translation_name}.name"), value: t("#{translation_name}.value", respect: selected_reward.respect), inline: true)
end
if selected_reward.reward_items.present?
items = selected_reward.items.sort_by { |v| v[:display_name].downcase }.join_map("\n") do |item|
"#{item[:quantity]}x #{item[:display_name]}"
end
translation_name = "information_embed.fields.items"
e.add_field(name: t("#{translation_name}.name"), value: t("#{translation_name}.value", items: items))
end
if selected_reward.reward_vehicles.present?
vehicles = selected_reward.vehicles.sort_by { |v| v[:display_name].downcase }.join_map("\n") do |vehicle|
vehicle[:display_name].to_s
end
translation_name = "information_embed.fields.vehicles"
e.add_field(name: t("#{translation_name}.name"), value: t("#{translation_name}.value", vehicles: vehicles))
end
e.add_field(value: t("information_embed.fields.accept")) if !spawn_locations_needed?
e.footer = t("information_embed.footer")
end
reply(embed)
end
def spawn_locations_needed?
@spawn_locations_needed ||= SPAWN_LOCATIONS.any? { |location| @locations.key?(location) }
end
def request_spawn_locations
@territories = user_territories.sort_by(&:territory_id)
# They'll be added back later. We don't need a location for them
@vehicles.reject! do |vehicle|
@nearby_vehicles << vehicle if vehicle[:spawn_location] == "nearby"
end
@vehicle_message = reply(vehicle_embed)
end
def selected_reward
@selected_reward ||= target_server.server_rewards.where(reward_id: arguments.reward_id).first
end
def check_for_in_progress!
return if !ESM.bot.waiting_for_reply?(user_id: current_user.discord_id, channel_id: current_channel.id)
raise_error!(:waiting_for_reply, user: current_user.mention, command_name: name)
end
def check_for_valid_reward_id!
return if selected_reward.present?
raise_error!(:incorrect_reward_id, user: current_user.mention, reward_id: arguments.reward_id)
end
def check_for_reward_items!
return if selected_reward.reward_items.present? ||
selected_reward.reward_vehicles.present? ||
selected_reward.locker_poptabs.positive? ||
selected_reward.player_poptabs.positive? ||
selected_reward.respect.positive?
raise_error!(:no_reward_items, user: current_user.mention)
end
def await_for_reply
Thread.new do
ESM.bot.wait_for_reply(user_id: current_user.discord_id, channel_id: current_channel.id) { |event| on_reply(event) }
end
end
#
# Sends a request to the server to retrieve this player's territory data
#
def user_territories
@user_territories ||= lambda do
response = target_server.connection.send_message(
{
type: "query",
data: {
name: "reward_territories",
arguments: {
uid: current_user.steam_uid
}
}
}
)
if response.errors?
error!(id: response.id, errors: response.errors)
raise_error!(:territory_query, user: current_user.mention, server_id: target_server.server_id)
end
return [] if response.data.results.blank?
# {
# id: Integer,
# custom_id: String,
# name: String,
# level: Integer,
# vehicle_count: Integer,
# }
response.data.results.map do |territory|
territory[:territory_id] = territory["custom_id"] || target_server.encode_id(territory[:id]).upcase
OpenStruct.new(territory)
end
end.call
end
def vehicle_embed
ESM::Embed.new do |e|
e.title = "Vehicle Configuration"
e.description = ""
e.add_field(name: "", value: "```#{spawn_locations_table}```")
e.add_field(name: "Vehicles", value: "```#{vehicles_table}```")
end
end
def spawn_locations_table
Terminal::Table.new do |t|
t.style = {
# width: 63,
border: :unicode,
border_top: false,
border_right: false,
border_bottom: false,
border_left: false
}
t.headings = ["Territory ID", "Name", "Free Spots"]
t.add_row(["Nearby", "Spawn near player", "∞"])
max_size_lookup = target_server.metadata.vg_max_sizes.to_a
@territories.each do |territory|
max_size = max_size_lookup[territory.level].to_i
free_spots =
if territory.vehicle_count >= max_size
"full"
else
max_size - territory.vehicle_count
end
t.add_row([territory.custom_id || territory.territory_id, territory.name.truncate(60), free_spots])
end
end
end
def vehicles_table
Terminal::Table.new do |t|
t.style = {
border: :unicode,
# width: 63,
border_top: false,
border_right: false,
border_bottom: false,
border_left: false
}
t.headings = ["Vehicle #", "Location", "Vehicle Name"]
@vehicles.each_with_index do |vehicle, index|
display_name =
if vehicle[:spawn_location] == "virtual_garage"
"#{vehicle[:display_name].truncate(60)} (VG ONLY)"
else
vehicle[:display_name].truncate(80)
end
t.add_row([index + 1, vehicle[:chosen_location] || "Choose...", display_name])
end
end
end
def store_vehicle_location(event)
# Supports one at a time or multiple
location_data = event.message.content.split.map { |s| s.split(":") }
success = false
location_data.each do |vehicle_index, location|
vehicle_index = [vehicle_index.to_i - 1, 0].max
vehicle = @vehicles[vehicle_index]
next if vehicle.nil?
# Check to make sure this territory is valid. The user can use a custom id or territory ID
location = location.downcase
territory = @territories.find { |t| location.casecmp?(t.custom_id) || location.casecmp?(t.territory_id) }
# Store the location on the vehicle
previous_choice = vehicle[:chosen_location]
if territory && SPAWN_LOCATIONS.include?(vehicle[:spawn_location])
territory.vehicle_count += 1
vehicle[:chosen_location] = territory.custom_id || territory.territory_id
vehicle[:territory_id] = territory.id
elsif vehicle[:spawn_location] == "player_decides"
vehicle[:chosen_location] = "Nearby"
vehicle.delete(:territory_id)
else
next # Territory is invalid
end
# Increase the vehicle count because we're removing one
if (previous_territory = @territories.find { |t| previous_choice.casecmp?(t.custom_id) || previous_choice.casecmp?(t.territory_id) })
previous_territory.vehicle_count -= 1
end
success = true
end
if !success
await_for_reply
event.message.react("❌")
return
end
event.message.react("✅")
edit_message(@vehicle_message, vehicle_embed)
await_for_reply
end
end
end
end
end