class ActivityProcessor attr_reader :character, :activity, :results def initialize(character) @character = character @activity = character.activity @results = [] process end def process return unless @character.activity && @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" puts "Result: #{result}" handle_xp_result(result) when "monster_spawn" raise TooManyWoundsError unless @character.can_fight? next if rand > (result[:chance] || 1) @results.push({ type: "br" }) monster_spawn = MonsterSpawn.where(location: Location.find_by_gid(result[:location])).select(&:alive?).first raise MonsterSpawnError unless monster_spawn @results.push({ type: type, monster_spawn: monster_spawn }) resolve_combat_with(monster_spawn) break 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 "condition" Character.transaction do condition = Condition.find_by_gid(result[:gid]) @character.states.create!(condition: condition, expires_at: Time.now + result[:duration]) @results.push({ type: "message", body: result[:message] }) @results.push({ type: type, condition: condition }) end 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]) raise "Invalid activity gid (#{table_entry[:gid]})" unless new_activity 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 # Note: This will result in equipment being checked for breakage twice (after combat, and now) if it provides the # `beastslay_speed` stat. At the time of this writing, that stat doesn't exist. # But just something to keep in mind. # Note: This will result in equipment being checked twice if it provides two speed stats. # Fine for now since no equipment gives two skill speed stats, but may want to refine in the future. if @activity.whatnot[:requirements].any? required_skill_gids = @activity.whatnot[:requirements].select { |r| r[:type] == "skill" }.map { |r| r[:gid] }.uniq required_skill_gids.each do |required_skill_gid| skill = Skill.find_by_gid(required_skill_gid) @character.do_skill_based_equipment_break_checks(skill).each do |broken_item| @results.push({ type: "warning", message: "Your #{broken_item.name} was damaged beyond repair!" }) end @character.do_skill_based_item_infix_break_checks(skill).each do |broken_item| @results.push({ type: "warning", message: "Your #{broken_item.name} omen faded away." }) end 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." }]) rescue MonsterSpawnError @character.stop_activity @results.replace([{ type: "error", message: "There are no living leviathans here." }]) 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) title = Title.find_by_gid(data[:gid]) if @character.award_title(title) @results.push({ type: "title", title: title }) end end def handle_xp_result(data) skill = Skill.find_by_gid(data[:gid]) 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, with_xp: with_xp) 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) monster_spawn = nil if mon.is_a? MonsterSpawn monster_spawn = mon mon = monster_spawn.monster end char = @character char_hp = @character.max_hp mon_hp = monster_spawn.present? ? monster_spawn.remaining_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) if actor == mon accuracy_roll += char.total_enemy_stat_change("accuracy") end evasion_roll = roll(20) + target.evasion(with_combat_style: true) if accuracy_roll >= evasion_roll || base_accuracy_roll == 20 dealt_damage = {} actor.damage_ranges.each do |data| damage_roll = rand(data[:min]..data[:max]) dealt_damage[data[:gid]] = damage_roll end # If you can't do damage any other way, hit 'em with your fists! if actor.damage_ranges.none? dealt_damage["bash"] = rand(1..2) combat_message.call("Lacking any other weapon, #{actor.name} thrashes wildly!") end if base_accuracy_roll == 20 combat_message.call("#{actor.name} landed a critical hit!") dealt_damage.each do |gid, amount| dealt_damage[gid] = amount * 2 end end # Apply power to random damages actor.power.times do dealt_damage[dealt_damage.keys.sample] += 1 end resolved_damage = {} dealt_damage.each do |gid, amount| effective_resistance = [target.resistance(gid), amount].min resolved_damage[gid] = amount - (effective_resistance * rand(0.5..1)).round end total_damage = resolved_damage.values.sum actor == char ? mon_hp -= total_damage : char_hp -= total_damage damage_text = "#{total_damage} damage (#{resolved_damage.to_a.map { |gid, amount| "#{amount} #{gid}" }.join(", ")})" 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 # HACK: DoT is char-only, and one-damage-type-only for now. if actor == char actor.dots.each do |data| damage_roll = rand(data[:min]..data[:max]) effective_resistance = [target.resistance(data[:gid]), damage_roll].min resolved_damage = damage_roll - (effective_resistance * rand(0.5..1)).round mon_hp -= resolved_damage combat_message.call("#{data[:message]} (#{resolved_damage} #{data[:gid]})") end end if char_hp < 1 || mon_hp < 1 @character.do_equipment_break_checks.each do |broken_item| @results.push({ type: "warning", message: "Your #{broken_item.name} was damaged beyond repair!" }) end if monster_spawn hp_lost = monster_spawn.remaining_hp - mon_hp if hp_lost > 0 monster_spawn.monster_spawn_combats.create(monster_spawn: monster_spawn, character: char, hp_lost: monster_spawn.remaining_hp - mon_hp) end end 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}." }) monster_kills = character.monster_kills.find_or_initialize_by(monster: mon) monster_kills.quantity ? monster_kills.quantity += 1 : monster_kills.quantity = 1 monster_kills.save if monster_spawn char.stop_activity return else 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 end break end end end end