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.
- checksums.yaml +7 -0
- data/.envrc +7 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/RULES.md +121 -0
- data/Rakefile +23 -0
- data/docs/game_board.svg +88 -0
- data/examples/01_random_match.rb +53 -0
- data/examples/warriors/opportunist.md +18 -0
- data/examples/warriors/oppressor.md +17 -0
- data/examples/warriors/sentinel.md +18 -0
- data/examples/warriors/wanderer.md +19 -0
- data/examples/warriors/warmonger.md +17 -0
- data/lib/robot_wars/action.rb +66 -0
- data/lib/robot_wars/action_parser.rb +66 -0
- data/lib/robot_wars/board.rb +47 -0
- data/lib/robot_wars/conflict_resolver.rb +32 -0
- data/lib/robot_wars/direction.rb +30 -0
- data/lib/robot_wars/fixed_roll_generator.rb +16 -0
- data/lib/robot_wars/game.rb +79 -0
- data/lib/robot_wars/game_rules.md +44 -0
- data/lib/robot_wars/game_rules.rb +12 -0
- data/lib/robot_wars/illegal_move_resolver.rb +18 -0
- data/lib/robot_wars/llm_pilot.rb +17 -0
- data/lib/robot_wars/model_spec.rb +39 -0
- data/lib/robot_wars/move_resolver.rb +30 -0
- data/lib/robot_wars/occupancy_map.rb +45 -0
- data/lib/robot_wars/position.rb +13 -0
- data/lib/robot_wars/ranged_combat_resolver.rb +59 -0
- data/lib/robot_wars/robot.rb +49 -0
- data/lib/robot_wars/roll_generator.rb +15 -0
- data/lib/robot_wars/sensing_report.rb +61 -0
- data/lib/robot_wars/territory.rb +75 -0
- data/lib/robot_wars/turn_resolver.rb +265 -0
- data/lib/robot_wars/version.rb +3 -0
- data/lib/robot_wars/warrior.rb +22 -0
- data/lib/robot_wars.rb +29 -0
- data/sig/robot_wars.rbs +6 -0
- 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
|