robot_wars 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +7 -0
  3. data/.loki +10 -0
  4. data/.rubocop.yml +1 -0
  5. data/CHANGELOG.md +17 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +145 -0
  8. data/RULES.md +121 -0
  9. data/Rakefile +23 -0
  10. data/docs/game_board.svg +88 -0
  11. data/examples/01_random_match.rb +53 -0
  12. data/examples/warriors/opportunist.md +18 -0
  13. data/examples/warriors/oppressor.md +17 -0
  14. data/examples/warriors/sentinel.md +18 -0
  15. data/examples/warriors/wanderer.md +19 -0
  16. data/examples/warriors/warmonger.md +17 -0
  17. data/lib/robot_wars/action.rb +66 -0
  18. data/lib/robot_wars/action_parser.rb +66 -0
  19. data/lib/robot_wars/board.rb +47 -0
  20. data/lib/robot_wars/conflict_resolver.rb +32 -0
  21. data/lib/robot_wars/direction.rb +30 -0
  22. data/lib/robot_wars/fixed_roll_generator.rb +16 -0
  23. data/lib/robot_wars/game.rb +79 -0
  24. data/lib/robot_wars/game_rules.md +44 -0
  25. data/lib/robot_wars/game_rules.rb +12 -0
  26. data/lib/robot_wars/illegal_move_resolver.rb +18 -0
  27. data/lib/robot_wars/llm_pilot.rb +17 -0
  28. data/lib/robot_wars/model_spec.rb +39 -0
  29. data/lib/robot_wars/move_resolver.rb +30 -0
  30. data/lib/robot_wars/occupancy_map.rb +45 -0
  31. data/lib/robot_wars/position.rb +13 -0
  32. data/lib/robot_wars/ranged_combat_resolver.rb +59 -0
  33. data/lib/robot_wars/robot.rb +49 -0
  34. data/lib/robot_wars/roll_generator.rb +15 -0
  35. data/lib/robot_wars/sensing_report.rb +61 -0
  36. data/lib/robot_wars/territory.rb +75 -0
  37. data/lib/robot_wars/turn_resolver.rb +265 -0
  38. data/lib/robot_wars/version.rb +3 -0
  39. data/lib/robot_wars/warrior.rb +22 -0
  40. data/lib/robot_wars.rb +29 -0
  41. data/sig/robot_wars.rbs +6 -0
  42. metadata +100 -0
