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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 14f324f88d83aab8853d7c7ac338883cac3a3e6d605854f6dfeac76abc67175c
|
|
4
|
+
data.tar.gz: 9724df9749cb271fa4d9950c642c2d6d30eef84b135f7a80dfd6350bc5d7d585
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0ae69ce16a9150d4ddf0b9425a364eb7e7ef8be97bbd29b721fa9649f9d78eb701e0f63b956e5bc154acf685691bfd6a9111a24142cb8898d13c78c8a2c60cd7
|
|
7
|
+
data.tar.gz: 279d6b5c3f98c351be802b9b5caba6fa4755e2952df821466744c25d63d57230397f5a4d2394c0a00188a4b883dc681c843fab1b7cef4596a6cb2e46141bd590
|
data/.envrc
ADDED
data/.loki
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inherit_from: ../.rubocop-base.yml
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
- Phase 1 engine foundation: `Position`, `Direction`, `Board`,
|
|
6
|
+
`RollGenerator`/`FixedRollGenerator`, `Robot`, `Action`,
|
|
7
|
+
`OccupancyMap`, `Territory`, `MoveResolver`, `ConflictResolver`,
|
|
8
|
+
`IllegalMoveResolver`, `RangedCombatResolver` — 100% line/branch
|
|
9
|
+
coverage.
|
|
10
|
+
- Phase 1 turn orchestrator: `TurnResolver` (rule 40's full sequence,
|
|
11
|
+
including cascading return conflicts and displacement-or-death) and
|
|
12
|
+
`Game` (setup, `play_turn`, win/tie detection) — 100% line/branch
|
|
13
|
+
coverage.
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-09-16
|
|
16
|
+
|
|
17
|
+
- Initial gem skeleton
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewayne VanHoozer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# RobotWars
|
|
2
|
+
|
|
3
|
+
RobotWars is a component of the [RobotLab](https://github.com/MadBomber/robot_lab)
|
|
4
|
+
multi-robot LLM orchestration project: a turn-based game in which RobotLab
|
|
5
|
+
robots compete on a 2D grid board until only one remains.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Game Concept
|
|
10
|
+
|
|
11
|
+
A turn-based, last-robot-standing game. Robots move on a bounded grid,
|
|
12
|
+
fight for squares, claim territory by conquest or occupation, shell each
|
|
13
|
+
other blind from across the board, and die at 0 life points — or by
|
|
14
|
+
having nowhere left to stand.
|
|
15
|
+
|
|
16
|
+
The complete numbered rule set lives in **[RULES.md](RULES.md)**; the
|
|
17
|
+
design discussion behind each rule is logged in `notes.md`.
|
|
18
|
+
|
|
19
|
+
> **Status:** phase 1 (headless engine) has a working core. `RobotWars::Game`
|
|
20
|
+
> runs full matches turn by turn — movement, the life-point economy,
|
|
21
|
+
> square conflicts (including cascading returns and displacement-or-death),
|
|
22
|
+
> ranged combat, death processing, and territory — end to end, with no
|
|
23
|
+
> graphics. Still missing: the turn-event stream for phase 2 to consume,
|
|
24
|
+
> and a CLI/example runner.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
board = RobotWars::Board.new(width: 10, height: 10)
|
|
28
|
+
game = RobotWars::Game.start(board: board, robot_ids: %w[alpha bravo charlie delta])
|
|
29
|
+
|
|
30
|
+
until game.over?
|
|
31
|
+
actions = game.alive_robots.to_h { |robot| [robot, your_strategy.decide(robot, game)] }
|
|
32
|
+
game.play_turn(actions)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
game.winner # => the last robot standing, or nil for a tie
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
See `examples/01_random_match.rb` for a runnable match (random actions,
|
|
39
|
+
printed turn by turn) — `bundle exec ruby examples/01_random_match.rb`.
|
|
40
|
+
|
|
41
|
+
## The `rwars` CLI — LLM-backed warriors
|
|
42
|
+
|
|
43
|
+
`rwars` runs a real match with each warrior's actions decided by an LLM,
|
|
44
|
+
built from a human-authored prompt file (RobotLab's `.md` template
|
|
45
|
+
format: YAML front matter + a personality body). One file per warrior;
|
|
46
|
+
the filename (minus `.md`) becomes that warrior's id (files starting
|
|
47
|
+
with `_` are reserved for shared partials and skipped). A warrior file
|
|
48
|
+
carries only its model choice and personality — the rules of the game
|
|
49
|
+
are the gem's job: `RobotWars::GameRules` (shipped as
|
|
50
|
+
`lib/robot_wars/game_rules.md`) is handed to every robot as its system
|
|
51
|
+
prompt, so all warriors everywhere play by the same canonical rules.
|
|
52
|
+
|
|
53
|
+
Each warrior's `.md` front matter picks its own brain via the `model:`
|
|
54
|
+
field, written as `<provider>/<model id>` — the RubyLLM provider name,
|
|
55
|
+
a slash, then the model id as that provider knows it (e.g.
|
|
56
|
+
`apfel/apple-foundationmodel`, `ollama/llama3`). Pass
|
|
57
|
+
`--model PROVIDER/MODEL` to force every warrior onto the same one
|
|
58
|
+
regardless of what its template says. Nothing is hardcoded: providers
|
|
59
|
+
that ship outside ruby_llm (as `ruby_llm-providers-<name>` gems) are
|
|
60
|
+
required on demand, and a template that names no model falls through to
|
|
61
|
+
RobotLab's config cascade.
|
|
62
|
+
|
|
63
|
+
The example warriors in `examples/warriors/` all pick **Apfel** —
|
|
64
|
+
Apple's on-device Foundation Model, served locally by the `apfel` CLI.
|
|
65
|
+
No API key, no cloud calls, no per-token cost:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
brew install apfel # once, per machine
|
|
69
|
+
apfel --serve # start the local server (127.0.0.1:11434)
|
|
70
|
+
|
|
71
|
+
bin/rwars --warriors examples/warriors --width 10 --height 10
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Apfel requires an Apple Silicon Mac on macOS 26+ with Apple
|
|
75
|
+
Intelligence enabled. See the
|
|
76
|
+
[`ruby_llm-providers-apfel`](https://apfel.franzai.com/) gem for
|
|
77
|
+
details.
|
|
78
|
+
|
|
79
|
+
Each turn, a warrior is sent a sensing report (RULES.md 33-35: its own
|
|
80
|
+
position and life, and the full map of owned squares — never another
|
|
81
|
+
robot's position) and must reply with exactly one line:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
STAY
|
|
85
|
+
MOVE <north|northeast|east|southeast|south|southwest|west|northwest>
|
|
86
|
+
ATTACK <x>,<y> <points>
|
|
87
|
+
DEFEND <points>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A reply that doesn't parse is treated as an illegal-move-style penalty
|
|
91
|
+
(RULES.md 21) rather than crashing the match. Run `bin/rwars --help` for
|
|
92
|
+
all options, including `--seed` for a reproducible match and
|
|
93
|
+
`--max-turns` as a safety valve against stalemates.
|
|
94
|
+
|
|
95
|
+
`examples/warriors/` has four ready-made brains with distinct
|
|
96
|
+
personalities — a fight between them exercises very different play
|
|
97
|
+
styles:
|
|
98
|
+
|
|
99
|
+
| Warrior | Strategy |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `warmonger.md` | Relentlessly aggressive; attacks almost every turn |
|
|
102
|
+
| `sentinel.md` | Patient turtle; claims territory by occupation, rarely fights |
|
|
103
|
+
| `opportunist.md` | Reactive and adaptive; picks its spots, varies its play |
|
|
104
|
+
| `wanderer.md` | Pure expansionist; claims empty ground, avoids conflict entirely |
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bin/rwars --warriors examples/warriors --width 10 --height 10
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Installation
|
|
111
|
+
|
|
112
|
+
Add the gem to your application's Gemfile:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bundle add robot_wars
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or install it directly:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
gem install robot_wars
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
require "robot_wars"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
More to come as the game engine takes shape.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run
|
|
135
|
+
`bundle exec rake test` to run the tests. You can also run `bin/console` for an
|
|
136
|
+
interactive prompt that will allow you to experiment.
|
|
137
|
+
|
|
138
|
+
During cross-gem development the `Gemfile.local` points at the sibling
|
|
139
|
+
`../robot_lab` checkout (selected via the project-root `.envrc` / asgard).
|
|
140
|
+
Quality gates are run with `asgard quality`.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
The gem is available as open source under the terms of the
|
|
145
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/RULES.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# RobotWars — Core Rules
|
|
2
|
+
|
|
3
|
+
Numbered for reference. All rules below are decided (proposed defaults
|
|
4
|
+
approved by Dewayne 2026-09-16). Two sensing details remain open under
|
|
5
|
+
rule 35. See `notes.md` for the discussion history behind each rule.
|
|
6
|
+
|
|
7
|
+
## Board and Setup
|
|
8
|
+
|
|
9
|
+
1. The game is played on a 2D board divided into squares. The board is
|
|
10
|
+
bounded by walls.
|
|
11
|
+
2. Board size is set at the start of the game.
|
|
12
|
+
3. Each robot player is represented by an icon on the board.
|
|
13
|
+
4. Only one robot can occupy a square at a time.
|
|
14
|
+
5. Every robot starts the game with 100 life points.
|
|
15
|
+
6. Starting positions are randomized. No two robots start on the same
|
|
16
|
+
square.
|
|
17
|
+
|
|
18
|
+
## Turns and Actions
|
|
19
|
+
|
|
20
|
+
7. The game is turn based. All robots act simultaneously; results are
|
|
21
|
+
resolved between turns.
|
|
22
|
+
8. Each turn, a robot chooses exactly one action: **stay**, **move**,
|
|
23
|
+
**attack**, or **defend**.
|
|
24
|
+
9. A move is one square in any of the 8 directions (like a chess king).
|
|
25
|
+
10. A successful move costs 1 life point.
|
|
26
|
+
11. A turn in which the robot does not move adds 1 life point. Only a
|
|
27
|
+
**stay** earns this point; attack and defend turns neither pay the
|
|
28
|
+
move cost nor earn the rest point.
|
|
29
|
+
12. There is no upper limit on life points.
|
|
30
|
+
|
|
31
|
+
## Conflicts (robots on the same square)
|
|
32
|
+
|
|
33
|
+
13. A conflict occurs when two or more robots are on the same square at
|
|
34
|
+
the end of a turn.
|
|
35
|
+
14. One random number between 1 and 10 is generated per conflict. That
|
|
36
|
+
same value is subtracted from the life points of every robot in the
|
|
37
|
+
conflict.
|
|
38
|
+
15. The robot with the highest remaining life points wins: it stays on
|
|
39
|
+
the square and marks it as its own.
|
|
40
|
+
16. A tie for highest life is the same as a loss. No robot wins, and the
|
|
41
|
+
square ends the turn unowned.
|
|
42
|
+
17. Every robot that does not win returns to the square it came from.
|
|
43
|
+
18. If a returning robot finds another robot in its origin square, a
|
|
44
|
+
second conflict is resolved there under the same rules.
|
|
45
|
+
19. A robot that loses a second conflict in the same resolution is
|
|
46
|
+
placed on an available adjacent square: one of the 8 neighboring
|
|
47
|
+
squares that is unoccupied and not owned by another robot.
|
|
48
|
+
The neighborhood is centered on the square the robot failed to
|
|
49
|
+
return to, and when several neighbors are available one is chosen
|
|
50
|
+
at random.
|
|
51
|
+
20. If no adjacent square is available, the twice-defeated robot dies.
|
|
52
|
+
|
|
53
|
+
## Illegal Moves
|
|
54
|
+
|
|
55
|
+
21. Attempting to move off the board, or into a square owned by another
|
|
56
|
+
robot, is a solo conflict: the robot loses a random number of life
|
|
57
|
+
points and stays where it was. The random number uses the same 1–10
|
|
58
|
+
range as rule 14.
|
|
59
|
+
|
|
60
|
+
## Territory
|
|
61
|
+
|
|
62
|
+
22. Winning a conflict on a square makes the winner the owner of that
|
|
63
|
+
square (conquest).
|
|
64
|
+
23. A robot that occupies the same square for 3 consecutive turns owns
|
|
65
|
+
the square (occupation).
|
|
66
|
+
24. No robot may enter a square owned by a different robot. The owner
|
|
67
|
+
may occupy its own squares freely.
|
|
68
|
+
25. When a robot dies, all squares it owned are released and become
|
|
69
|
+
unowned.
|
|
70
|
+
|
|
71
|
+
## Ranged Combat
|
|
72
|
+
|
|
73
|
+
26. A robot may attack any square on the board, committing a number of
|
|
74
|
+
points up to its current life as the attack's strength.
|
|
75
|
+
27. Ranged combat is resolved after the turn. If a robot is in the
|
|
76
|
+
target square at resolution time, it loses the full committed
|
|
77
|
+
amount. If the square is empty, the attack misses and has no effect.
|
|
78
|
+
28. Attacking costs the attacker nothing. Committed points are projected
|
|
79
|
+
force, not spent life. An attack on an undefended robot is free.
|
|
80
|
+
29. A robot may defend, committing a number of points up to its current
|
|
81
|
+
life.
|
|
82
|
+
30. Defense is counter-fire: every robot that attacks a defender that
|
|
83
|
+
turn is hit back for the defender's full committed amount. Multiple
|
|
84
|
+
attackers each take the full amount.
|
|
85
|
+
31. Defense does not reduce incoming damage. The defender still takes
|
|
86
|
+
the attacker's full committed amount.
|
|
87
|
+
32. A robot that defends but is not attacked that turn loses 1 life
|
|
88
|
+
point.
|
|
89
|
+
|
|
90
|
+
## Sensing
|
|
91
|
+
|
|
92
|
+
33. A robot's only perception of the board is the map of owned squares.
|
|
93
|
+
A robot can never see another robot's position.
|
|
94
|
+
34. Being attacked does not reveal the attacker. A robot knows the
|
|
95
|
+
damage it took, not where it came from.
|
|
96
|
+
35. A robot knows its own position, its own life total, and the
|
|
97
|
+
outcomes of events it takes part in (conflicts fought, moves
|
|
98
|
+
bounced, damage taken). Still open: whether the ownership map
|
|
99
|
+
identifies which robot owns a square, and whether deaths are
|
|
100
|
+
announced.
|
|
101
|
+
|
|
102
|
+
## Death and Victory
|
|
103
|
+
|
|
104
|
+
36. A robot dies when its life points reach 0 or less.
|
|
105
|
+
37. A robot also dies by displacement under rule 20, regardless of its
|
|
106
|
+
life total.
|
|
107
|
+
38. Mutual kills are allowed. All deaths in the same resolution stand.
|
|
108
|
+
39. The last robot alive wins. If the last robots die in the same
|
|
109
|
+
resolution, the game is a tie: no winner, no loser.
|
|
110
|
+
|
|
111
|
+
## Turn Resolution Order
|
|
112
|
+
|
|
113
|
+
40. Each turn resolves in this sequence:
|
|
114
|
+
1. All robots declare their actions blind and simultaneously.
|
|
115
|
+
2. Moves are executed; move costs and rest gains are applied.
|
|
116
|
+
3. Conflicts on shared squares are resolved, including return
|
|
117
|
+
conflicts and displacement (rules 13–20).
|
|
118
|
+
4. Ranged attacks and counter-fire are resolved against post-move
|
|
119
|
+
positions (rules 26–32).
|
|
120
|
+
5. Deaths are processed; owned squares of the dead are released.
|
|
121
|
+
6. Occupation streaks are updated; ownership by occupation vests.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'bundler/gem_tasks'
|
|
2
|
+
require 'rake/testtask'
|
|
3
|
+
|
|
4
|
+
Rake::TestTask.new(:test) do |t|
|
|
5
|
+
t.libs << 'test'
|
|
6
|
+
t.libs << 'lib'
|
|
7
|
+
t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
|
|
8
|
+
t.verbose = true
|
|
9
|
+
t.ruby_opts << '-rtest_helper'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task default: :test
|
|
13
|
+
|
|
14
|
+
desc 'Run tests with verbose output'
|
|
15
|
+
task :test_verbose do
|
|
16
|
+
ENV['TESTOPTS'] = '--verbose'
|
|
17
|
+
Rake::Task[:test].invoke
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc 'Run a single test file'
|
|
21
|
+
task :test_file, [:file] do |_t, args|
|
|
22
|
+
ruby "test/#{args[:file]}"
|
|
23
|
+
end
|
data/docs/game_board.svg
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="420" viewBox="0 0 560 420" font-family="Menlo, Consolas, monospace">
|
|
2
|
+
<!-- Dark theme, transparent background -->
|
|
3
|
+
<defs>
|
|
4
|
+
<style>
|
|
5
|
+
.cell { fill: #1e2430; stroke: #3a4356; stroke-width: 1; }
|
|
6
|
+
.label { fill: #8b95a8; font-size: 12px; }
|
|
7
|
+
.title { fill: #c9d3e4; font-size: 16px; font-weight: bold; }
|
|
8
|
+
.legend { fill: #aab4c6; font-size: 12px; }
|
|
9
|
+
</style>
|
|
10
|
+
</defs>
|
|
11
|
+
|
|
12
|
+
<text x="30" y="26" class="title">RobotWars — one robot per square, simultaneous moves</text>
|
|
13
|
+
|
|
14
|
+
<!-- 6x5 board, 70px squares -->
|
|
15
|
+
<g transform="translate(30,50)">
|
|
16
|
+
<!-- squares -->
|
|
17
|
+
<g>
|
|
18
|
+
<!-- rows -->
|
|
19
|
+
<g id="row">
|
|
20
|
+
<rect class="cell" x="0" y="0" width="70" height="70"/>
|
|
21
|
+
<rect class="cell" x="70" y="0" width="70" height="70"/>
|
|
22
|
+
<rect class="cell" x="140" y="0" width="70" height="70"/>
|
|
23
|
+
<rect class="cell" x="210" y="0" width="70" height="70"/>
|
|
24
|
+
<rect class="cell" x="280" y="0" width="70" height="70"/>
|
|
25
|
+
<rect class="cell" x="350" y="0" width="70" height="70"/>
|
|
26
|
+
</g>
|
|
27
|
+
<use href="#row" y="70"/>
|
|
28
|
+
<use href="#row" y="140"/>
|
|
29
|
+
<use href="#row" y="210"/>
|
|
30
|
+
<use href="#row" y="280"/>
|
|
31
|
+
</g>
|
|
32
|
+
|
|
33
|
+
<!-- Robot icons: distinct colors per player -->
|
|
34
|
+
<!-- Robot A (cyan) at (1,1) -->
|
|
35
|
+
<g transform="translate(70,70)">
|
|
36
|
+
<rect x="17" y="22" width="36" height="30" rx="6" fill="#22d3ee"/>
|
|
37
|
+
<rect x="24" y="10" width="22" height="14" rx="4" fill="#22d3ee"/>
|
|
38
|
+
<circle cx="30" cy="17" r="2.5" fill="#0b1220"/>
|
|
39
|
+
<circle cx="40" cy="17" r="2.5" fill="#0b1220"/>
|
|
40
|
+
<line x1="35" y1="10" x2="35" y2="4" stroke="#22d3ee" stroke-width="2"/>
|
|
41
|
+
<circle cx="35" cy="3" r="2.5" fill="#22d3ee"/>
|
|
42
|
+
</g>
|
|
43
|
+
|
|
44
|
+
<!-- Robot B (amber) at (4,1) -->
|
|
45
|
+
<g transform="translate(280,70)">
|
|
46
|
+
<rect x="17" y="22" width="36" height="30" rx="6" fill="#fbbf24"/>
|
|
47
|
+
<rect x="24" y="10" width="22" height="14" rx="4" fill="#fbbf24"/>
|
|
48
|
+
<circle cx="30" cy="17" r="2.5" fill="#0b1220"/>
|
|
49
|
+
<circle cx="40" cy="17" r="2.5" fill="#0b1220"/>
|
|
50
|
+
<line x1="35" y1="10" x2="35" y2="4" stroke="#fbbf24" stroke-width="2"/>
|
|
51
|
+
<circle cx="35" cy="3" r="2.5" fill="#fbbf24"/>
|
|
52
|
+
</g>
|
|
53
|
+
|
|
54
|
+
<!-- Robot C (violet) at (2,3) -->
|
|
55
|
+
<g transform="translate(140,210)">
|
|
56
|
+
<rect x="17" y="22" width="36" height="30" rx="6" fill="#a78bfa"/>
|
|
57
|
+
<rect x="24" y="10" width="22" height="14" rx="4" fill="#a78bfa"/>
|
|
58
|
+
<circle cx="30" cy="17" r="2.5" fill="#0b1220"/>
|
|
59
|
+
<circle cx="40" cy="17" r="2.5" fill="#0b1220"/>
|
|
60
|
+
<line x1="35" y1="10" x2="35" y2="4" stroke="#a78bfa" stroke-width="2"/>
|
|
61
|
+
<circle cx="35" cy="3" r="2.5" fill="#a78bfa"/>
|
|
62
|
+
</g>
|
|
63
|
+
|
|
64
|
+
<!-- Simultaneous move arrows -->
|
|
65
|
+
<g stroke-width="3" fill="none" stroke-linecap="round">
|
|
66
|
+
<path d="M 140 105 h 45" stroke="#22d3ee"/>
|
|
67
|
+
<path d="M 185 105 l -8 -6 m 8 6 l -8 6" stroke="#22d3ee"/>
|
|
68
|
+
<path d="M 280 105 h -45" stroke="#fbbf24"/>
|
|
69
|
+
<path d="M 235 105 l 8 -6 m -8 6 l 8 6" stroke="#fbbf24"/>
|
|
70
|
+
<path d="M 175 210 v -45" stroke="#a78bfa"/>
|
|
71
|
+
<path d="M 175 165 l -6 8 m 6 -8 l 6 8" stroke="#a78bfa"/>
|
|
72
|
+
</g>
|
|
73
|
+
|
|
74
|
+
<!-- Conflict marker: two robots targeting the same square -->
|
|
75
|
+
<g transform="translate(210,70)">
|
|
76
|
+
<rect x="4" y="4" width="62" height="62" rx="4" fill="none" stroke="#f87171" stroke-width="2" stroke-dasharray="6 4"/>
|
|
77
|
+
<text x="35" y="42" text-anchor="middle" fill="#f87171" font-size="20" font-weight="bold">!</text>
|
|
78
|
+
</g>
|
|
79
|
+
</g>
|
|
80
|
+
|
|
81
|
+
<!-- Legend -->
|
|
82
|
+
<g transform="translate(30,406)">
|
|
83
|
+
<circle cx="6" cy="-4" r="5" fill="#22d3ee"/>
|
|
84
|
+
<text x="16" y="0" class="legend">robot players (one per square)</text>
|
|
85
|
+
<rect x="230" y="-12" width="14" height="14" rx="2" fill="none" stroke="#f87171" stroke-width="2" stroke-dasharray="4 3"/>
|
|
86
|
+
<text x="250" y="0" class="legend">conflict — resolved between turns</text>
|
|
87
|
+
</g>
|
|
88
|
+
</svg>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# A full RobotWars match with random actions, printed turn by turn.
|
|
3
|
+
#
|
|
4
|
+
# There is no GUI yet (phase 2) — this exercises the headless engine
|
|
5
|
+
# end to end and gives something to watch while developing it.
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/01_random_match.rb [seed]
|
|
8
|
+
# ./examples/01_random_match.rb [seed]
|
|
9
|
+
#
|
|
10
|
+
# Works from any current directory: BUNDLE_GEMFILE (relative, e.g. from
|
|
11
|
+
# the project's .envrc) is resolved against this repo's root, not cwd.
|
|
12
|
+
repo_root = File.expand_path("..", __dir__)
|
|
13
|
+
ENV["BUNDLE_GEMFILE"] = File.expand_path(ENV["BUNDLE_GEMFILE"] || "Gemfile.local", repo_root)
|
|
14
|
+
|
|
15
|
+
require "bundler/setup"
|
|
16
|
+
require "robot_wars"
|
|
17
|
+
|
|
18
|
+
BOARD = RobotWars::Board.new(width: 8, height: 8)
|
|
19
|
+
ROBOT_IDS = %w[alpha bravo charlie delta].freeze
|
|
20
|
+
|
|
21
|
+
def random_action(robot, board, random)
|
|
22
|
+
case random.rand(4)
|
|
23
|
+
when 0 then RobotWars::Action.stay
|
|
24
|
+
when 1 then RobotWars::Action.move(RobotWars::Direction::OFFSETS.sample(random: random))
|
|
25
|
+
when 2 then RobotWars::Action.attack(square: board.random_position(random: random), points: [1, robot.life].max)
|
|
26
|
+
else RobotWars::Action.defend(points: [1, robot.life].max)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
seed = (ARGV.first || Random.new_seed).to_i
|
|
31
|
+
random = Random.new(seed)
|
|
32
|
+
game = RobotWars::Game.start(board: BOARD, robot_ids: ROBOT_IDS, random: random)
|
|
33
|
+
|
|
34
|
+
puts <<~HEADER
|
|
35
|
+
RobotWars — #{ROBOT_IDS.size} robots on a #{BOARD.width}x#{BOARD.height} board
|
|
36
|
+
seed: #{seed}
|
|
37
|
+
HEADER
|
|
38
|
+
|
|
39
|
+
until game.over?
|
|
40
|
+
actions = game.alive_robots.to_h { |robot| [robot, random_action(robot, BOARD, random)] }
|
|
41
|
+
report = game.play_turn(actions)
|
|
42
|
+
|
|
43
|
+
status = game.alive_robots.map { |robot| "#{robot.id}=#{robot.life}" }.join(", ")
|
|
44
|
+
puts "turn #{game.turn_number}: #{status}"
|
|
45
|
+
report.deaths.each { |robot| puts " #{robot.id} died" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
puts "---"
|
|
49
|
+
if game.tie?
|
|
50
|
+
puts "Tie — the last robots fell in the same turn."
|
|
51
|
+
else
|
|
52
|
+
puts "Winner: #{game.winner.id} (#{game.winner.life} life) after #{game.turn_number} turns."
|
|
53
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Adaptive and calculating — reads the board and reacts, doesn't commit to a script
|
|
3
|
+
model: apfel/apple-foundationmodel
|
|
4
|
+
temperature: 0.6
|
|
5
|
+
parameters:
|
|
6
|
+
callsign: Opportunist
|
|
7
|
+
---
|
|
8
|
+
Your callsign is <%= callsign %>.
|
|
9
|
+
|
|
10
|
+
Your personality: think before you act, every turn, using only the
|
|
11
|
+
report you're given. Track how much owned territory has appeared or
|
|
12
|
+
changed since last turn, if you can recall it — a sudden new claim near
|
|
13
|
+
you is a real robot, somewhere close. Attack ONLY when you have a
|
|
14
|
+
specific reason to believe a target is weak or unprepared, and never
|
|
15
|
+
commit more than you're willing to lose to counter-fire. Rest whenever
|
|
16
|
+
nothing looks worth reacting to. Defend when you suspect you're being
|
|
17
|
+
hunted. Never follow a fixed pattern — vary your play so an opponent
|
|
18
|
+
can't predict you.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Relentlessly aggressive — always looking for a fight
|
|
3
|
+
model: lms/openai/gpt-oss-20b
|
|
4
|
+
temperature: 0.4
|
|
5
|
+
parameters:
|
|
6
|
+
callsign: Oppressor
|
|
7
|
+
---
|
|
8
|
+
Your callsign is <%= callsign %>.
|
|
9
|
+
|
|
10
|
+
Your personality: you are impatient and love a fight. Resting to heal
|
|
11
|
+
bores you. Every turn, prefer ATTACK over anything else — pick an owned
|
|
12
|
+
square that isn't yours and hit it with a meaningful chunk of your
|
|
13
|
+
current life (a third to half of it is reasonable; more if you're
|
|
14
|
+
already strong). If no squares are owned yet, MOVE toward the center of
|
|
15
|
+
the board so you're in the thick of things sooner. Only DEFEND if your
|
|
16
|
+
life has dropped below 30 and you expect retaliation. Never STAY out of
|
|
17
|
+
caution — that's not who you are.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A patient turtle — builds territory and health before ever fighting
|
|
3
|
+
model: apfel/apple-foundationmodel
|
|
4
|
+
temperature: 0.3
|
|
5
|
+
parameters:
|
|
6
|
+
callsign: Sentinel
|
|
7
|
+
---
|
|
8
|
+
Your callsign is <%= callsign %>.
|
|
9
|
+
|
|
10
|
+
Your personality: patient and defensive. Your default action is STAY —
|
|
11
|
+
rack up life and, after 3 turns on the same square, claim it as your
|
|
12
|
+
own permanent territory. Only DEFEND if you have reason to think you
|
|
13
|
+
are being watched or hunted (e.g. your life has taken damage you can't
|
|
14
|
+
explain, or the board is getting crowded with owned squares near you) —
|
|
15
|
+
commit a moderate amount, not everything. Never ATTACK first. If your
|
|
16
|
+
life ever exceeds 150, you may risk a single opportunistic ATTACK on a
|
|
17
|
+
square you judge is weakly held, then return to being patient. Avoid
|
|
18
|
+
moving unless staying would clearly put you in danger.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: An expansionist — claims empty ground and avoids every fight it can
|
|
3
|
+
model: apfel/apple-foundationmodel
|
|
4
|
+
temperature: 0.5
|
|
5
|
+
parameters:
|
|
6
|
+
callsign: Wanderer
|
|
7
|
+
---
|
|
8
|
+
Your callsign is <%= callsign %>.
|
|
9
|
+
|
|
10
|
+
Your personality: a wanderer and a homesteader, not a fighter. Your
|
|
11
|
+
goal is to accumulate as much owned territory as possible while
|
|
12
|
+
avoiding conflict entirely. Each turn, prefer STAY if you're not yet at
|
|
13
|
+
3 consecutive turns on your current square — finish claiming it first.
|
|
14
|
+
Once a square is yours, MOVE toward open, unclaimed-looking ground to
|
|
15
|
+
start claiming the next one, favoring directions away from where new
|
|
16
|
+
territory has recently appeared (that's where rivals are). Never
|
|
17
|
+
ATTACK. Only DEFEND, briefly, if you have strong reason to think you're
|
|
18
|
+
about to be caught while still mid-claim on a square. Winning by
|
|
19
|
+
attrition and territory, not combat, is your entire strategy.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Relentlessly aggressive — always looking for a fight
|
|
3
|
+
model: apfel/apple-foundationmodel
|
|
4
|
+
temperature: 0.4
|
|
5
|
+
parameters:
|
|
6
|
+
callsign: Warmonger
|
|
7
|
+
---
|
|
8
|
+
Your callsign is <%= callsign %>.
|
|
9
|
+
|
|
10
|
+
Your personality: you are impatient and love a fight. Resting to heal
|
|
11
|
+
bores you. Every turn, prefer ATTACK over anything else — pick an owned
|
|
12
|
+
square that isn't yours and hit it with a meaningful chunk of your
|
|
13
|
+
current life (a third to half of it is reasonable; more if you're
|
|
14
|
+
already strong). If no squares are owned yet, MOVE toward the center of
|
|
15
|
+
the board so you're in the thick of things sooner. Only DEFEND if your
|
|
16
|
+
life has dropped below 30 and you expect retaliation. Never STAY out of
|
|
17
|
+
caution — that's not who you are.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module RobotWars
|
|
2
|
+
# A robot's single declared action for a turn (RULES.md 8: stay, move,
|
|
3
|
+
# attack, or defend — exactly one). `invalid` is not a rule 8 choice —
|
|
4
|
+
# it's what a pilot (e.g. an LLM reply that fails to parse) hands the
|
|
5
|
+
# engine when it produced nothing usable; TurnResolver treats it the
|
|
6
|
+
# same as an illegal move (rule 21's solo-conflict penalty).
|
|
7
|
+
Action = Data.define(:type, :direction, :square, :points) do
|
|
8
|
+
def self.stay
|
|
9
|
+
new(type: :stay, direction: nil, square: nil, points: nil)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.move(direction)
|
|
13
|
+
new(type: :move, direction: direction, square: nil, points: nil)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.attack(square:, points:)
|
|
17
|
+
raise ArgumentError, "points must be positive" unless points.positive?
|
|
18
|
+
|
|
19
|
+
new(type: :attack, direction: nil, square: square, points: points)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.defend(points:)
|
|
23
|
+
raise ArgumentError, "points must be positive" unless points.positive?
|
|
24
|
+
|
|
25
|
+
new(type: :defend, direction: nil, square: nil, points: points)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.invalid
|
|
29
|
+
new(type: :invalid, direction: nil, square: nil, points: nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stay? = type == :stay
|
|
33
|
+
def move? = type == :move
|
|
34
|
+
def attack? = type == :attack
|
|
35
|
+
def defend? = type == :defend
|
|
36
|
+
def invalid? = type == :invalid
|
|
37
|
+
|
|
38
|
+
# Render the action in the same one-line grammar pilots reply in
|
|
39
|
+
# (see ActionParser), so a match transcript reads like the commands
|
|
40
|
+
# the warriors gave.
|
|
41
|
+
def to_s
|
|
42
|
+
case type
|
|
43
|
+
when :stay then "STAY"
|
|
44
|
+
when :move then "MOVE #{Direction.name_of(direction)}"
|
|
45
|
+
when :attack then "ATTACK #{square.x},#{square.y} #{points}"
|
|
46
|
+
when :defend then "DEFEND #{points}"
|
|
47
|
+
else "INVALID"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Like to_s, but anchored to the board: where the robot stood for
|
|
52
|
+
# STAY, where it was headed for MOVE. `origin` is the robot's square
|
|
53
|
+
# when it declared the action — the intended MOVE destination is
|
|
54
|
+
# origin + direction, which resolution may still deny (conflict
|
|
55
|
+
# loss, board edge, owned square).
|
|
56
|
+
def describe(origin)
|
|
57
|
+
case type
|
|
58
|
+
when :stay then "STAY at (#{origin.x},#{origin.y})"
|
|
59
|
+
when :move
|
|
60
|
+
destination = origin + direction
|
|
61
|
+
"MOVE #{Direction.name_of(direction)} to (#{destination.x},#{destination.y})"
|
|
62
|
+
else to_s
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|