robot_wars 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14f324f88d83aab8853d7c7ac338883cac3a3e6d605854f6dfeac76abc67175c
4
- data.tar.gz: 9724df9749cb271fa4d9950c642c2d6d30eef84b135f7a80dfd6350bc5d7d585
3
+ metadata.gz: cabecd1a75d787d30df7e8669332d89143ec82f98b3312122a8d618db2bb75b8
4
+ data.tar.gz: 05efc6cb5f01898fb9461f79364c4f5750f61826726e0e6696f174be30e96748
5
5
  SHA512:
6
- metadata.gz: 0ae69ce16a9150d4ddf0b9425a364eb7e7ef8be97bbd29b721fa9649f9d78eb701e0f63b956e5bc154acf685691bfd6a9111a24142cb8898d13c78c8a2c60cd7
7
- data.tar.gz: 279d6b5c3f98c351be802b9b5caba6fa4755e2952df821466744c25d63d57230397f5a4d2394c0a00188a4b883dc681c843fab1b7cef4596a6cb2e46141bd590
6
+ metadata.gz: a58ef65c02bb25d4d74155b68ec7dc7f397f0df3397248db58fc4b6bcc332062a67ee33b46603d74b595ee22d39475388a7e444776398e82638c05a498c9066e
7
+ data.tar.gz: 99adccb7a7b334ddf2fc2aeaf03971c4ce24a641a0c294818b9f7eb94911c206531790a7e9bc84e50e6531b219ecfaf3e097079278fa52579a543a30fc1cf752
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # RobotWars
2
2
 
