skirmish 0.1.2__tar.gz
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.
- skirmish-0.1.2/.gitignore +22 -0
- skirmish-0.1.2/LICENSE +22 -0
- skirmish-0.1.2/PKG-INFO +56 -0
- skirmish-0.1.2/README.md +42 -0
- skirmish-0.1.2/prompts/NEXT_ROUND.md +23 -0
- skirmish-0.1.2/prompts/OBJECTIVE.md +225 -0
- skirmish-0.1.2/prompts/prompts.ts +36 -0
- skirmish-0.1.2/pyproject.toml +26 -0
- skirmish-0.1.2/src/skirmish/__init__.py +106 -0
- skirmish-0.1.2/src/skirmish/strategies/__init__.py +0 -0
- skirmish-0.1.2/src/skirmish/strategies/example_1.js +68 -0
- skirmish-0.1.2/src/skirmish/strategies/example_2.js +87 -0
- skirmish-0.1.2/tests/test_skirmish.py +98 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
|
|
6
|
+
# Build artifacts
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
src/*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Python
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.py[cod]
|
|
15
|
+
*$py.class
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
*.swo
|
skirmish-0.1.2/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kai McPheeters
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
skirmish-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skirmish
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: LLM Skirmish, an adversarial in-context learning benchmark
|
|
5
|
+
Project-URL: Homepage, https://llmskirmish.com
|
|
6
|
+
Project-URL: Repository, https://github.com/llmskirmish/skirmish-py
|
|
7
|
+
Author-email: Kai McPheeters <kai@mcpheeters.us>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Skirmish
|
|
16
|
+
|
|
17
|
+
Python bindings for [LLM Skirmish](https://llmskirmish.com).
|
|
18
|
+
Run matches between strategies and collect results.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @llmskirmish/skirmish
|
|
24
|
+
pip install skirmish
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from skirmish import run_match, Winner, EndReason
|
|
31
|
+
|
|
32
|
+
# Run a match between two strategy scripts
|
|
33
|
+
result = run_match("example_1.js", "example_2.js")
|
|
34
|
+
|
|
35
|
+
print(f"Winner: {result.winner}") # Winner.PLAYER1, PLAYER2, DRAW, or ERROR
|
|
36
|
+
print(f"Reason: {result.reason}") # EndReason.ELIMINATION, TIMEOUT, or ERROR
|
|
37
|
+
print(f"Ticks: {result.ticks}") # Number of game ticks
|
|
38
|
+
print(f"Scores: {result.scores}") # Scores(player1=..., player2=...)
|
|
39
|
+
|
|
40
|
+
# Optional parameters
|
|
41
|
+
result = run_match(
|
|
42
|
+
p1="example_1.js",
|
|
43
|
+
p2="example_2.js",
|
|
44
|
+
map_name="swamp", # Map to play on (default: "swamp")
|
|
45
|
+
max_ticks=2000, # Max game length (default: 2000)
|
|
46
|
+
timeout=120.0, # Process timeout in seconds (default: 120)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Error handling - errors return a result, not an exception
|
|
50
|
+
if result.winner == Winner.ERROR:
|
|
51
|
+
print(f"Match failed: {result.error}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
skirmish-0.1.2/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Skirmish
|
|
2
|
+
|
|
3
|
+
Python bindings for [LLM Skirmish](https://llmskirmish.com).
|
|
4
|
+
Run matches between strategies and collect results.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @llmskirmish/skirmish
|
|
10
|
+
pip install skirmish
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from skirmish import run_match, Winner, EndReason
|
|
17
|
+
|
|
18
|
+
# Run a match between two strategy scripts
|
|
19
|
+
result = run_match("example_1.js", "example_2.js")
|
|
20
|
+
|
|
21
|
+
print(f"Winner: {result.winner}") # Winner.PLAYER1, PLAYER2, DRAW, or ERROR
|
|
22
|
+
print(f"Reason: {result.reason}") # EndReason.ELIMINATION, TIMEOUT, or ERROR
|
|
23
|
+
print(f"Ticks: {result.ticks}") # Number of game ticks
|
|
24
|
+
print(f"Scores: {result.scores}") # Scores(player1=..., player2=...)
|
|
25
|
+
|
|
26
|
+
# Optional parameters
|
|
27
|
+
result = run_match(
|
|
28
|
+
p1="example_1.js",
|
|
29
|
+
p2="example_2.js",
|
|
30
|
+
map_name="swamp", # Map to play on (default: "swamp")
|
|
31
|
+
max_ticks=2000, # Max game length (default: 2000)
|
|
32
|
+
timeout=120.0, # Process timeout in seconds (default: 120)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Error handling - errors return a result, not an exception
|
|
36
|
+
if result.winner == Winner.ERROR:
|
|
37
|
+
print(f"Match failed: {result.error}")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Round {round} — Reviewing Previous Results
|
|
2
|
+
|
|
3
|
+
Review your round {prev_round} performance to identify strengths, weaknesses, and areas for improvement.
|
|
4
|
+
|
|
5
|
+
## Step 1: Read Your Previous Match Logs
|
|
6
|
+
|
|
7
|
+
Your round {prev_round} match logs are at:
|
|
8
|
+
|
|
9
|
+
- **`log/{model_name}/round_{prev_round}/`** — Contains match logs with game events, unit actions, and combat outcomes
|
|
10
|
+
|
|
11
|
+
Read these files to understand what happened in your matches.
|
|
12
|
+
|
|
13
|
+
## Step 2: Read Your Previous Strategy
|
|
14
|
+
|
|
15
|
+
Your round {prev_round} strategy is at:
|
|
16
|
+
|
|
17
|
+
- **`strategies/{model_name}/round_{prev_round}.js`**
|
|
18
|
+
|
|
19
|
+
## Step 3: Write Your Improved Strategy
|
|
20
|
+
|
|
21
|
+
Create your round {round} strategy at:
|
|
22
|
+
|
|
23
|
+
- **`strategies/{model_name}/round_{round}.js`**
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Skirmish — Game Objective
|
|
2
|
+
|
|
3
|
+
Skirmish is a game where players write JavaScript game scripts that battle in real-time strategy combat. The game uses the **Screeps Arena API**, a programmable RTS engine where code controls every action.
|
|
4
|
+
|
|
5
|
+
## Victory Condition
|
|
6
|
+
|
|
7
|
+
**Destroy your opponent's Spawn to win.**
|
|
8
|
+
|
|
9
|
+
Each player starts with one Spawn structure. When a Spawn is destroyed, that player loses. If neither Spawn is destroyed when the tick limit is reached, the match ends in a draw.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Core Mechanics
|
|
14
|
+
|
|
15
|
+
### The Arena
|
|
16
|
+
|
|
17
|
+
- **100×100 tile grid** with three terrain types:
|
|
18
|
+
- **Plain** — Normal movement
|
|
19
|
+
- **Swamp** — Slows movement (increased fatigue)
|
|
20
|
+
- **Wall** — Impassable
|
|
21
|
+
|
|
22
|
+
### Spawns
|
|
23
|
+
|
|
24
|
+
Your Spawn is your base and your only way to create units:
|
|
25
|
+
|
|
26
|
+
- **5,000 HP** on the swamp map (can vary by map)
|
|
27
|
+
- **Stores energy** — Used to spawn creeps (starts with 500 on swamp map)
|
|
28
|
+
- **Spawning takes time** — 3 ticks per body part (a 3-part creep takes 9 ticks)
|
|
29
|
+
- `spawn.spawning` is truthy while spawning, `null` when ready
|
|
30
|
+
- `spawn.store.energy` shows current energy
|
|
31
|
+
|
|
32
|
+
### Creeps
|
|
33
|
+
|
|
34
|
+
Creeps are the units you control. Each creep is built from **body parts** that determine its abilities:
|
|
35
|
+
|
|
36
|
+
| Body Part | Cost | Function |
|
|
37
|
+
|-----------|------|----------|
|
|
38
|
+
| `MOVE` | 50 | Reduces fatigue, enables movement |
|
|
39
|
+
| `ATTACK` | 80 | Melee attack (30 damage, range 1) |
|
|
40
|
+
| `RANGED_ATTACK` | 150 | Ranged attack (10 damage, range 1-3) |
|
|
41
|
+
| `HEAL` | 250 | Heals self or allies (12 HP close, 4 HP ranged) |
|
|
42
|
+
| `WORK` | 100 | Harvests energy from sources |
|
|
43
|
+
| `CARRY` | 50 | Carries resources (50 capacity) |
|
|
44
|
+
| `TOUGH` | 10 | Cheap HP buffer (100 HP) |
|
|
45
|
+
|
|
46
|
+
**Fatigue System:** Moving generates fatigue based on body weight and terrain:
|
|
47
|
+
- Each non-MOVE, non-CARRY part adds **2 fatigue** on plains, **10 fatigue** on swamps
|
|
48
|
+
- Each MOVE part reduces fatigue by **2 per tick**
|
|
49
|
+
- Creeps cannot move while `fatigue > 0`
|
|
50
|
+
- **Full speed on plains:** 1 MOVE per 1 heavy part (e.g., `[ATTACK, MOVE]`)
|
|
51
|
+
- **Full speed on swamps:** 5 MOVE per 1 heavy part (expensive!)
|
|
52
|
+
|
|
53
|
+
### Energy & Economy
|
|
54
|
+
|
|
55
|
+
Some maps contain **Sources** — harvestable energy deposits:
|
|
56
|
+
- Creeps with `WORK` parts can harvest energy
|
|
57
|
+
- Energy is carried with `CARRY` parts and deposited into the Spawn
|
|
58
|
+
- More energy = more creeps = stronger army
|
|
59
|
+
|
|
60
|
+
Maps without sources start with finite spawn energy, rewarding aggressive early plays.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Combat
|
|
65
|
+
|
|
66
|
+
### Attack Types
|
|
67
|
+
|
|
68
|
+
- **Melee (`attack`)** — 30 damage at range 1
|
|
69
|
+
- **Ranged (`rangedAttack`)** — 10 damage at range 1-3
|
|
70
|
+
- **Mass Attack (`rangedMassAttack`)** — Damages all enemies within range 3 (damage scales with distance)
|
|
71
|
+
|
|
72
|
+
### Healing
|
|
73
|
+
|
|
74
|
+
- **Close heal** — 12 HP at range 1
|
|
75
|
+
- **Ranged heal** — 4 HP at range 1-3
|
|
76
|
+
|
|
77
|
+
### Damage Resolution
|
|
78
|
+
|
|
79
|
+
All attacks and heals are processed simultaneously each tick. Body parts are destroyed as they take damage (100 HP per part). Creeps die when all body parts are destroyed.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Writing a Game Script
|
|
84
|
+
|
|
85
|
+
Write your game script in **`strategies/{model_name}/round_1.js`**
|
|
86
|
+
|
|
87
|
+
Your game script is a JavaScript file with a `loop()` function that runs every tick:
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
function loop() {
|
|
91
|
+
// Get all game objects
|
|
92
|
+
const myCreeps = getObjectsByPrototype(Creep).filter(c => c.my);
|
|
93
|
+
const enemySpawn = getObjectsByPrototype(StructureSpawn).find(s => !s.my);
|
|
94
|
+
const mySpawn = getObjectsByPrototype(StructureSpawn).find(s => s.my);
|
|
95
|
+
|
|
96
|
+
// Command your creeps
|
|
97
|
+
for (const creep of myCreeps) {
|
|
98
|
+
if (enemySpawn) {
|
|
99
|
+
creep.moveTo(enemySpawn);
|
|
100
|
+
creep.attack(enemySpawn);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Spawn new creeps
|
|
105
|
+
if (mySpawn && !mySpawn.spawning) {
|
|
106
|
+
mySpawn.spawnCreep([ATTACK, MOVE, MOVE]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Example Scripts
|
|
112
|
+
|
|
113
|
+
For complete working examples, see:
|
|
114
|
+
- **`example_strategies/example_1.js`** — Aggressive melee rush strategy using cheap, fast attackers
|
|
115
|
+
- **`example_strategies/example_2.js`** — Kiting ranged strategy that maintains distance and attacks from range
|
|
116
|
+
|
|
117
|
+
### Key API Functions
|
|
118
|
+
|
|
119
|
+
| Function | Description |
|
|
120
|
+
|----------|-------------|
|
|
121
|
+
| `getObjectsByPrototype(Type)` | Get all objects of a type (Creep, StructureSpawn, Source, etc.) |
|
|
122
|
+
| `getRange(a, b)` | Chebyshev distance between two positions |
|
|
123
|
+
| `findClosestByRange(obj, targets)` | Find nearest target by range |
|
|
124
|
+
| `findClosestByPath(obj, targets, opts)` | Find nearest reachable target |
|
|
125
|
+
| `findInRange(obj, targets, range)` | Find all targets within range |
|
|
126
|
+
| `getTerrainAt(pos)` | Get terrain type at position (TERRAIN_PLAIN, TERRAIN_SWAMP, TERRAIN_WALL) |
|
|
127
|
+
| `getDirection(dx, dy)` | Convert delta to direction constant (TOP, RIGHT, etc.) |
|
|
128
|
+
| `getTicks()` | Get current tick number |
|
|
129
|
+
| `getObjectById(id)` | Get a game object by its ID |
|
|
130
|
+
| `searchPath(origin, goal, opts)` | Advanced pathfinding with CostMatrix support |
|
|
131
|
+
|
|
132
|
+
### GameObject Methods
|
|
133
|
+
|
|
134
|
+
All game objects (creeps, spawns, sources) have these instance methods:
|
|
135
|
+
|
|
136
|
+
| Method | Description |
|
|
137
|
+
|--------|-------------|
|
|
138
|
+
| `obj.getRangeTo(target)` | Distance to target (same as `getRange(obj, target)`) |
|
|
139
|
+
| `obj.findInRange(objects, range)` | Find objects within range of this object |
|
|
140
|
+
| `obj.findClosestByRange(objects)` | Find closest object to this object |
|
|
141
|
+
| `obj.findClosestByPath(objects, opts)` | Find closest reachable object |
|
|
142
|
+
| `obj.findPathTo(target, opts)` | Get path array to target |
|
|
143
|
+
|
|
144
|
+
### Creep Actions
|
|
145
|
+
|
|
146
|
+
| Method | Range | Description |
|
|
147
|
+
|--------|-------|-------------|
|
|
148
|
+
| `move(direction)` | — | Move one tile in a direction |
|
|
149
|
+
| `moveTo(target, opts)` | — | Pathfind and move toward target |
|
|
150
|
+
| `attack(target)` | 1 | Melee attack (30 damage) |
|
|
151
|
+
| `rangedAttack(target)` | 1-3 | Ranged attack (10 damage) |
|
|
152
|
+
| `rangedMassAttack()` | 1-3 | AoE attack (10/4/1 damage at range 1/2/3) |
|
|
153
|
+
| `heal(target)` | 1 | Close heal (12 HP) |
|
|
154
|
+
| `rangedHeal(target)` | 1-3 | Ranged heal (4 HP) |
|
|
155
|
+
| `harvest(source)` | 1 | Gather energy (2 per WORK part) |
|
|
156
|
+
| `transfer(target, type)` | 1 | Give resources to structure/creep |
|
|
157
|
+
| `pull(target)` | 1 | Drag another creep when you move |
|
|
158
|
+
| `drop(type, amount)` | — | Drop resources on the ground |
|
|
159
|
+
| `pickup(resource)` | 1 | Pick up dropped resources |
|
|
160
|
+
|
|
161
|
+
### CostMatrix
|
|
162
|
+
|
|
163
|
+
Custom pathfinding costs for `moveTo()` and `searchPath()`:
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
const cm = new CostMatrix();
|
|
167
|
+
|
|
168
|
+
// Set terrain costs (0 = use default, 255 = unwalkable)
|
|
169
|
+
cm.set(x, y, 5); // Avoid this tile (higher = less preferred)
|
|
170
|
+
cm.set(x, y, 255); // Block this tile completely
|
|
171
|
+
|
|
172
|
+
// Use in pathfinding
|
|
173
|
+
creep.moveTo(target, { costMatrix: cm });
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Starting Conditions
|
|
179
|
+
|
|
180
|
+
Each player starts with:
|
|
181
|
+
|
|
182
|
+
- **1 Spawn** (5,000 HP, 500 energy)
|
|
183
|
+
- **3 Workers** — `[MOVE, MOVE, CARRY, CARRY, WORK, WORK]`
|
|
184
|
+
- **1 Melee** — `[ATTACK, ATTACK, MOVE, MOVE, TOUGH]`
|
|
185
|
+
|
|
186
|
+
The map has **4 Sources** (3,000 energy each) for harvesting.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Constants
|
|
191
|
+
|
|
192
|
+
These constants are available globally in your game script:
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
// Body parts
|
|
196
|
+
MOVE, WORK, CARRY, ATTACK, RANGED_ATTACK, HEAL, TOUGH
|
|
197
|
+
|
|
198
|
+
// Terrain
|
|
199
|
+
TERRAIN_PLAIN // 0
|
|
200
|
+
TERRAIN_WALL // 1
|
|
201
|
+
TERRAIN_SWAMP // 2
|
|
202
|
+
|
|
203
|
+
// Directions
|
|
204
|
+
TOP, TOP_RIGHT, RIGHT, BOTTOM_RIGHT, BOTTOM, BOTTOM_LEFT, LEFT, TOP_LEFT
|
|
205
|
+
|
|
206
|
+
// Resources
|
|
207
|
+
RESOURCE_ENERGY
|
|
208
|
+
|
|
209
|
+
// Prototypes (for getObjectsByPrototype)
|
|
210
|
+
Creep, StructureSpawn, Source
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Match Flow
|
|
216
|
+
|
|
217
|
+
1. Both players start with a Spawn and starting units (varies by map)
|
|
218
|
+
2. Each tick, both `loop()` functions execute simultaneously
|
|
219
|
+
3. All intents (move, attack, spawn, etc.) are collected and processed
|
|
220
|
+
4. Game state updates; damaged units lose HP, new creeps spawn
|
|
221
|
+
5. Repeat until a Spawn dies or tick limit is reached
|
|
222
|
+
|
|
223
|
+
**Default tick limit:** 2000 ticks
|
|
224
|
+
|
|
225
|
+
---
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const FIRST_ROUND_PROMPT = `<file path="OBJECTIVE.md">
|
|
2
|
+
{OBJECTIVE_MD}
|
|
3
|
+
</file>
|
|
4
|
+
|
|
5
|
+
Based on the OBJECTIVE.md above, create a game script at strategies/{model_name}/round_1.js
|
|
6
|
+
|
|
7
|
+
Design a winning strategy and write the complete JavaScript file.`;
|
|
8
|
+
|
|
9
|
+
export const NEXT_ROUND_PROMPT = `<file path="OBJECTIVE.md">
|
|
10
|
+
{OBJECTIVE_MD}
|
|
11
|
+
</file>
|
|
12
|
+
|
|
13
|
+
<file path="NEXT_ROUND.md">
|
|
14
|
+
{NEXT_ROUND_MD}
|
|
15
|
+
</file>
|
|
16
|
+
|
|
17
|
+
Review the match results from round_{prev_round}, then create your improved strategy at strategies/{model_name}/round_{round}.js`;
|
|
18
|
+
|
|
19
|
+
export const FINAL_ROUND_PROMPT = `<file path="OBJECTIVE.md">
|
|
20
|
+
{OBJECTIVE_MD}
|
|
21
|
+
</file>
|
|
22
|
+
|
|
23
|
+
<file path="NEXT_ROUND.md">
|
|
24
|
+
{NEXT_ROUND_MD}
|
|
25
|
+
</file>
|
|
26
|
+
|
|
27
|
+
This is the FINAL ROUND. Review results from round_4, then create your best strategy at strategies/{model_name}/round_5.js
|
|
28
|
+
|
|
29
|
+
Maximize your chances of winning.`;
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export const VALIDATION_ERROR_PROMPT = `Your script strategies/{model_name}/round_{n}.js failed validation with the following error:
|
|
33
|
+
|
|
34
|
+
{error}
|
|
35
|
+
|
|
36
|
+
Please fix the script and save it again.`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "skirmish"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "LLM Skirmish, an adversarial in-context learning benchmark"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Kai McPheeters", email = "kai@mcpheeters.us" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=8.0"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://llmskirmish.com"
|
|
20
|
+
Repository = "https://github.com/llmskirmish/skirmish-py"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/skirmish"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Skirmish - Python bindings for LLM Skirmish.
|
|
3
|
+
|
|
4
|
+
Run matches between javascript strategies and collect results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__version__ = "0.0.1"
|
|
15
|
+
__all__ = ["EndReason", "MatchResult", "Scores", "Winner", "run_match"]
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Scores:
|
|
19
|
+
"""Final scores for both players."""
|
|
20
|
+
player1: int
|
|
21
|
+
player2: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Winner(str, Enum):
|
|
25
|
+
"""Match outcome - who won."""
|
|
26
|
+
PLAYER1 = "player1"
|
|
27
|
+
PLAYER2 = "player2"
|
|
28
|
+
DRAW = "draw" # very rare, requires both EndReason.TIMEOUT and MatchResult.scores to be equal
|
|
29
|
+
ERROR = "error"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EndReason(str, Enum):
|
|
33
|
+
"""Why the match ended."""
|
|
34
|
+
ELIMINATION = "elimination" # All enemy spawn was destroyed
|
|
35
|
+
TIMEOUT = "timeout" # Max ticks reached, winner by score
|
|
36
|
+
ERROR = "error" # Match failed to run (see error field)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class MatchResult:
|
|
41
|
+
"""Immutable result of a completed match."""
|
|
42
|
+
winner: Winner
|
|
43
|
+
reason: EndReason
|
|
44
|
+
ticks: int
|
|
45
|
+
scores: Scores
|
|
46
|
+
error: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_jsonl(output: str) -> Iterator[dict]:
|
|
50
|
+
"""Parse JSONL output, silently skipping malformed lines."""
|
|
51
|
+
for line in output.strip().split("\n"):
|
|
52
|
+
if not line:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
yield json.loads(line)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_result(output: str) -> tuple[Winner, EndReason, int, Scores]:
|
|
61
|
+
"""Extract (winner, reason, ticks, scores) from CLI JSONL output."""
|
|
62
|
+
winner = Winner.DRAW
|
|
63
|
+
reason = EndReason.TIMEOUT
|
|
64
|
+
ticks = 0
|
|
65
|
+
scores = Scores(0, 0)
|
|
66
|
+
|
|
67
|
+
for data in _parse_jsonl(output):
|
|
68
|
+
msg_type = data.get("type")
|
|
69
|
+
if msg_type == "tick":
|
|
70
|
+
ticks = data.get("tick", ticks)
|
|
71
|
+
elif msg_type == "result":
|
|
72
|
+
victory = data.get("victory", {})
|
|
73
|
+
raw_winner = victory.get("winner")
|
|
74
|
+
winner = Winner.PLAYER1 if raw_winner == "player1" else Winner.PLAYER2 if raw_winner == "player2" else Winner.DRAW
|
|
75
|
+
reason = EndReason(victory.get("reason", "timeout"))
|
|
76
|
+
raw_scores = victory.get("scores", {})
|
|
77
|
+
scores = Scores(raw_scores.get("player1", 0), raw_scores.get("player2", 0))
|
|
78
|
+
|
|
79
|
+
return winner, reason, ticks, scores
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _make_error(error_msg: str) -> MatchResult:
|
|
83
|
+
"""Create an error result."""
|
|
84
|
+
return MatchResult(Winner.ERROR, EndReason.ERROR, 0, Scores(0, 0), error_msg)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_match(p1: str | Path, p2: str | Path, map_name: str = "swamp", max_ticks: int = 2000, timeout: float = 120.0) -> MatchResult:
|
|
88
|
+
"""Run a match between two JS bot strategies (p1/p2 are file paths)."""
|
|
89
|
+
cmd = [
|
|
90
|
+
"skirmish", "run",
|
|
91
|
+
"--p1", str(p1), "--p2", str(p2),
|
|
92
|
+
"--map", map_name, "--max-ticks", str(max_ticks), "--stdout",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
97
|
+
except FileNotFoundError:
|
|
98
|
+
return _make_error("skirmish CLI not found. Install: npm i -g @llmskirmish/skirmish")
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
return _make_error(f"Match timed out after {timeout}s")
|
|
101
|
+
|
|
102
|
+
if proc.returncode != 0:
|
|
103
|
+
return _make_error(proc.stderr.strip() or f"Exit code {proc.returncode}")
|
|
104
|
+
|
|
105
|
+
winner, reason, ticks, scores = _extract_result(proc.stdout)
|
|
106
|
+
return MatchResult(winner, reason, ticks, scores)
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Swarm - Aggressive melee rush strategy
|
|
3
|
+
*
|
|
4
|
+
* Strategy: Spawn cheap, fast melee units and rush the enemy.
|
|
5
|
+
* Always be moving, always be attacking. Overwhelm with numbers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function loop() {
|
|
9
|
+
const myCreeps = getObjectsByPrototype(Creep).filter(c => c.my && !c.spawning);
|
|
10
|
+
const enemies = getObjectsByPrototype(Creep).filter(c => !c.my && !c.spawning);
|
|
11
|
+
const mySpawn = getObjectsByPrototype(StructureSpawn).find(s => s.my);
|
|
12
|
+
const enemySpawn = getObjectsByPrototype(StructureSpawn).find(s => !s.my);
|
|
13
|
+
|
|
14
|
+
// Run each creep
|
|
15
|
+
for (const creep of myCreeps) {
|
|
16
|
+
runCreep(creep, enemies, enemySpawn, mySpawn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Spawn new creeps
|
|
20
|
+
if (mySpawn && !mySpawn.spawning) {
|
|
21
|
+
// Cheap and fast melee attackers
|
|
22
|
+
mySpawn.spawnCreep([ATTACK, ATTACK, MOVE, MOVE]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runCreep(creep, enemies, enemySpawn, mySpawn) {
|
|
27
|
+
// Check body parts: creep.body is an array of {type, hits} objects
|
|
28
|
+
const isMelee = creep.body.some(p => p.type === ATTACK);
|
|
29
|
+
if (!isMelee) return; // Skip non-combat creeps (e.g., workers)
|
|
30
|
+
|
|
31
|
+
// Priority 1: Attack adjacent enemies
|
|
32
|
+
const adjacentEnemy = findClosestByRange(creep, enemies.filter(e => getRange(creep, e) <= 1));
|
|
33
|
+
if (adjacentEnemy) {
|
|
34
|
+
creep.attack(adjacentEnemy);
|
|
35
|
+
// Stay on them if they're still alive
|
|
36
|
+
creep.moveTo(adjacentEnemy);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Priority 2: Attack enemy spawn if in range
|
|
41
|
+
if (enemySpawn && getRange(creep, enemySpawn) <= 1) {
|
|
42
|
+
creep.attack(enemySpawn);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Priority 3: Find and chase the nearest enemy
|
|
47
|
+
const nearestEnemy = findClosestByRange(creep, enemies);
|
|
48
|
+
if (nearestEnemy) {
|
|
49
|
+
creep.moveTo(nearestEnemy);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Priority 4: Attack the enemy spawn
|
|
54
|
+
if (enemySpawn) {
|
|
55
|
+
creep.moveTo(enemySpawn);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback: Patrol around our spawn (should rarely happen)
|
|
60
|
+
if (mySpawn) {
|
|
61
|
+
const tick = getTicks();
|
|
62
|
+
const angle = (tick / 20 + creep.id.charCodeAt(0)) % (2 * Math.PI);
|
|
63
|
+
creep.moveTo({
|
|
64
|
+
x: Math.round(mySpawn.x + Math.cos(angle) * 5),
|
|
65
|
+
y: Math.round(mySpawn.y + Math.sin(angle) * 5)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Rangers - Kiting ranged attackers
|
|
3
|
+
*
|
|
4
|
+
* Strategy: Keep distance from enemies, attack from range.
|
|
5
|
+
* Kite backwards when enemies get close, always maintain range 3.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function loop() {
|
|
9
|
+
const myCreeps = getObjectsByPrototype(Creep).filter(c => c.my && !c.spawning);
|
|
10
|
+
const enemies = getObjectsByPrototype(Creep).filter(c => !c.my && !c.spawning);
|
|
11
|
+
const mySpawn = getObjectsByPrototype(StructureSpawn).find(s => s.my);
|
|
12
|
+
const enemySpawn = getObjectsByPrototype(StructureSpawn).find(s => !s.my);
|
|
13
|
+
|
|
14
|
+
// Run each creep
|
|
15
|
+
for (const creep of myCreeps) {
|
|
16
|
+
runCreep(creep, enemies, enemySpawn, mySpawn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Spawn new creeps
|
|
20
|
+
if (mySpawn && !mySpawn.spawning) {
|
|
21
|
+
// Ranged attackers with good mobility
|
|
22
|
+
mySpawn.spawnCreep([RANGED_ATTACK, RANGED_ATTACK, MOVE, MOVE]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runCreep(creep, enemies, enemySpawn, mySpawn) {
|
|
27
|
+
const nearestEnemy = findClosestByRange(creep, enemies);
|
|
28
|
+
const enemyRange = nearestEnemy ? getRange(creep, nearestEnemy) : Infinity;
|
|
29
|
+
|
|
30
|
+
// Priority 1: Attack enemies in range (range 3 for ranged attack)
|
|
31
|
+
if (nearestEnemy && enemyRange <= 3) {
|
|
32
|
+
creep.rangedAttack(nearestEnemy);
|
|
33
|
+
|
|
34
|
+
// Kite: If enemy is too close (range 2 or less), back away
|
|
35
|
+
if (enemyRange <= 2) {
|
|
36
|
+
moveAway(creep, nearestEnemy);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// At perfect range (3), hold position or find more targets
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Priority 2: Attack enemy spawn if in range
|
|
45
|
+
if (enemySpawn && getRange(creep, enemySpawn) <= 3) {
|
|
46
|
+
creep.rangedAttack(enemySpawn);
|
|
47
|
+
// Back off if enemies are nearby
|
|
48
|
+
if (nearestEnemy && enemyRange <= 4) {
|
|
49
|
+
moveAway(creep, nearestEnemy);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Priority 3: Approach nearest enemy to get in range
|
|
55
|
+
if (nearestEnemy) {
|
|
56
|
+
creep.moveTo(nearestEnemy);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Priority 4: Attack the enemy spawn
|
|
61
|
+
if (enemySpawn) {
|
|
62
|
+
creep.moveTo(enemySpawn);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback: Patrol around our spawn
|
|
67
|
+
if (mySpawn) {
|
|
68
|
+
const tick = getTicks();
|
|
69
|
+
const angle = (tick / 20 + creep.id.charCodeAt(0)) % (2 * Math.PI);
|
|
70
|
+
creep.moveTo({
|
|
71
|
+
x: Math.round(mySpawn.x + Math.cos(angle) * 5),
|
|
72
|
+
y: Math.round(mySpawn.y + Math.sin(angle) * 5)
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function moveAway(creep, target) {
|
|
78
|
+
// Move in the opposite direction from the target
|
|
79
|
+
const dx = creep.x - target.x;
|
|
80
|
+
const dy = creep.y - target.y;
|
|
81
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
82
|
+
|
|
83
|
+
creep.moveTo({
|
|
84
|
+
x: Math.round(creep.x + (dx / dist) * 3),
|
|
85
|
+
y: Math.round(creep.y + (dy / dist) * 3)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for run_match using example strategies."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from skirmish import EndReason, MatchResult, Scores, Winner, run_match
|
|
8
|
+
|
|
9
|
+
STRATEGIES = files("skirmish.strategies")
|
|
10
|
+
EXAMPLE_1 = STRATEGIES / "example_1.js"
|
|
11
|
+
EXAMPLE_2 = STRATEGIES / "example_2.js"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="module")
|
|
15
|
+
def cli_available() -> bool:
|
|
16
|
+
"""Check if skirmish CLI is installed."""
|
|
17
|
+
import shutil
|
|
18
|
+
return shutil.which("skirmish") is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def require_cli(cli_available: bool) -> None:
|
|
23
|
+
"""Skip test if CLI not available."""
|
|
24
|
+
if not cli_available:
|
|
25
|
+
pytest.skip("skirmish CLI not installed")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestRunMatch:
|
|
29
|
+
"""Tests for run_match function."""
|
|
30
|
+
|
|
31
|
+
def test_returns_match_result(self, require_cli: None) -> None:
|
|
32
|
+
"""run_match returns a MatchResult."""
|
|
33
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=100)
|
|
34
|
+
assert isinstance(result, MatchResult)
|
|
35
|
+
|
|
36
|
+
def test_result_has_winner(self, require_cli: None) -> None:
|
|
37
|
+
"""Result has a valid winner."""
|
|
38
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=100)
|
|
39
|
+
assert result.winner in (Winner.PLAYER1, Winner.PLAYER2, Winner.DRAW)
|
|
40
|
+
|
|
41
|
+
def test_result_has_reason(self, require_cli: None) -> None:
|
|
42
|
+
"""Result has a valid end reason."""
|
|
43
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=100)
|
|
44
|
+
assert result.reason in (EndReason.ELIMINATION, EndReason.TIMEOUT)
|
|
45
|
+
|
|
46
|
+
def test_result_has_scores(self, require_cli: None) -> None:
|
|
47
|
+
"""Result has scores for both players."""
|
|
48
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=100)
|
|
49
|
+
assert isinstance(result.scores, Scores)
|
|
50
|
+
assert isinstance(result.scores.player1, int)
|
|
51
|
+
assert isinstance(result.scores.player2, int)
|
|
52
|
+
|
|
53
|
+
def test_result_has_ticks(self, require_cli: None) -> None:
|
|
54
|
+
"""Result has tick count."""
|
|
55
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=100)
|
|
56
|
+
assert isinstance(result.ticks, int)
|
|
57
|
+
assert 0 <= result.ticks <= 100
|
|
58
|
+
|
|
59
|
+
def test_max_ticks_respected(self, require_cli: None) -> None:
|
|
60
|
+
"""Match ends at or before max_ticks."""
|
|
61
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2, max_ticks=50)
|
|
62
|
+
assert result.ticks <= 50
|
|
63
|
+
|
|
64
|
+
def test_same_strategy_vs_itself(self, require_cli: None) -> None:
|
|
65
|
+
"""Same strategy can play against itself."""
|
|
66
|
+
result = run_match(EXAMPLE_1, EXAMPLE_1, max_ticks=100)
|
|
67
|
+
assert isinstance(result, MatchResult)
|
|
68
|
+
assert result.reason != EndReason.ERROR
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestRunMatchErrors:
|
|
72
|
+
"""Tests for error handling."""
|
|
73
|
+
|
|
74
|
+
def test_missing_cli_returns_error(self) -> None:
|
|
75
|
+
"""Missing CLI returns error result, not exception."""
|
|
76
|
+
import subprocess
|
|
77
|
+
import skirmish
|
|
78
|
+
|
|
79
|
+
# Temporarily break the CLI lookup by monkeypatching
|
|
80
|
+
original_run = subprocess.run
|
|
81
|
+
|
|
82
|
+
def fake_run(*args, **kwargs):
|
|
83
|
+
raise FileNotFoundError("skirmish")
|
|
84
|
+
|
|
85
|
+
subprocess.run = fake_run
|
|
86
|
+
try:
|
|
87
|
+
result = run_match(EXAMPLE_1, EXAMPLE_2)
|
|
88
|
+
assert result.winner == Winner.ERROR
|
|
89
|
+
assert result.reason == EndReason.ERROR
|
|
90
|
+
assert "not found" in result.error.lower()
|
|
91
|
+
finally:
|
|
92
|
+
subprocess.run = original_run
|
|
93
|
+
|
|
94
|
+
def test_nonexistent_file_returns_error(self, require_cli: None) -> None:
|
|
95
|
+
"""Nonexistent strategy file returns error."""
|
|
96
|
+
result = run_match("/nonexistent/path.js", EXAMPLE_1)
|
|
97
|
+
assert result.winner == Winner.ERROR
|
|
98
|
+
assert result.reason == EndReason.ERROR
|