@@ -0,0 +1,66 @@
1
+ module RobotWars
2
+ # Parses a pilot's free-text reply into an Action, per the grammar a
3
+ # warrior's brain is instructed to answer in:
4
+ #
5
+ # STAY
6
+ # MOVE <north|northeast|east|southeast|south|southwest|west|northwest>
7
+ # ATTACK <x>,<y> <points>
8
+ # DEFEND <points>
9
+ #
10
+ # Anything that doesn't match becomes Action.invalid (RULES.md 21's
11
+ # solo-conflict penalty, via TurnResolver) rather than raising — a
12
+ # pilot's bad reply is a turn's mistake, not a crash.
13
+ # :reek:RepeatedConditional -- each parse_* method guards its own independent regex match; they only share a name.
14
+ class ActionParser
15
+ DIRECTIONS = {
16
+ "north" => Direction::NORTH, "northeast" => Direction::NORTHEAST,
17
+ "east" => Direction::EAST, "southeast" => Direction::SOUTHEAST,
18
+ "south" => Direction::SOUTH, "southwest" => Direction::SOUTHWEST,
19
+ "west" => Direction::WEST, "northwest" => Direction::NORTHWEST
20
+ }.freeze
21
+
22
+ MOVE_PATTERN = /\bMOVE\s+(\w+)/i
23
+ ATTACK_PATTERN = /\bATTACK\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s+(\d+)/i
24
+ DEFEND_PATTERN = /\bDEFEND\s+(\d+)/i
25
+ STAY_PATTERN = /\bSTAY\b/i
26
+
27
+ def parse(text)
28
+ return Action.invalid if text.nil?
29
+
30
+ parse_move(text) || parse_attack(text) || parse_defend(text) || parse_stay(text) || Action.invalid
31
+ end
32
+
33
+ private
34
+
35
+ def parse_move(text)
36
+ match = MOVE_PATTERN.match(text)
37
+ return unless match
38
+
39
+ direction = DIRECTIONS[match[1].downcase]
40
+ direction && Action.move(direction)
41
+ end
42
+
43
+ def parse_attack(text)
44
+ match = ATTACK_PATTERN.match(text)
45
+ return unless match
46
+
47
+ points = match[3].to_i
48
+ return unless points.positive?
49
+
50
+ square = Position.new(x: match[1].to_i, y: match[2].to_i)
51
+ Action.attack(square: square, points: points)
52
+ end
53
+
54
+ def parse_defend(text)
55
+ match = DEFEND_PATTERN.match(text)
56
+ return unless match
57
+
58
+ points = match[1].to_i
59
+ points.positive? ? Action.defend(points: points) : nil
60
+ end
61
+
62
+ def parse_stay(text)
63
+ Action.stay if STAY_PATTERN.match?(text)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ module RobotWars
2
+ # Board geometry only (RULES.md 1: bounded board; 2: size set at game
3
+ # start). Holds no robots or ownership — see OccupancyMap and Territory.
4
+ class Board
5
+ attr_reader :width, :height
6
+
7
+ def initialize(width:, height:)
8
+ raise ArgumentError, "width must be positive" unless width.positive?
9
+ raise ArgumentError, "height must be positive" unless height.positive?
10
+
11
+ @width = width
12
+ @height = height
13
+ end
14
+
15
+ def on_board?(position)
16
+ position.x.between?(0, width - 1) && position.y.between?(0, height - 1)
17
+ end
18
+
19
+ def neighbors_of(position)
20
+ position.neighbors.select { |neighbor| on_board?(neighbor) }
21
+ end
22
+
23
+ # :reek:NestedIterators -- a 2D grid walk is inherently two loops deep.
24
+ # :reek:UncommunicativeVariableName -- x and y ARE the communicative names for grid coordinates.
25
+ def each_position
26
+ return enum_for(:each_position) unless block_given?
27
+
28
+ (0...width).each do |x|
29
+ (0...height).each { |y| yield Position.new(x: x, y: y) }
30
+ end
31
+ end
32
+
33
+ def random_position(random: Random.new)
34
+ Position.new(x: random.rand(width), y: random.rand(height))
35
+ end
36
+
37
+ # `count` distinct squares (RULES.md 6: no two robots share a
38
+ # starting square).
39
+ # :reek:FeatureEnvy -- `squares` is a local snapshot of this board's own positions, not another object's data.
40
+ def sample_positions(count, random: Random.new)
41
+ squares = each_position.to_a
42
+ raise ArgumentError, "cannot place #{count} robots on #{squares.size} squares" if count > squares.size
43
+
44
+ squares.sample(count, random: random)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ module RobotWars
2
+ # Resolves a square conflict (RULES.md 13-16): one shared roll damages
3
+ # every robot present, and the highest remaining life wins the square.
4
+ # A tie counts as a loss for everyone.
5
+ class ConflictResolver
6
+ Result = Data.define(:winner, :losers, :roll)
7
+
8
+ def initialize(roll_generator: RollGenerator.new)
9
+ @roll_generator = roll_generator
10
+ end
11
+
12
+ def resolve(robots)
13
+ raise ArgumentError, "a conflict needs at least 2 robots" if robots.size < 2
14
+
15
+ roll = @roll_generator.roll
16
+ robots.each { |robot| robot.apply_damage(roll) }
17
+
18
+ winner = winner_of(robots)
19
+ losers = robots - Array(winner)
20
+
21
+ Result.new(winner: winner, losers: losers, roll: roll)
22
+ end
23
+
24
+ private
25
+
26
+ def winner_of(robots)
27
+ highest_life = robots.map(&:life).max
28
+ contenders = robots.select { |robot| robot.life == highest_life }
29
+ contenders.one? ? contenders.first : nil
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,30 @@
1
+ module RobotWars
2
+ # The 8 one-square move directions (RULES.md 9: a robot moves like a
3
+ # chess king).
4
+ # :reek:TooManyConstants -- 8 compass points plus their two lookup tables; the compass isn't getting bigger.
5
+ module Direction
6
+ OFFSETS = [
7
+ Position.new(x: 0, y: -1),
8
+ Position.new(x: 1, y: -1),
9
+ Position.new(x: 1, y: 0),
10
+ Position.new(x: 1, y: 1),
11
+ Position.new(x: 0, y: 1),
12
+ Position.new(x: -1, y: 1),
13
+ Position.new(x: -1, y: 0),
14
+ Position.new(x: -1, y: -1)
15
+ ].freeze
16
+
17
+ NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST = OFFSETS
18
+
19
+ NAMES = {
20
+ NORTH => "north", NORTHEAST => "northeast",
21
+ EAST => "east", SOUTHEAST => "southeast",
22
+ SOUTH => "south", SOUTHWEST => "southwest",
23
+ WEST => "west", NORTHWEST => "northwest"
24
+ }.freeze
25
+
26
+ # The compass name of a direction offset, for showing a move to a
27
+ # human ("north"), or nil for a position that isn't a direction.
28
+ def self.name_of(offset) = NAMES[offset]
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ module RobotWars
2
+ # A queued, deterministic roll source for tests and seeded replays.
3
+ class FixedRollGenerator
4
+ Exhausted = Class.new(StandardError)
5
+
6
+ def initialize(rolls)
7
+ @rolls = rolls.dup
8
+ end
9
+
10
+ def roll
11
+ raise Exhausted, "no more fixed rolls queued" if @rolls.empty?
12
+
13
+ @rolls.shift
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,79 @@
1
+ module RobotWars
2
+ # A full match: setup, one turn at a time, until it ends (rule 39).
3
+ class Game
4
+ attr_reader :board, :occupancy, :territory, :turn_number
5
+
6
+ def initialize(board:, roll_generator: RollGenerator.new, random: Random.new)
7
+ @board = board
8
+ @occupancy = OccupancyMap.new
9
+ @territory = Territory.new
10
+ @robots = []
11
+ @turn_number = 0
12
+ @turn_resolver = TurnResolver.new(
13
+ board: board, occupancy: @occupancy, territory: @territory,
14
+ roll_generator: roll_generator, random: random
15
+ )
16
+ end
17
+
18
+ # Places robots on distinct random squares (rule 6), each starting
19
+ # with `life` points (rule 5's 100 unless the match says otherwise).
20
+ def self.start(board:, robot_ids:, life: Robot::STARTING_LIFE, roll_generator: RollGenerator.new, random: Random.new)
21
+ game = new(board: board, roll_generator: roll_generator, random: random)
22
+ positions = board.sample_positions(robot_ids.size, random: random)
23
+
24
+ robot_ids.zip(positions).each { |id, position| game.add_robot(Robot.new(id: id, life: life), position) }
25
+ game
26
+ end
27
+
28
+ def add_robot(robot, position)
29
+ @occupancy.place(robot, position)
30
+ @robots << robot
31
+ self
32
+ end
33
+
34
+ def robots
35
+ @robots.dup
36
+ end
37
+
38
+ def robot(id)
39
+ @robots.find { |robot| robot.id == id }
40
+ end
41
+
42
+ def alive_robots
43
+ @robots.select(&:alive?)
44
+ end
45
+
46
+ def over?
47
+ alive_robots.size <= 1
48
+ end
49
+
50
+ def winner
51
+ over? ? alive_robots.first : nil
52
+ end
53
+
54
+ def tie?
55
+ over? && alive_robots.empty?
56
+ end
57
+
58
+ # actions: Hash{Robot => Action}, exactly one entry per living robot.
59
+ def play_turn(actions)
60
+ raise ArgumentError, "the game is already over" if over?
61
+
62
+ validate_actions!(actions)
63
+ @turn_number += 1
64
+
65
+ report = @turn_resolver.resolve!(actions)
66
+ @robots -= report.deaths
67
+ report
68
+ end
69
+
70
+ private
71
+
72
+ def validate_actions!(actions)
73
+ provided = actions.keys
74
+ return if (alive_robots - provided).empty? && (provided - alive_robots).empty?
75
+
76
+ raise ArgumentError, "expected exactly one action per living robot"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,44 @@
1
+ You are a warrior in RobotWars — a turn-based, last-robot-standing
2
+ game on a bounded 2D grid.
3
+
4
+ Every turn you receive a sensing report: your position, your life
5
+ total, the full map of currently owned squares and who owns them, and
6
+ how many warriors remain. You can never see where any robot physically
7
+ is — only territory reveals anything about rivals, and only where it's
8
+ been claimed. If you attacked last turn, the report also tells you
9
+ whether that attack was a HIT (a robot was on the square) or a MISS
10
+ (the square was empty) — the one other clue you ever get about where
11
+ rivals are.
12
+
13
+ The rules, in brief:
14
+ - 8-way movement, one square per turn, like a chess king. Moving costs
15
+ 1 life; staying still gains 1 life.
16
+ - An illegal move — off the board, or onto a square someone else
17
+ owns — is punished with a random hit and no movement.
18
+ - Two ways to own a square: win a fight there, or occupy it for
19
+ 3 consecutive turns. A square you own is yours alone — no one else
20
+ may ever enter it while you hold it.
21
+ - Fighting for a contested square: a single shared random roll hits
22
+ everyone on it equally; whoever has the most life left afterward
23
+ wins and stays, everyone else is sent home (a tie sends everyone
24
+ home). Losing your way home can cascade into a second fight, and
25
+ losing twice in one turn gets you shoved onto a nearby empty
26
+ square — or killed outright if there isn't one.
27
+ - Ranged attack: commit up to your current life as an attack's
28
+ strength against ANY square on the board. If a robot is standing
29
+ there, it loses that full amount. Attacking is free UNLESS the
30
+ target had committed to DEFEND — then you take their full defended
31
+ amount back as counter-fire, regardless of how much damage you
32
+ dealt. You can never be sure a square is undefended.
33
+ - Defending: commits points that counter-hit every attacker that turn
34
+ for your full committed amount. If nobody attacks you, defending
35
+ still costs 1 life as a premium for bracing.
36
+ - You die at 0 life, or by being crushed with nowhere to stand. A dead
37
+ warrior's territory is released.
38
+
39
+ Respond with EXACTLY one line and nothing else, in one of these forms:
40
+
41
+ STAY
42
+ MOVE <north|northeast|east|southeast|south|southwest|west|northwest>
43
+ ATTACK <x>,<y> <points>
44
+ DEFEND <points>
@@ -0,0 +1,12 @@
1
+ module RobotWars
2
+ # The canonical rules-of-the-game recap, shipped with the gem
3
+ # (lib/robot_wars/game_rules.md) so every warrior everywhere plays by
4
+ # the same rules. bin/rwars hands it to each RobotLab robot as its
5
+ # system_prompt — warrior templates carry only their model choice and
6
+ # personality, never a rules retelling.
7
+ module GameRules
8
+ FILE = File.expand_path("game_rules.md", __dir__)
9
+
10
+ def self.text = File.read(FILE)
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ module RobotWars
2
+ # Resolves an illegal move attempt (RULES.md 21): a solo conflict
3
+ # against the board edge or a rival's owned square — a random hit,
4
+ # no movement.
5
+ class IllegalMoveResolver
6
+ Result = Data.define(:robot, :roll)
7
+
8
+ def initialize(roll_generator: RollGenerator.new)
9
+ @roll_generator = roll_generator
10
+ end
11
+
12
+ def resolve(robot)
13
+ roll = @roll_generator.roll
14
+ robot.apply_damage(roll)
15
+ Result.new(robot: robot, roll: roll)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ module RobotWars
2
+ # A warrior's brain: an LLM-backed decision-maker built from a human's
3
+ # prompt template. `llm_robot` is anything answering `#run(message)`
4
+ # with a result that answers `#reply` — a RobotLab::Robot in practice,
5
+ # but never required by name, so tests can hand it a plain double.
6
+ class LLMPilot
7
+ def initialize(llm_robot:, parser: ActionParser.new)
8
+ @llm_robot = llm_robot
9
+ @parser = parser
10
+ end
11
+
12
+ def decide(report)
13
+ reply = @llm_robot.run(report).reply
14
+ @parser.parse(reply)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ require "yaml"
2
+
3
+ module RobotWars
4
+ # A warrior's LLM designation: "<provider>/<model id>" — the pattern of
5
+ # the `model:` front matter field in warrior templates and of the rwars
6
+ # --model option. The first "/" splits the RubyLLM provider name from
7
+ # the model id (which may itself contain slashes, as OpenRouter-style
8
+ # ids do). A bare id with no "/" names no provider and leaves that
9
+ # choice to RubyLLM's model registry.
10
+ ModelSpec = Data.define(:provider, :model) do
11
+ # Parse a "<provider>/<model id>" string.
12
+ #
13
+ # @param text [String, nil]
14
+ # @return [ModelSpec, nil] nil when text is nil or blank
15
+ def self.parse(text)
16
+ text = text.to_s.strip
17
+ return nil if text.empty?
18
+
19
+ provider, _, model = text.partition("/")
20
+ return new(provider: nil, model: provider) if model.empty?
21
+ return new(provider: nil, model: model) if provider.empty?
22
+
23
+ new(provider: provider.to_sym, model: model)
24
+ end
25
+
26
+ # Read a warrior template's YAML front matter and parse its `model:`
27
+ # field.
28
+ #
29
+ # @param path [String] path to a warrior *.md template
30
+ # @return [ModelSpec, nil] nil when the template names no model
31
+ def self.from_template(path)
32
+ matched = File.read(path).match(/\A---\s*\n(.*?)\n---\s*(\n|\z)/m)
33
+ return nil unless matched
34
+
35
+ front_matter = YAML.safe_load(matched[1])
36
+ parse(front_matter.is_a?(Hash) ? front_matter["model"] : nil)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ module RobotWars
2
+ # Determines a move's destination and legality (RULES.md 9 and 21).
3
+ # Board boundaries and rival ownership make a destination illegal;
4
+ # another robot simply standing there does not — that's a conflict,
5
+ # not an illegal move.
6
+ class MoveResolver
7
+ Result = Data.define(:legal, :destination)
8
+
9
+ def initialize(board:, territory:)
10
+ @board = board
11
+ @territory = territory
12
+ end
13
+
14
+ def resolve(robot, origin, direction)
15
+ destination = origin + direction
16
+
17
+ if legal_destination?(destination, robot)
18
+ Result.new(legal: true, destination: destination)
19
+ else
20
+ Result.new(legal: false, destination: origin)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def legal_destination?(destination, robot)
27
+ @board.on_board?(destination) && !@territory.owned_by_other?(destination, robot)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,45 @@
1
+ module RobotWars
2
+ # Where each living robot stands right now (RULES.md 4: one robot per
3
+ # square). Pure position bookkeeping — combat and ownership live in
4
+ # ConflictResolver and Territory.
5
+ class OccupancyMap
6
+ def initialize
7
+ @robot_by_position = {}
8
+ end
9
+
10
+ def place(robot, position)
11
+ raise ArgumentError, "#{position} is already occupied" if occupied?(position)
12
+
13
+ @robot_by_position[position] = robot
14
+ end
15
+
16
+ def vacate(position)
17
+ @robot_by_position.delete(position)
18
+ end
19
+
20
+ def move(robot, from:, to:)
21
+ raise ArgumentError, "#{robot} is not at #{from}" unless robot_at(from).equal?(robot)
22
+
23
+ vacate(from)
24
+ place(robot, to)
25
+ end
26
+
27
+ def robot_at(position)
28
+ @robot_by_position[position]
29
+ end
30
+
31
+ def occupied?(position)
32
+ @robot_by_position.key?(position)
33
+ end
34
+
35
+ def position_of(robot)
36
+ @robot_by_position.key(robot)
37
+ end
38
+
39
+ def each_occupied(&block)
40
+ return enum_for(:each_occupied) unless block
41
+
42
+ @robot_by_position.each_pair(&block)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ module RobotWars
2
+ # A single square's coordinates. Values only — no board-bounds checking
3
+ # here; see Board#on_board?.
4
+ Position = Data.define(:x, :y) do
5
+ def +(other)
6
+ Position.new(x: x + other.x, y: y + other.y)
7
+ end
8
+
9
+ def neighbors
10
+ Direction::OFFSETS.map { |offset| self + offset }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,59 @@
1
+ module RobotWars
2
+ # Resolves ranged combat for one turn (RULES.md 26-32): attacks land
3
+ # on whoever occupies the target square, defense is counter-fire
4
+ # rather than a shield, and a defender nobody shot at still pays a
5
+ # 1-point premium.
6
+ class RangedCombatResolver
7
+ AttackOrder = Data.define(:attacker, :square, :points)
8
+ DefendOrder = Data.define(:defender, :points)
9
+ Effect = Data.define(:kind, :robot, :amount, :source)
10
+
11
+ def initialize(occupancy_map:)
12
+ @occupancy_map = occupancy_map
13
+ end
14
+
15
+ def resolve(attacks:, defenses:)
16
+ defense_by_defender = defenses.to_h { |defense| [defense.defender, defense] }
17
+ attacked_defenders = []
18
+
19
+ effects = attacks.flat_map { |attack| resolve_attack(attack, defense_by_defender, attacked_defenders) }
20
+ effects + unchallenged_effects(defenses, attacked_defenders)
21
+ end
22
+
23
+ private
24
+
25
+ # :reek:FeatureEnvy -- an AttackOrder is pure data; resolving it needs this resolver's occupancy map and defense table.
26
+ def resolve_attack(attack, defense_by_defender, attacked_defenders)
27
+ target = @occupancy_map.robot_at(attack.square)
28
+ return [miss(attack)] unless target
29
+
30
+ target.apply_damage(attack.points)
31
+ effects = [Effect.new(kind: :hit, robot: target, amount: attack.points, source: attack.attacker)]
32
+
33
+ defense = defense_by_defender[target]
34
+ return effects unless defense
35
+
36
+ attacked_defenders << defense.defender
37
+ effects << counter_fire(attack.attacker, defense)
38
+ end
39
+
40
+ def miss(attack)
41
+ Effect.new(kind: :miss, robot: nil, amount: attack.points, source: attack.attacker)
42
+ end
43
+
44
+ def counter_fire(attacker, defense)
45
+ attacker.apply_damage(defense.points)
46
+ Effect.new(kind: :counter_fire, robot: attacker, amount: defense.points, source: defense.defender)
47
+ end
48
+
49
+ def unchallenged_effects(defenses, attacked_defenders)
50
+ defenses.reject { |defense| attacked_defenders.include?(defense.defender) }
51
+ .map { |defense| unchallenged(defense) }
52
+ end
53
+
54
+ def unchallenged(defense)
55
+ defense.defender.apply_damage(1)
56
+ Effect.new(kind: :unchallenged, robot: defense.defender, amount: 1, source: nil)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ module RobotWars
2
+ # A player in the game. Life-point bookkeeping only (RULES.md 5, 10,
3
+ # 11, 12, 36) — position, ownership, and actions live elsewhere so
4
+ # each piece of state can be tested on its own.
5
+ class Robot
6
+ STARTING_LIFE = 100
7
+
8
+ attr_reader :id, :life
9
+
10
+ def initialize(id:, life: STARTING_LIFE)
11
+ @id = id
12
+ @life = life
13
+ @eliminated = false
14
+ end
15
+
16
+ def alive?
17
+ !@eliminated && life.positive?
18
+ end
19
+
20
+ def dead?
21
+ !alive?
22
+ end
23
+
24
+ # Death by displacement (RULES.md 37) — a separate cause from life
25
+ # reaching 0 or less (rule 36), so the life total is left as-is.
26
+ def eliminate!
27
+ @eliminated = true
28
+ self
29
+ end
30
+
31
+ def apply_damage(amount)
32
+ raise ArgumentError, "amount must not be negative" if amount.negative?
33
+
34
+ @life -= amount
35
+ self
36
+ end
37
+
38
+ def heal(amount)
39
+ raise ArgumentError, "amount must not be negative" if amount.negative?
40
+
41
+ @life += amount
42
+ self
43
+ end
44
+
45
+ def to_s
46
+ "##{id} (#{life} life)"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ module RobotWars
2
+ # The shared conflict roll (RULES.md 14 and 21): one random number,
3
+ # 1 to 10.
4
+ class RollGenerator
5
+ RANGE = (1..10)
6
+
7
+ def initialize(random: Random.new)
8
+ @random = random
9
+ end
10
+
11
+ def roll
12
+ @random.rand(RANGE)
13
+ end
14
+ end
15
+ end