class ActivityProcessor attr_reader :character, :activity, :results def initialize(character) @character = character @activity = character.activity @results = [] process end def process return unless @character.activity_time_remaining <= 0 if @character.resting? @results.replace([{ type: "error", message: "You can't do anything while you're resting." }]) @character.stop_activity return end unless @character.can_do_activity?(@activity) message = "You can't do this right now." message += " (requires #{@activity.requirements&.join(", ")})" if @activity.requirements.any? message += " (costs #{@activity.costs&.join(", ")})" if @activity.costs.any? @results.replace([{ type: "error", message: message }]) @character.stop_activity return end Character.transaction do if @character.rested_duration > 0 remaining_rested_duration = @character.rested_duration - @character.rested_duration_to_spend_on_activity @character.update!(rested_duration: remaining_rested_duration) end @character.pay_activity_cost @activity.whatnot[:results].each do |result| type = result[:type] case type when "xp" handle_xp_result(result) when "monster" raise TooManyWoundsError unless @character.can_fight? next if rand > (result[:chance] || 1) @results.push({ type: "br" }) if result[:table].pluck(:gid).include?(@character.trophy_monster_gid) monster = Monster.find_by_gid(@character.trophy_monster_gid) @results.push({ type: type, monster: monster }) resolve_combat_with(monster) break end table_roll = rand result[:table].sort_by { |t| -t[:score] }.each do |table_entry| score = table_entry[:score] if table_roll >= score monster = Monster.find_by_gid(table_entry[:gid]) @results.push({ type: type, monster: monster }) resolve_combat_with(monster) break end end when "item" handle_item_result(result, with_xp: true) when "hearth_amenity" bhi = @character.hearth.built_hearth_amenities .find_or_initialize_by(hearth_amenity: HearthAmenity.find_by_gid(result[:gid])) bhi.update(level: result[:level]) @results.push({ type: type, hearth_amenity: bhi.hearth_amenity }) when "hearth_planting" unless @character.hearth.available_planting_spots > 0 @results.replace([{ type: "error", message: "You're out of space to plant seeds." }]) @character.stop_activity return end item = Item.find_by_gid(result[:gid]) hp = @character.hearth.hearth_plantings.create(item: item) @results.push({ type: type, hearth_planting: hp }) when "activity" next if rand > (result[:chance] || 1) table_roll = rand result[:table].sort_by { |t| -t[:score] }.each do |table_entry| score = table_entry[:score] if table_roll >= score new_activity = Activity.find_by_gid(table_entry[:gid]) unless @character.learned_activities.exists?(activity: new_activity) @character.learned_activities.create(activity: new_activity) @results.push({ type: type, activity: new_activity }) end end end else raise "Invalid result type (#{type})" # TODO: Improve this. end end if @character.activity && @character.queued_actions if @character.queued_actions > 0 @character.queued_actions -= 1 @character.activity_started_at = Time.now @character.save else @character.stop_activity @results.push({ type: "message", body: "You have finished your work." }) return end else @character.update(activity_started_at: Time.now) end unless @results.any? @results.push({ type: "message", body: "You come up empty." }) end # HACK: To display any titles that were gained indirectly (not as part of the activity results). @character.title_awards.where(created_at: 5.seconds.ago..).each do |title_award| @results.push({ type: "title", title: title_award.title }) end end rescue ItemQuantityError @character.stop_activity @results.replace([{ type: "error", message: "You don't have enough items to complete this activity." }]) rescue HearthPlantingError @character.stop_activity @results.replace([{ type: "error", message: "You don't have that crop planted." }]) rescue TooManyWoundsError @character.stop_activity @results.replace([{ type: "error", message: "You can't fight in your condition. You'll have to heal a wound." }]) end private def roll(sides) rand(sides) + 1 end def give_item(data, quantity, with_xp: false) item = Item.find_by_gid(data[:gid]) xp_awards = [] if with_xp xp_awards = data[:xp]&.map { |xpe| { skill: Skill.find_by_gid(xpe[:gid]), amount: xpe[:value] } } xp_awards&.each do |award| @character.add_skill_xp(award[:skill], (award[:amount] * quantity)) end end @character.shift_item(item, quantity) @results.push({ type: "item", item: item, quantity: quantity, xp: xp_awards }) end def handle_title_result(data) if @character.award_title(data[:gid]) @results.push({ type: "title", title: title }) end end def handle_xp_result(data) skill = Skill.find_by_gid(data[:skill]) amount = data[:base] @character.add_skill_xp(skill, amount) @results.push({ type: "xp", skill: skill, xp: amount }) end def handle_item_result(data, with_xp: false) return if rand > (data[:chance] || 1) if data[:table] table_roll = rand data[:table].sort_by { |t| -t[:score] }.each do |table_entry| min_quantity = table_entry[:min_quantity] || table_entry[:quantity] || 1 max_quantity = table_entry[:max_quantity] || table_entry[:quantity] || 1 quantity = rand(min_quantity..max_quantity) score = table_entry[:score] if table_roll >= score give_item(table_entry, quantity) table_entry[:titles]&.each do |title_data| handle_title_result(title_data) end break end end else min_quantity = data[:min_quantity] || data[:quantity] || 1 max_quantity = data[:max_quantity] || data[:quantity] || 1 quantity = rand(min_quantity..max_quantity) give_item(data, quantity, with_xp: with_xp) end end def resolve_combat_with(mon) char = @character char_hp = @character.max_hp mon_hp = mon.max_hp combat_message = ->(msg) { @results.push({ type: "message", body: "[#{char_hp}/#{char.max_hp}] #{msg}" }) } char_initiative = roll(20) + char.speed mon_initative = roll(20) + mon.speed if char_initiative > mon_initative turn_order = [[char, mon], [mon, char]] elsif mon_initative > char_initiative turn_order = [[mon, char], [char, mon]] else turn_order = [[char, mon], [mon, char]].shuffle end turn_order.cycle do |actor, target| base_accuracy_roll = roll(20) accuracy_roll = base_accuracy_roll + actor.accuracy(with_combat_style: true) evasion_roll = roll(20) + target.evasion(with_combat_style: true) if accuracy_roll >= evasion_roll dealt_damage = roll(4) + actor.power(with_combat_style: true) # TODO: Replace d4 with weapon damage if base_accuracy_roll == 20 combat_message.call("#{actor.name} landed a critical hit!") dealt_damage = dealt_damage * 2 end blocked_damage = (accuracy_roll >= (roll(20) + target.block(with_combat_style: true))) ? 0 : target.block_value blocked_damage = [blocked_damage, (dealt_damage - 1)].min resolved_damage = dealt_damage - blocked_damage actor == char ? mon_hp -= resolved_damage : char_hp -= resolved_damage damage_text = "#{resolved_damage} damage." damage_text += " (#{dealt_damage} - #{blocked_damage} blocked)" if blocked_damage > 0 combat_message.call("#{actor.name} hit for #{damage_text}") elsif evasion_roll > accuracy_roll combat_message.call("#{target.name} evaded #{actor.name}'s attack.") end if char_hp < 1 || mon_hp < 1 if char_hp < 1 @results.push({ type: "message", body: "You were defeated! You retreat, wounded." }) char.wounds += 1 char.save! unless char.can_fight? @results.push({ type: "error", message: "You can't fight in your condition. You'll have to heal a wound." }) char.stop_activity return end else @results.push({ type: "message", body: "You slew the #{mon.name}." }) mon.whatnot[:awards]&.each do |award_data| case award_data[:type] when "title" handle_title_result(award_data) when "xp" handle_xp_result(award_data) when "item" handle_item_result(award_data) else raise "Invalid award type string (#{award_data[:type]})" end end end @character.do_equipment_break_checks.each do |broken_item| @results.push({ type: "warning", message: "Your #{broken_item.name} was damaged beyond repair!" }) end break end end end end