From 83921e7293ee22759d145b19dde91c5bb102d379 Mon Sep 17 00:00:00 2001 From: Jen Heilemann Date: Wed, 17 Oct 2018 16:49:33 -0400 Subject: [PATCH] Create a starter kit for Ruby --- starter_kits/Ruby/.gitignore | 2 + starter_kits/Ruby/MyBot.rb | 77 ++++++++++++++++ starter_kits/Ruby/hlt/cell.rb | 47 ++++++++++ starter_kits/Ruby/hlt/commands.rb | 12 +++ starter_kits/Ruby/hlt/constants.rb | 47 ++++++++++ starter_kits/Ruby/hlt/direction.rb | 57 ++++++++++++ starter_kits/Ruby/hlt/dropoff.rb | 6 ++ starter_kits/Ruby/hlt/entity.rb | 25 ++++++ starter_kits/Ruby/hlt/game.rb | 85 ++++++++++++++++++ starter_kits/Ruby/hlt/map.rb | 138 +++++++++++++++++++++++++++++ starter_kits/Ruby/hlt/player.rb | 80 +++++++++++++++++ starter_kits/Ruby/hlt/position.rb | 45 ++++++++++ starter_kits/Ruby/hlt/ship.rb | 54 +++++++++++ starter_kits/Ruby/hlt/shipyard.rb | 11 +++ starter_kits/Ruby/run_game.bat | 1 + starter_kits/Ruby/run_game.sh | 3 + 16 files changed, 690 insertions(+) create mode 100644 starter_kits/Ruby/.gitignore create mode 100644 starter_kits/Ruby/MyBot.rb create mode 100644 starter_kits/Ruby/hlt/cell.rb create mode 100644 starter_kits/Ruby/hlt/commands.rb create mode 100644 starter_kits/Ruby/hlt/constants.rb create mode 100644 starter_kits/Ruby/hlt/direction.rb create mode 100644 starter_kits/Ruby/hlt/dropoff.rb create mode 100644 starter_kits/Ruby/hlt/entity.rb create mode 100644 starter_kits/Ruby/hlt/game.rb create mode 100644 starter_kits/Ruby/hlt/map.rb create mode 100644 starter_kits/Ruby/hlt/player.rb create mode 100644 starter_kits/Ruby/hlt/position.rb create mode 100644 starter_kits/Ruby/hlt/ship.rb create mode 100644 starter_kits/Ruby/hlt/shipyard.rb create mode 100644 starter_kits/Ruby/run_game.bat create mode 100644 starter_kits/Ruby/run_game.sh diff --git a/starter_kits/Ruby/.gitignore b/starter_kits/Ruby/.gitignore new file mode 100644 index 000000000..4f695f6b9 --- /dev/null +++ b/starter_kits/Ruby/.gitignore @@ -0,0 +1,2 @@ +*.hlt +*.log diff --git a/starter_kits/Ruby/MyBot.rb b/starter_kits/Ruby/MyBot.rb new file mode 100644 index 000000000..a6728a0e9 --- /dev/null +++ b/starter_kits/Ruby/MyBot.rb @@ -0,0 +1,77 @@ + +# Welcome to your first Halite-III bot! +# +# This bot's purpose is simple (don't expect it to win complex games!): +# 1. Initialize game +# 2. If a ship is full or the current ship position has less than 10% halite, +# move in a random direction. Otherwise, collect halite. +# 3. Try to spawn a ship at the shipyard. + +# Load the files we need +$:.unshift(File.dirname(__FILE__) + "/hlt") +require 'game' +require 'logger' + +# Our bot is named... +bot_name = "MyRubyBot" + +# Set up a logger +LOGGER = Logger.new("#{bot_name}_#{Time.now.to_f}.log").tap do |l| + l.formatter = proc do |severity, datetime, progname, msg| + "#{datetime.strftime("%m-%d %H:%M:%S.%3N")} - #{severity} - #{msg}\n" + end +end + +######## Game Begin ######## + +# This game object contains the initial game state. +game = Game.new(bot_name) +# At this point "game" variable is populated with initial map data. +# This is a good place to do computationally expensive start-up pre-processing. + +# As soon as you call "ready" function below, the 2 second per turn timer will +# start. +game.ready + +# Now that your bot is initialized, save a message to yourself in the log file +# with some important information. +# Here, you log here your id, which you can always fetch from the game object +# by using my_id. +LOGGER.info("Successfully created bot! My Player ID is #{game.my_id}.") + +######## Game Loop ######## + +while true + # This loop handles each turn of the game. The game object changes every turn, + # and you refresh that state by running update_frame(). + game.update_frame + # Extract player metadata and the updated map metadata here for convenience. + me = game.me + map = game.map + + # A command queue holds all the commands you will run this turn. + # You build this list up and submit it at the end of the turn. + command_queue = [] + + for ship in me.ships + # For each of your ships, move randomly if the ship is on a low halite + # location or the ship is full. + # Else, stay still & collect halite. + if map[ship.position].halite_amount < Constants::MAX_HALITE / 10 || ship.is_full? + command_queue << ship.move( Direction.all_cardinals.sample ) + else + command_queue << ship.stay_still + end + end + + # If the game is in the first 200 turns and you have enough halite, spawn a + # ship. Don't spawn a ship if you currently have a ship at port, though - + # the ships will collide. + if game.turn_number <= 200 && me.halite_amount >= Constants::SHIP_COST && + !map[me.shipyard].is_occupied? + command_queue << me.shipyard.spawn + end + + # Send your moves back to the game environment, ending this turn. + game.end_turn(command_queue) +end diff --git a/starter_kits/Ruby/hlt/cell.rb b/starter_kits/Ruby/hlt/cell.rb new file mode 100644 index 000000000..06dff2ecf --- /dev/null +++ b/starter_kits/Ruby/hlt/cell.rb @@ -0,0 +1,47 @@ + +# A cell on the game map. +class Cell + attr_reader :position + attr_accessor :structure, :halite_amount, :ship + + def initialize(position, halite_amount) + @position = position + @halite_amount = halite_amount + @ship = nil + @structure = nil + end + + # :return: Whether this cell has no ships or structures + def is_empty? + return @ship == nil && @structure == nil + end + + # :return: Whether this cell has any ships + def is_occupied? + return @ship != nil + end + + # :return: Whether this cell has any structures + def has_structure? + return @structure != nil + end + + # :return: What is the structure type in this cell + def structure_type + return !@structure ? nil : @structure.class + end + + # Mark this cell as unsafe (occupied) for navigation. + # Use in conjunction with GameMap.naive_navigate. + def mark_unsafe(ship) + @ship = ship + end + + def ==(other) + return @position == other.position + end + + def to_s + return "Cell(#{@position}, halite=#{@halite_amount})" + end +end diff --git a/starter_kits/Ruby/hlt/commands.rb b/starter_kits/Ruby/hlt/commands.rb new file mode 100644 index 000000000..41c0faf41 --- /dev/null +++ b/starter_kits/Ruby/hlt/commands.rb @@ -0,0 +1,12 @@ +# All viable commands that can be sent to the engine + +module Commands + NORTH = 'n' + SOUTH = 's' + EAST = 'e' + WEST = 'w' + STAY_STILL = 'o' + GENERATE = 'g' + CONSTRUCT = 'c' + MOVE = 'm' +end diff --git a/starter_kits/Ruby/hlt/constants.rb b/starter_kits/Ruby/hlt/constants.rb new file mode 100644 index 000000000..7ad1aabaa --- /dev/null +++ b/starter_kits/Ruby/hlt/constants.rb @@ -0,0 +1,47 @@ +# The constants representing the game variation being played. +# They come from game engine and changing them has no effect. +# They are strictly informational. + +module Constants + # Load constants from JSON given by the game engine. + def self.load_constants(constants) + # The cost to build a single ship. + const_set("SHIP_COST", constants['NEW_ENTITY_ENERGY_COST']) + + # The cost to build a dropoff. + const_set("DROPOFF_COST", constants['DROPOFF_COST']) + + # The maximum amount of halite a ship can carry. + const_set("MAX_HALITE", constants['MAX_ENERGY']) + + # The maximum number of turns a game can last. This reflects the fact + # that smaller maps play for fewer turns. + const_set("MAX_TURNS", constants['MAX_TURNS']) + + # 1/EXTRACT_RATIO halite (truncated) is collected from a square per turn. + const_set("EXTRACT_RATIO", constants['EXTRACT_RATIO']) + + # 1/MOVE_COST_RATIO halite (truncated) is needed to move off a cell. + const_set("MOVE_COST_RATIO", constants['MOVE_COST_RATIO']) + + # Whether inspiration is enabled. + const_set("INSPIRATION_ENABLED", constants['INSPIRATION_ENABLED']) + + # A ship is inspired if at least INSPIRATION_SHIP_COUNT opponent + # ships are within this Manhattan distance. + const_set("INSPIRATION_RADIUS", constants['INSPIRATION_RADIUS']) + + # A ship is inspired if at least this many opponent ships are within + # INSPIRATION_RADIUS distance. + const_set("INSPIRATION_SHIP_COUNT", constants['INSPIRATION_SHIP_COUNT']) + + # An inspired ship mines 1/X halite from a cell per turn instead. + const_set("INSPIRED_EXTRACT_RATIO", constants['INSPIRED_EXTRACT_RATIO']) + + # An inspired ship that removes Y halite from a cell collects X*Y additional halite. + const_set("INSPIRED_BONUS_MULTIPLIER", constants['INSPIRED_BONUS_MULTIPLIER']) + + # An inspired ship instead spends 1/X% halite to move. + const_set("INSPIRED_MOVE_COST_RATIO", constants['INSPIRED_MOVE_COST_RATIO']) + end +end diff --git a/starter_kits/Ruby/hlt/direction.rb b/starter_kits/Ruby/hlt/direction.rb new file mode 100644 index 000000000..1cb085049 --- /dev/null +++ b/starter_kits/Ruby/hlt/direction.rb @@ -0,0 +1,57 @@ +require 'commands' + +# Holds positional arrays in relation to cardinal directions +module Direction + NORTH = [0, -1] + SOUTH = [0, 1] + EAST = [1, 0] + WEST = [-1, 0] + + STILL = [0, 0] + + # Returns all contained items in each cardinal + # :return: An array of cardinals + def self.all_cardinals + return [NORTH, SOUTH, EAST, WEST] + end + + # Converts from this direction tuple notation to the engine's string notation + # :param direction: the direction in this notation + # :return: The character equivalent for the game engine + def self.convert(direction) + case direction + when NORTH + return Commands::NORTH + when SOUTH + return Commands::SOUTH + when EAST + return Commands::EAST + when WEST + return Commands::WEST + when STILL + return Commands::STAY_STILL + else + raise IndexError + end + end + + # Returns the opposite cardinal direction given a direction + # :param direction: The input direction + # :return: The opposite direction + def self.invert(direction) + case direction + when NORTH + return SOUTH + when SOUTH + return NORTH + when EAST + return WEST + when WEST + return EAST + when STILL + return STILL + else + raise IndexError + end + end +end diff --git a/starter_kits/Ruby/hlt/dropoff.rb b/starter_kits/Ruby/hlt/dropoff.rb new file mode 100644 index 000000000..9f44e40c0 --- /dev/null +++ b/starter_kits/Ruby/hlt/dropoff.rb @@ -0,0 +1,6 @@ +require 'entity' + + +# Dropoff class for housing dropoffs +class Dropoff < Entity +end diff --git a/starter_kits/Ruby/hlt/entity.rb b/starter_kits/Ruby/hlt/entity.rb new file mode 100644 index 000000000..fea546e2b --- /dev/null +++ b/starter_kits/Ruby/hlt/entity.rb @@ -0,0 +1,25 @@ +require 'position' + +# Base Entity Class from whence Ships, Dropoffs and Shipyards inherit +class Entity + attr_reader :owner, :id, :position + + def initialize(owner, id, position) + @owner = owner + @id = id + @position = position + end + + # Method which creates an entity for a specific player given input from the engine. + # :param game: The game object for fetching information + # :param player_id: The player id for the player who owns this entity + # :return: An instance of Entity along with its id + def self.generate(game, player_id) + id, x, y = game.read_ints_from_input + self.new(player_id, id, Position.new(x, y)) + end + + def to_s + "#{self.class}(id=#{@id}, #{@position})" + end +end diff --git a/starter_kits/Ruby/hlt/game.rb b/starter_kits/Ruby/hlt/game.rb new file mode 100644 index 000000000..9a1c15764 --- /dev/null +++ b/starter_kits/Ruby/hlt/game.rb @@ -0,0 +1,85 @@ +require 'logger' +require 'json' +require 'constants' +require 'map' +require 'player' + +# The game object holds all metadata pertinent to the game and all its contents +class Game + attr_reader :map, :me, :turn_number, :my_id + + # Initiates a game object collecting all start-state instances for the contained items for pre-game. + # Also sets up basic logging. + def initialize(name) + @name = name + # implicit IO flush + $stdout.sync = true + + # Grab constants JSON + Constants.load_constants(JSON.parse(read_from_input)) + + @turn_number = 0 + @num_players, @my_id = read_ints_from_input + + @players = {} + @num_players.times do + player_id, player = Player.generate(read_ints_from_input) + @players[player_id] = player + end + + @me = @players[@my_id] + @map = Map.generate(self) + end + + # Indicate that your bot is ready to play. + def ready + end_turn([@name]) + end + + # Updates the game object's state. + # :returns: nothing. + def update_frame + @turn_number = read_ints_from_input.first + LOGGER.info("=============== TURN #{@turn_number} ================") + + @num_players.times do |_| + player, num_ships, num_dropoffs, halite = read_ints_from_input + @players[player].update(self, num_ships, num_dropoffs, halite) + end + + @map.update(self) + + + # Mark cells with ships as unsafe for navigation + for player in @players.values + for ship in player.ships + @map[ship.position].mark_unsafe(ship) + end + + @map[player.shipyard.position].structure = player.shipyard + for dropoff in player.dropoffs + @map[dropoff.position].structure = dropoff + end + end + end + + def end_turn(commands) + write_to_output(commands.join(" ")) + end + + def read_from_input + $stdin.gets.strip + end + + def read_ints_from_input + read_from_input.split(' ').map { |v| Integer(v) } + end + + private + + def write_to_output(data) + data = "#{data.strip}" + LOGGER.info("Sending: #{data.inspect}") + $stdout.puts(data) + end +end diff --git a/starter_kits/Ruby/hlt/map.rb b/starter_kits/Ruby/hlt/map.rb new file mode 100644 index 000000000..3dd9a6e00 --- /dev/null +++ b/starter_kits/Ruby/hlt/map.rb @@ -0,0 +1,138 @@ +require 'direction' +require 'position' +require 'cell' + + +# The game map. +# Can be indexed by a position, or by a contained entity. +# Coordinates start at 0. Coordinates are normalized for you. +class Map + + def initialize(cells, width, height) + @width = width + @height = height + @cells = cells + end + + # Getter for position object or entity objects within the game map + # :param location: the position or entity to access in this map + # :return: the contents housing that cell or entity + def [](location) + if location.is_a? Position + location = normalize(location) + return @cells[location.y][location.x] + elsif location.is_a? Entity + return @cells[location.position.y][location.position.x] + end + return nil + end + + # Compute the Manhattan distance between two locations. + # Accounts for wrap-around. + # :param source: The source from where to calculate + # :param target: The target to where calculate + # :return: The distance between these items + def calculate_distance(source, target) + source = normalize(source) + target = normalize(target) + resulting_position = (source - target).abs + return min(resulting_position.x, @width - resulting_position.x) + + min(resulting_position.y, @height - resulting_position.y) + end + + # Normalize the position within the bounds of the toroidal map. + # i.e.: Takes a point which may or may not be within width and + # height bounds, and places it within those bounds considering + # wraparound. + # :param position: A position object. + # :return: A normalized position object fitting within the bounds of the map + def normalize(position) + Position.new(position.x % @width, position.y % @height) + end + + # Return the Direction(s) to move closer to the target point, or empty if the + # points are the same. + # This move mechanic does not account for collisions. The multiple + # directions are if both directional movements are viable. + # :param source: The starting position + # :param destination: The destination towards which you wish to move your object. + # :return: A list of valid (closest) Directions towards your target. + def get_unsafe_moves(source, destination) + source = normalize(source) + destination = normalize(destination) + possible_moves = [] + distance = (destination - source).abs + y_cardinality, x_cardinality = self.class.get_target_direction(source, destination) + + if distance.x != 0 + possible_moves.append(distance.x < (@width / 2) ? x_cardinality : + Direction.invert(x_cardinality)) + end + if distance.y != 0 + possible_moves.append(distance.y < (@height / 2) ? y_cardinality : + Direction.invert(y_cardinality)) + end + return possible_moves + end + + # Returns a singular safe move towards the destination. + # :param ship: The ship to move. + # :param destination: Ending position + # :return: A direction. + def naive_navigate(ship, destination) + # No need to normalize destination, since get_unsafe_moves does that + for direction in get_unsafe_moves(ship.position, destination) + target_pos = ship.position.directional_offset(direction) + if !self[target_pos].is_occupied? + self[target_pos].mark_unsafe(ship) + return direction + end + end + + return Direction.Still + end + + # Updates this map object from the input given by the game engine + # :return: nothing + def update(game) + # Mark cells as safe for navigation (will re-mark unsafe cells later) + for y in 0...@height + for x in 0...@width + self[Position.new(x, y)].ship = nil + end + end + + game.read_ints_from_input.first.times do + cell_x, cell_y, cell_energy = game.read_ints_from_input + self[Position.new(cell_x, cell_y)].halite_amount = cell_energy + end + end + + # Creates a map object from the input given by the game engine + # :return: The map object + def self.generate(game) + map_width, map_height = game.read_ints_from_input + game_map = [] + for y_pos in 0...map_height + game_map[y_pos] = [] + cells = game.read_ints_from_input + for x_pos in 0...map_width + game_map[y_pos][x_pos] = Cell.new(Position.new(x_pos, y_pos), cells[x_pos]) + end + end + return Map.new(game_map, map_width, map_height) + end + + # Returns where in the cardinality spectrum the target is from source. + # e.g.: North, East; South, West; etc. + # NOTE: Ignores toroid + # :param source: The source position + # :param target: The target position + # :return: An array containing the target Direction(s). Either item (or both) + # could be nil if within same coords + def self.get_target_direction(source, target) + y_cardinality = target.y > source.y ? Direction::SOUTH : (target.y < source.y ? Direction::NORTH : nil) + x_cardinality = target.x > source.x ? Direction::EAST : (target.x < source.x ? Direction::WEST : nil) + return [y_cardinality, x_cardinality] + end +end diff --git a/starter_kits/Ruby/hlt/player.rb b/starter_kits/Ruby/hlt/player.rb new file mode 100644 index 000000000..92d4abecc --- /dev/null +++ b/starter_kits/Ruby/hlt/player.rb @@ -0,0 +1,80 @@ +require 'ship' +require 'dropoff' +require 'shipyard' +require 'position' + +# Player object containing all items/metadata pertinent to the player. +class Player + attr_reader :id, :shipyard, :halite_amount + + def initialize(player_id, shipyard, halite=0) + @id = player_id + @shipyard = shipyard + @halite_amount = halite + @ships = {} + @dropoffs = {} + end + + # Returns a singular ship mapped by the ship id + # :param ship_id: The ship id of the ship you wish to return + # :return: the ship object. + def ship(ship_id) + @ships[ship_id] + end + + # :return: Returns all ship objects in a list + def ships + @ships.values + end + + # Returns a singular dropoff mapped by its id + # :param dropoff_id: The dropoff id to return + # :return: The dropoff object + def dropoff(dropoff_id) + @dropoffs[dropoff_id] + end + + # :return: Returns all dropoff objects in a list + def dropoffs + @dropoffs.values + end + + # Check whether the player has a ship with a given ID. + # Useful if you track ships via IDs elsewhere and want to make + # sure the ship still exists. + # :param ship_id: The ID to check. + # :return: True if and only if the ship exists. + def has_ship?(ship_id) + ship(ship_id) != nil + end + + # Updates this player object considering the input from the game engine for + # the current specific turn. + # :param game: The game object for extracting ship/dropoff information + # :param num_ships: The number of ships this player has this turn + # :param num_dropoffs: The number of dropoffs this player has this turn + # :param halite: How much halite the player has in total + # :return: nothing. + def update(game, num_ships, num_dropoffs, halite) + @halite_amount = halite + + @ships = {} + num_ships.times do + ship = Ship.generate(game, @id) + @ships[ship.id] = ship + end + + @dropoffs = {} + num_dropoffs.times do + dropoff = Dropoff.generate(game, @id) + @dropoffs[dropoff.id] = dropoff + end + end + + # Creates a player object from the input given by the game engine + # :return: The player object + def self.generate(inputs) + id, x, y = inputs + return id, Player.new(id, Shipyard.new(id, -1, Position.new(x, y))) + end +end diff --git a/starter_kits/Ruby/hlt/position.rb b/starter_kits/Ruby/hlt/position.rb new file mode 100644 index 000000000..c34aa2b23 --- /dev/null +++ b/starter_kits/Ruby/hlt/position.rb @@ -0,0 +1,45 @@ +require 'direction' + +class Position + attr_reader :x, :y + + def initialize(x, y) + @x = x + @y = y + end + + # Returns the position considering a Direction cardinal tuple + # :param direction: the direction cardinal array + # :return: a new position moved in that direction + def directional_offset(direction) + return self + Position.new(*direction) + end + + # :return: Returns a list of all positions around this specific position in + # each cardinal direction + def get_surrounding_cardinals + Direction.all_cardinals.map do |direction| + directional_offset(direction) + end + end + + def +(other) + Position.new(@x + other.x, @y + other.y) + end + + def -(other) + Position.new(@x - other.x, @y - other.y) + end + + def abs + Position.new(@x.abs, @y.abs) + end + + def ==(other) + return @x == other.x && @y == other.y + end + + def to_s + "Position (#{@x}, #{@y})" + end +end diff --git a/starter_kits/Ruby/hlt/ship.rb b/starter_kits/Ruby/hlt/ship.rb new file mode 100644 index 000000000..e0ed0b0f6 --- /dev/null +++ b/starter_kits/Ruby/hlt/ship.rb @@ -0,0 +1,54 @@ +require 'entity' +require 'commands' +require 'constants' +require 'direction' +require 'position' + + +# Ship class to house ship entities +class Ship < Entity + attr_reader :halite_amount + + def initialize(owner, id, position, halite_amount) + super(owner, id, position) + @halite_amount = halite_amount + end + + # Is this ship at max halite capacity? + def is_full? + @halite_amount >= Constants::MAX_HALITE + end + + # Return a move to transform this ship into a dropoff. + def make_dropoff + "#{Commands::CONSTRUCT} #{@id}" + end + + # Return a move to move this ship in a direction without + # checking for collisions. + def move(direction) + raw_direction = direction + if !direction.is_a?(String) || !"nsewo".include?(direction) + raw_direction = Direction.convert(direction) + end + "#{Commands::MOVE} #{@id} #{raw_direction}" + end + + # Don't move this ship. + def stay_still + "#{Commands::MOVE} #{@id} #{Commands::STAY_STILL}" + end + + # Creates an instance of a ship for a given player given the engine's input. + # :param player_id: The id of the player who owns this ship + # :param player_id: The id of the player who owns this ship + # :return: The ship id and ship object + def self.generate(game, player_id) + id, x, y, halite = game.read_ints_from_input + Ship.new(player_id, id, Position.new(x, y), halite) + end + + def to_s + "#{self.class}(id=#{@id}, #{@position}, cargo=#{@halite_amount} halite)" + end +end diff --git a/starter_kits/Ruby/hlt/shipyard.rb b/starter_kits/Ruby/hlt/shipyard.rb new file mode 100644 index 000000000..ad21000b0 --- /dev/null +++ b/starter_kits/Ruby/hlt/shipyard.rb @@ -0,0 +1,11 @@ +require 'entity' +require 'commands' + + +# Shipyard class to house shipyards +class Shipyard < Entity + # Return a move to spawn a new ship. + def spawn + Commands::GENERATE + end +end diff --git a/starter_kits/Ruby/run_game.bat b/starter_kits/Ruby/run_game.bat new file mode 100644 index 000000000..a79b851eb --- /dev/null +++ b/starter_kits/Ruby/run_game.bat @@ -0,0 +1 @@ +halite.exe --replay-directory replays/ -vvv --width 32 --height 32 "ruby MyBot.rb" "ruby MyBot.rb" diff --git a/starter_kits/Ruby/run_game.sh b/starter_kits/Ruby/run_game.sh new file mode 100644 index 000000000..aa9f8e584 --- /dev/null +++ b/starter_kits/Ruby/run_game.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +./halite --replay-directory replays/ -v --width 32 --height 32 "ruby MyBot.rb" "ruby MyBot.rb"