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.
@@ -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
+
@@ -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
@@ -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