Repository navigation
Expand file tree
/
Copy pathstep4_attack.py
More file actions
313 lines (260 loc) · 14.3 KB
/
Copy pathstep4_attack.py
File metadata and controls
313 lines (260 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
#!/usr/bin/env python3
"""NetRTS tutorial, step 4: attack and win.
Step 3's economy and army, plus: once ATTACK_WITH soldiers are ready, every idle
soldier attacks the closest enemy it can see, or attack-moves to the enemy's
start position (where its Command Center is). At the end it prints the result.
This is the finished tutorial bot; it reliably beats the house bot "sitter".
python step4_attack.py --name my-bot
Only the Python standard library is used.
"""
import argparse
import json
import os
import urllib.error
import urllib.request
SERVER = "http://localhost:5080"
API_KEY = None # filled in by get_api_key()
# --------------------------------------------------------------------------- HTTP helper
class ApiError(Exception):
"""The server answered with an error, e.g. 409 {"code": "MATCH_NOT_STARTED", ...}."""
def __init__(self, status, code, message):
super().__init__(f"{status} {code}: {message}")
self.status, self.code = status, code
def api(method, path, body=None):
"""Send one request to the server and return the decoded JSON reply (or None)."""
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(SERVER + path, data=data, method=method)
request.add_header("Content-Type", "application/json")
if API_KEY:
request.add_header("Authorization", f"Bearer {API_KEY}")
try:
# A long poll can be held open for up to 30 s, so allow a generous timeout.
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
try:
error = json.loads(e.read())
except ValueError:
error = {}
raise ApiError(e.code, error.get("code", "HTTP_ERROR"), error.get("message", e.reason)) from None
# --------------------------------------------------------------------------- setup
def get_api_key(name):
"""Reuse the key saved in <name>.key, or register the name and save the new key."""
key_file = f"{name}.key"
if os.path.exists(key_file):
with open(key_file) as f:
return f.read().strip()
reply = api("POST", "/api/v1/players", {"name": name})
with open(key_file, "w") as f:
f.write(reply["apiKey"])
print(f"Registered '{name}'. API key saved to {key_file}")
return reply["apiKey"]
def create_match(opponent, tick_ms=None, max_ticks=None, seed=None):
settings = {}
if tick_ms:
settings["tickIntervalMs"] = tick_ms
if max_ticks:
settings["maxTicks"] = max_ticks
if seed is not None:
settings["seed"] = seed
match = api("POST", "/api/v1/matches", {"houseBots": [opponent], "settings": settings})
print(f"Match {match['matchId']} created against '{opponent}'")
print(f"Watch it at {SERVER}/#/match/{match['matchId']}")
return match["matchId"]
def wait_for_start(match_id):
"""waitForTick=0 blocks until the match has started (409 MATCH_NOT_STARTED means: ask again)."""
while True:
try:
return api("GET", f"/api/v1/matches/{match_id}/state?waitForTick=0")
except ApiError as e:
if e.code != "MATCH_NOT_STARTED":
raise
# --------------------------------------------------------------------------- strategy
TARGET_WORKERS = 12
COST = {"Worker": 50, "Soldier": 100, "Barracks": 150} # GET /api/v1/rules has every cost
terrain = [] # the map's rows: '.' = open ground, '#' = rock (loaded once in play())
barracks_ordered_at = None # tick when we last sent a worker to build a Barracks
barracks_builder_id = None # the worker we sent to build it
ATTACK_WITH = 6 # soldiers to gather before the first attack
attacking = False # becomes True once, and stays True
def distance(ax, ay, bx, by):
"""Tiles between two points, counting diagonal steps as 1 (the game's own measure)."""
return max(abs(ax - bx), abs(ay - by))
def find_build_site(command_center, state):
"""An open tile 3-6 tiles from the Command Center that doesn't get in the miners' way."""
taken = {(b["x"], b["y"]) for b in state["buildings"]}
ore = [(d["x"], d["y"]) for d in state["resources"]]
cx, cy = command_center["x"], command_center["y"]
best, best_score = None, None
for y in range(cy - 6, cy + 7):
for x in range(cx - 6, cx + 7):
if not (0 <= x < state["mapWidth"] and 0 <= y < state["mapHeight"]):
continue # off the map
if terrain[y][x] != "." or (x, y) in taken:
continue # rock, or a building is already there
d = distance(x, y, cx, cy)
if d < 3 or d > 6:
continue # too close (blocks the CC) or too far
to_ore = min((distance(x, y, ox, oy) for ox, oy in ore), default=99)
if to_ore <= 1:
continue # on or right next to a deposit: workers need that space
score = d - min(to_ore, 4) # close to the CC, but away from the ore
if best is None or score < best_score:
best, best_score = (x, y), score
return best
def enemy_base(state, x, y):
"""Start position of the nearest opponent still in the game: its Command Center is there."""
me = state["you"]["slot"]
rivals = [p for p in state["players"] if p["slot"] != me and not p["eliminated"]]
if rivals:
p = min(rivals, key=lambda p: distance(x, y, p["startPosition"]["x"], p["startPosition"]["y"]))
return p["startPosition"]["x"], p["startPosition"]["y"]
return state["mapWidth"] // 2, state["mapHeight"] // 2
def decide(state):
"""Look at this tick's state and return a list of commands to send."""
global barracks_ordered_at, barracks_builder_id, attacking
me = state["you"]["slot"]
tick = state["tick"]
budget = state["you"]["resources"] # ore we haven't promised to anything yet this tick
workers = [u for u in state["units"] if u["owner"] == me and u["type"] == "Worker"]
my_buildings = [b for b in state["buildings"] if b["owner"] == me]
command_center = next((b for b in my_buildings if b["type"] == "CommandCenter"), None)
barracks = [b for b in my_buildings if b["type"] == "Barracks"] # finished or under construction
commands = []
# 1. Build one Barracks once 6 workers are mining. Until it exists, save ore for it.
saving_for_barracks = command_center is not None and not barracks and len(workers) >= 6
recently_ordered = barracks_ordered_at is not None and tick - barracks_ordered_at < 30
if saving_for_barracks and not recently_ordered and budget >= COST["Barracks"]:
site = find_build_site(command_center, state)
if site:
# The worker closest to the CC and not carrying ore makes the best builder.
builder = min(workers, key=lambda w: (w["carrying"], distance(w["x"], w["y"],
command_center["x"], command_center["y"])))
commands.append({"type": "Build", "unitIds": [builder["id"]], "buildingType": "Barracks",
"x": site[0], "y": site[1]})
budget -= COST["Barracks"]
barracks_ordered_at = tick
barracks_builder_id = builder["id"]
workers.remove(builder) # don't send the builder off mining in step 2 below
print(f"tick {tick}: worker {builder['id']} will build a Barracks at {site}")
# 2. Idle workers go mining. Count how many workers already mine each deposit and
# prefer close deposits with few miners, so the workers spread out.
deposits = state["resources"] # only the deposits we can currently see
if deposits:
miners = {d["id"]: 0 for d in deposits}
for w in workers:
if w["targetId"] in miners:
miners[w["targetId"]] += 1
for w in workers:
if w["activity"] != "Idle":
continue
best = min(deposits, key=lambda d: distance(w["x"], w["y"], d["x"], d["y"]) + 3 * miners[d["id"]])
miners[best["id"]] += 1
commands.append({"type": "Gather", "unitIds": [w["id"]], "targetId": best["id"]})
# 3. Train more workers, one or two at a time, until we have TARGET_WORKERS
# (but not while we're saving up for the Barracks).
if command_center and command_center["completed"] and not saving_for_barracks:
queued = len(command_center["production"])
if len(workers) + queued < TARGET_WORKERS and queued < 2 and budget >= COST["Worker"]:
commands.append({"type": "Produce", "buildingId": command_center["id"], "unitType": "Worker"})
budget -= COST["Worker"]
# 4. Every finished Barracks keeps two Soldiers in its queue.
for b in barracks:
if b["completed"] and len(b["production"]) < 2 and budget >= COST["Soldier"]:
commands.append({"type": "Produce", "buildingId": b["id"], "unitType": "Soldier"})
budget -= COST["Soldier"]
# 5. Attack! Wait until we have ATTACK_WITH soldiers, then never stop: every soldier
# that is idle (new from the Barracks, or its target died) gets a new order.
soldiers = [u for u in state["units"] if u["owner"] == me and u["type"] == "Soldier"]
if len(soldiers) >= ATTACK_WITH:
attacking = True
idle_soldiers = [s for s in soldiers if s["activity"] == "Idle"]
if attacking and idle_soldiers:
ids = [s["id"] for s in idle_soldiers]
# Enemy units and buildings we can see right now (fog of war hides the rest).
enemies = [e for e in state["units"] + state["buildings"] if e["owner"] != me]
if enemies:
leader = idle_soldiers[0]
target = min(enemies, key=lambda e: distance(leader["x"], leader["y"], e["x"], e["y"]))
commands.append({"type": "Attack", "unitIds": ids, "targetId": target["id"]})
else:
# Attack-move: walk to the enemy base, fighting anything seen on the way.
x, y = enemy_base(state, idle_soldiers[0]["x"], idle_soldiers[0]["y"])
commands.append({"type": "Attack", "unitIds": ids, "x": x, "y": y})
return commands
def send(match_id, commands):
"""POST the commands and report any the server rejected straight away."""
reply = api("POST", f"/api/v1/matches/{match_id}/commands", {"commands": commands})
for result in reply["results"]:
if not result["accepted"]:
command = commands[result["index"]]
print(f" rejected {command['type']}: {result['error']['code']} - {result['error']['message']}")
def handle_events(state):
"""React to what happened since the last tick."""
global barracks_ordered_at
for event in state["events"]:
if event["kind"] == "CommandFailed":
# Accepted earlier, but it couldn't be carried out when the time came.
print(f"tick {event['tick']}: command failed: {event['message']}")
if event["entityId"] == barracks_builder_id:
barracks_ordered_at = None # our builder gave up: try again
elif event["kind"] in ("BuildingStarted", "BuildingCompleted", "UnitLost",
"BuildingDestroyed", "PlayerEliminated"):
print(f"tick {event['tick']}: {event['message']}")
# --------------------------------------------------------------------------- the loop
def play(match_id):
global terrain
state = wait_for_start(match_id)
# The terrain never changes, so fetch it once. It only exists once the match has started.
terrain = api("GET", f"/api/v1/matches/{match_id}/map")["terrain"]
print(f"Started! We are slot {state['you']['slot']} on a {state['mapWidth']}x{state['mapHeight']} map")
while state["status"] != "Completed":
handle_events(state)
commands = decide(state)
if commands:
send(match_id, commands)
if state["tick"] % 10 == 0:
me = state["you"]["slot"]
mine = [u for u in state["units"] if u["owner"] == me]
workers = sum(1 for u in mine if u["type"] == "Worker")
soldiers = sum(1 for u in mine if u["type"] == "Soldier")
print(f"tick {state['tick']:4}: ore {state['you']['resources']:5}, "
f"{workers} workers, {soldiers} soldiers")
# Wait for the next tick. sinceTick=<tick> means "only events newer than this tick".
tick = state["tick"]
state = api("GET", f"/api/v1/matches/{match_id}/state?waitForTick={tick + 1}&sinceTick={tick}")
handle_events(state) # the final tick's events
print_result(match_id)
def print_result(match_id):
"""GET /result: the winner, why the match ended, and everyone's score and stats."""
result = api("GET", f"/api/v1/matches/{match_id}/result")
outcome = result["outcome"]
winner = next((p["name"] for p in result["players"] if p["winner"]), "nobody (draw)")
print(f"\nMatch over after {outcome['ticks']} ticks ({outcome['reason']}). Winner: {winner}")
for p in result["players"]:
score = p["score"]
print(f" {p['name']:<16} score {score['total']:>6} "
f"(destruction {score['destruction']}, economy {score['economy']}, survival {score['survival']})"
f" units made {p['unitsProduced']}, lost {p['unitsLost']}, killed {p['unitsKilled']}")
def main():
global SERVER, API_KEY
parser = argparse.ArgumentParser(description="NetRTS tutorial bot")
parser.add_argument("--name", required=True, help="your bot's name (3-32 letters, digits, _ or -)")
parser.add_argument("--server", default=SERVER)
parser.add_argument("--vs", default="sitter", help="house bot to play against")
parser.add_argument("--tick-ms", type=int, help="milliseconds per tick (default 1000, minimum 100)")
parser.add_argument("--max-ticks", type=int, help="end the match after this many ticks (default 1800)")
parser.add_argument("--seed", type=int, help="map seed, to replay the same map")
args = parser.parse_args()
SERVER = args.server.rstrip("/")
API_KEY = get_api_key(args.name)
match_id = create_match(args.vs, args.tick_ms, args.max_ticks, args.seed)
try:
play(match_id)
except KeyboardInterrupt:
# Don't leave a half-played match running: concede it.
api("POST", f"/api/v1/matches/{match_id}/surrender")
print("\nSurrendered.")
if __name__ == "__main__":
main()