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
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
@@ -0,0 +1,7 @@
1
+ # robot_lab_project/robot_wars/.envrc
2
+ # BUNDLE_GEMFILE is inherited from the project root .envrc (asgard dev|prod)
3
+ source_up
4
+
5
+ export RR=$(pwd)
6
+
7
+ PATH_add $RR/bin
data/.loki ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # robot_wars — turn-based last-robot-standing game on a 2D grid board.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_wars".freeze
8
+
9
+ header "robot_wars v#{gem_version} — last robot standing"
10
+ end
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
+ ![RobotWars game board](docs/game_board.svg)
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
@@ -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