3
+ > **Note:** This gem is still in development. It is waiting on the
4
+ > release of some of its dependencies before it can be published.
5
+
3
6
  RobotWars is a component of the [RobotLab](https://github.com/MadBomber/robot_lab)
4
7
  multi-robot LLM orchestration project: a turn-based game in which RobotLab
5
8
  robots compete on a 2D grid board until only one remains.
data/bin/rwars ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env ruby
2
+ # rwars — run a RobotWars match with LLM-backed warriors.
3
+ #
4
+ # rwars --warriors ./warriors --width 10 --height 10
5
+ #
6
+ # Each warrior is one *.md prompt template (RobotLab's format: YAML
7
+ # front matter + a personality body) in the given directory. The
8
+ # filename (minus ".md") becomes that warrior's id. Files starting
9
+ # with "_" are reserved for shared partials, not warriors.
10
+ #
11
+ # The rules of the game are NOT the templates' job: every robot gets
12
+ # the gem's own canonical recap (RobotWars::GameRules, shipped as
13
+ # lib/robot_wars/game_rules.md) as its RobotLab system_prompt.
14
+
15
+ repo_root = File.expand_path("..", __dir__)
16
+ ENV["BUNDLE_GEMFILE"] = File.expand_path(ENV["BUNDLE_GEMFILE"] || "Gemfile.local", repo_root)
17
+ require "bundler/setup"
18
+
19
+ require "optparse"
20
+ require "robot_wars"
21
+
22
+ # Each warrior's own *.md front matter picks its brain: the `model:`
23
+ # field is "<provider>/<model id>" (e.g. apfel/apple-foundationmodel),
24
+ # parsed by RobotWars::ModelSpec. --model, in the same format, forces
25
+ # every warrior onto the same one. Nothing here names a provider or
26
+ # model of its own — a template that names neither falls through to
27
+ # RobotLab's config cascade.
28
+ options = { width: 10, height: 10, max_turns: 500, seed: nil, model: nil,
29
+ life: RobotWars::Robot::STARTING_LIFE }
30
+
31
+ parser = OptionParser.new do |opts|
32
+ opts.banner = "Usage: rwars --warriors DIR [options]"
33
+
34
+ opts.on("-w", "--warriors DIR", "Directory of *.md warrior brain templates (required)") do |dir|
35
+ options[:warriors] = dir
36
+ end
37
+ opts.on("--width N", Integer, "Board width (default: #{options[:width]})") { |n| options[:width] = n }
38
+ opts.on("--height N", Integer, "Board height (default: #{options[:height]})") { |n| options[:height] = n }
39
+ opts.on("--size WxH", "Board size shorthand, e.g. 12x12 (overrides --width/--height)") do |size|
40
+ options[:width], options[:height] = size.split(/x/i, 2).map(&:to_i)
41
+ end
42
+ opts.on("--max-turns N", Integer, "Stop after N turns if nobody has won yet (default: #{options[:max_turns]})") do |n|
43
+ options[:max_turns] = n
44
+ end
45
+ opts.on("--seed N", Integer, "Seed the random number generator for a reproducible match") { |n| options[:seed] = n }
46
+ opts.on("--life N", Integer, "Starting life for every robot (default: #{options[:life]})") { |n| options[:life] = n }
47
+ opts.on("--model PROVIDER/MODEL", "Force every warrior onto this model, overriding its own front matter") do |m|
48
+ options[:model] = m
49
+ end
50
+ opts.on("--version", "Show the robot_wars gem version") do
51
+ puts "rwars #{RobotWars::VERSION}"
52
+ exit
53
+ end
54
+ opts.on("-h", "--help", "Show this help") do
55
+ puts opts
56
+ exit
57
+ end
58
+ end
59
+
60
+ parser.parse!(ARGV)
61
+
62
+ unless options[:warriors]
63
+ warn parser
64
+ abort "\nerror: --warriors DIR is required"
65
+ end
66
+
67
+ abort "error: --life must be positive; got #{options[:life]}" unless options[:life].positive?
68
+
69
+ warriors_dir = File.expand_path(options[:warriors])
70
+ abort "error: #{warriors_dir} is not a directory" unless File.directory?(warriors_dir)
71
+
72
+ brain_files = Dir.glob(File.join(warriors_dir, "*.md")).reject { |path| File.basename(path).start_with?("_") }
73
+ abort "error: no *.md files found in #{warriors_dir}" if brain_files.empty?
74
+ abort "error: need at least 2 warriors to fight; found #{brain_files.size}" if brain_files.size < 2
75
+
76
+ # Setting PM.configure directly here would be overwritten the moment
77
+ # RobotLab.config is first touched (lazily, inside RobotLab::Robot#new) —
78
+ # it reapplies its own default prompts_dir. Going through RobotLab's own
79
+ # config makes our setting durable against that lazy reload. Quieting
80
+ # ruby_llm's default :debug logging the same way, since a game shouldn't
81
+ # dump every raw API request/response to stdout by default.
82
+ RobotLab.config.template_path = warriors_dir
83
+ RobotLab.config.ruby_llm.log_level = :warn
84
+ RobotLab.config.after_load
85
+
86
+ random = options[:seed] ? Random.new(options[:seed]) : Random.new
87
+ board = RobotWars::Board.new(width: options[:width], height: options[:height])
88
+ warrior_ids = brain_files.map { |path| File.basename(path, ".md") }
89
+ game = RobotWars::Game.start(board: board, robot_ids: warrior_ids, life: options[:life], random: random)
90
+
91
+ # Resolve each warrior's "<provider>/<model id>" up front: --model wins
92
+ # over front matter, front matter over nothing. RobotLab would otherwise
93
+ # hand the front matter's combined string to RubyLLM verbatim as a model
94
+ # id, so the split into provider + bare model must happen here.
95
+ override = RobotWars::ModelSpec.parse(options[:model])
96
+ specs = game.robots.to_h do |robot|
97
+ [robot, override || RobotWars::ModelSpec.from_template(File.join(warriors_dir, "#{robot.id}.md"))]
98
+ end
99
+
100
+ # Providers that live outside ruby_llm itself (apfel, lms, ...) ship as
101
+ # ruby_llm-providers-<name> gems and register themselves when required;
102
+ # built-in providers are already in the registry and need nothing.
103
+ specs.values.compact.map(&:provider).compact.uniq.each do |provider|
104
+ next if RubyLLM::Provider.providers.key?(provider)
105
+
106
+ begin
107
+ require "ruby_llm/providers/#{provider}"
108
+ rescue LoadError
109
+ abort "error: unknown provider #{provider.inspect} — not built into ruby_llm " \
110
+ "and no ruby_llm/providers/#{provider} library could be loaded"
111
+ end
112
+ end
113
+
114
+ warriors = game.robots.to_h do |robot|
115
+ # Constructor kwargs beat front matter, which is what makes the parsed
116
+ # bare model id (and --model's universal override) win over the raw
117
+ # "<provider>/<model>" string still sitting in the template.
118
+ build_options = { name: robot.id, template: robot.id.to_sym,
119
+ system_prompt: RobotWars::GameRules.text }
120
+ if (spec = specs.fetch(robot))
121
+ build_options[:model] = spec.model
122
+ build_options[:provider] = spec.provider if spec.provider
123
+ end
124
+
125
+ llm_robot = RobotLab.build(**build_options)
126
+ [robot, RobotWars::Warrior.new(robot: robot, pilot: RobotWars::LLMPilot.new(llm_robot: llm_robot))]
127
+ end
128
+
129
+ puts <<~HEADER
130
+ RobotWars — #{warrior_ids.size} warriors on a #{board.width}x#{board.height} board
131
+ warriors: #{warrior_ids.join(', ')}
132
+ HEADER
133
+
134
+ last_report = nil
135
+ until game.over? || game.turn_number >= options[:max_turns]
136
+ # Every pilot's prompt goes out concurrently — the calls are
137
+ # I/O-bound waits, so a turn costs the slowest brain rather than the
138
+ # sum of all of them. Sensing reports are built up front on the main
139
+ # thread (they read shared game state); each thread then only talks
140
+ # to its own warrior's chat. Battleship-style: an attacker is told on
141
+ # its NEXT turn whether last turn's shot found a robot (HIT) or an
142
+ # empty square (MISS).
143
+ threads = game.alive_robots.to_h do |robot|
144
+ sensing = RobotWars::SensingReport.new(
145
+ game: game, robot: robot,
146
+ attack_outcome: last_report&.attack_outcome_for(robot)
147
+ ).to_s
148
+
149
+ thread = Thread.new do
150
+ # A brain failure is reported once, by the abort below — not as a
151
+ # raw stack trace the moment the thread dies.
152
+ Thread.current.report_on_exception = false
153
+ warriors.fetch(robot).decide(sensing)
154
+ end
155
+ [robot, thread]
156
+ end
157
+
158
+ # Thread#value re-raises a failed brain's exception here, preserving
159
+ # the serial loop's abort behavior.
160
+ actions = threads.to_h do |robot, thread|
161
+ [robot, thread.value]
162
+ rescue StandardError => e
163
+ abort "error: #{robot.id}'s brain failed to respond (#{e.class}: #{e.message})"
164
+ end
165
+
166
+ # Origins must be read before resolution — afterward a robot may have
167
+ # moved, been displaced, or died off the board.
168
+ origins = actions.keys.to_h { |robot| [robot, game.occupancy.position_of(robot)] }
169
+
170
+ report = game.play_turn(actions)
171
+ puts "turn #{game.turn_number}: #{game.alive_robots.map { |r| "#{r.id}=#{r.life}" }.join(', ')}"
172
+ # Recap what each warrior declared this turn — every robot that was
173
+ # alive when actions were collected, including any the turn just
174
+ # eliminated — then the conflicts those declarations caused, in the
175
+ # order they resolve: rule 21 solo conflicts during the movement
176
+ # phase, then the square conflicts.
177
+ actions.each do |robot, action|
178
+ line = " #{robot.id}: #{action.describe(origins.fetch(robot))}"
179
+ owned = report.claimed_squares_for(robot).map { |square| "(#{square.x},#{square.y})" }
180
+ line << " — now owns #{owned.join(', ')}" unless owned.empty?
181
+ puts line
182
+ end
183
+ (report.solo_conflicts + report.conflicts).each { |conflict| puts " #{conflict}" }
184
+ # Ranged shots resolve last; show each attacker's Battleship-style
185
+ # outcome (the same feedback its next sensing report will carry).
186
+ actions.each_key do |robot|
187
+ outcome = report.attack_outcome_for(robot)
188
+ puts " #{robot.id}'s attack was a #{outcome.to_s.upcase}" if outcome
189
+ end
190
+ last_report = report
191
+ end
192
+
193
+ puts "---"
194
+ if game.tie?
195
+ puts "Tie — the last warriors fell in the same turn."
196
+ elsif game.over?
197
+ puts "Winner: #{game.winner.id} (#{game.winner.life} life) after #{game.turn_number} turns."
198
+ else
199
+ puts "Stopped after #{game.turn_number} turns (max-turns reached) — #{game.alive_robots.size} warriors still standing."
200
+ end
@@ -1,3 +1,3 @@
1
1
  module RobotWars
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.1.1".freeze
3
3
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_wars
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -28,7 +28,8 @@ description: 'A turn-based game built on the RobotLab framework: each robot occu
28
28
  between turns, and the last robot remaining wins.'
29
29
  email:
30
30
  - dvanhoozer@gmail.com
31
- executables: []
31
+ executables:
32
+ - rwars
32
33
  extensions: []
33
34
  extra_rdoc_files: []
34
35
  files:
@@ -40,6 +41,7 @@ files:
40
41
  - README.md
41
42
  - RULES.md
42
43
  - Rakefile
44
+ - bin/rwars
43
45
  - docs/game_board.svg
44
46
  - examples/01_random_match.rb
45
47
  - examples/warriors/opportunist.md