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 +4 -4
- data/README.md +3 -0
- data/bin/rwars +200 -0
- data/lib/robot_wars/version.rb +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cabecd1a75d787d30df7e8669332d89143ec82f98b3312122a8d618db2bb75b8
|
|
4
|
+
data.tar.gz: 05efc6cb5f01898fb9461f79364c4f5750f61826726e0e6696f174be30e96748
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/robot_wars/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
8
|
-
bindir:
|
|
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
|