forked from mirrors/0ad
Add an interface for Reinforcement Learning.
Implement a simple HTTP server to start games, receive the gamestate and pass commands to the simulation. This is mainly intended for training reinforcement learning agents in 0 AD. As such, a python client and a small example are included. This option can be enabled using the -rl-interface flag. Patch by: irishninja Reviewed By: wraitii, Itms Fixes #5548 Differential Revision: https://code.wildfiregames.com/D2199 This was SVN commit r23917.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# 0 AD Python Client
|
||||
This directory contains `zero_ad`, a python client for 0 AD which enables users to control the environment headlessly.
|
||||
|
||||
## Installation
|
||||
`zero_ad` can be installed with `pip` by running the following from the current directory:
|
||||
```
|
||||
pip install .
|
||||
```
|
||||
|
||||
Development dependencies can be installed with `pip install -r requirements-dev.txt`. Tests are using pytest and can be run with `python -m pytest`.
|
||||
|
||||
## Basic Usage
|
||||
If there is not a running instance of 0 AD, first start 0 AD with the RL interface enabled:
|
||||
```
|
||||
pyrogenesis --rl-interface=127.0.0.1:6000
|
||||
```
|
||||
|
||||
Next, the python client can be connected with:
|
||||
```
|
||||
import zero_ad
|
||||
from zero_ad import ZeroAD
|
||||
|
||||
game = ZeroAD('http://localhost:6000')
|
||||
```
|
||||
|
||||
A map can be loaded with:
|
||||
|
||||
```
|
||||
with open('./samples/arcadia.json', 'r') as f:
|
||||
arcadia_config = f.read()
|
||||
|
||||
state = game.reset(arcadia_config)
|
||||
```
|
||||
|
||||
where `./samples/arcadia.json` is the path to a game configuration JSON (included in the first line of the commands.txt file in a game replay directory) and `state` contains the initial game state for the given map. The game engine can be stepped (optionally applying actions at each step) with:
|
||||
|
||||
```
|
||||
state = game.step()
|
||||
```
|
||||
|
||||
For example, enemy units could be attacked with:
|
||||
|
||||
```
|
||||
my_units = state.units(owner=1)
|
||||
enemy_units = state.units(owner=2)
|
||||
actions = [zero_ad.actions.attack(my_units, enemy_units[0])]
|
||||
state = game.step(actions)
|
||||
```
|
||||
|
||||
For a more thorough example, check out samples/simple-example.py!
|
||||
@@ -0,0 +1 @@
|
||||
pytest
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"settings": {
|
||||
"TriggerScripts": [
|
||||
"scripts/TriggerHelper.js",
|
||||
"scripts/ConquestCommon.js",
|
||||
"scripts/ConquestUnits.js"
|
||||
],
|
||||
"VictoryConditions": [
|
||||
"conquest_units"
|
||||
],
|
||||
"Name": "Arcadia",
|
||||
"mapType": "scenario",
|
||||
"AISeed": 0,
|
||||
"Seed": 0,
|
||||
"CheatsEnabled": true,
|
||||
"Ceasefire": 0,
|
||||
"WonderDuration": 10,
|
||||
"RelicDuration": 10,
|
||||
"RelicCount": 2,
|
||||
"Size": 256,
|
||||
"PlayerData": [
|
||||
{
|
||||
"Name": "Player 1",
|
||||
"Civ": "spart",
|
||||
"Color": {
|
||||
"r": 150,
|
||||
"g": 20,
|
||||
"b": 20
|
||||
},
|
||||
"AI": "",
|
||||
"AIDiff": 3,
|
||||
"AIBehavior": "random",
|
||||
"Team": 1
|
||||
},
|
||||
{
|
||||
"Name": "Player 2",
|
||||
"Civ": "spart",
|
||||
"Color": {
|
||||
"r": 150,
|
||||
"g": 20,
|
||||
"b": 20
|
||||
},
|
||||
"AI": "",
|
||||
"AIDiff": 3,
|
||||
"AIBehavior": "random",
|
||||
"Team": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapType": "scenario",
|
||||
"map": "maps/scenarios/arcadia",
|
||||
"gameSpeed": 1
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# This script provides an overview of the zero_ad wrapper for 0 AD
|
||||
from os import path
|
||||
import zero_ad
|
||||
|
||||
# First, we will define some helper functions we will use later.
|
||||
import math
|
||||
def dist (p1, p2):
|
||||
return math.sqrt(sum((math.pow(x2 - x1, 2) for (x1, x2) in zip(p1, p2))))
|
||||
|
||||
def center(units):
|
||||
sum_position = map(sum, zip(*map(lambda u: u.position(), units)))
|
||||
return [x/len(units) for x in sum_position]
|
||||
|
||||
def closest(units, position):
|
||||
dists = (dist(unit.position(), position) for unit in units)
|
||||
index = 0
|
||||
min_dist = next(dists)
|
||||
for (i, d) in enumerate(dists):
|
||||
if d < min_dist:
|
||||
index = i
|
||||
min_dist = d
|
||||
|
||||
return units[index]
|
||||
|
||||
# Connect to a 0 AD game server listening at localhost:6000
|
||||
game = zero_ad.ZeroAD('http://localhost:6000')
|
||||
|
||||
# Load the Arcadia map
|
||||
samples_dir = path.dirname(path.realpath(__file__))
|
||||
scenario_config_path = path.join(samples_dir, 'arcadia.json')
|
||||
with open(scenario_config_path, 'r') as f:
|
||||
arcadia_config = f.read()
|
||||
|
||||
state = game.reset(arcadia_config)
|
||||
|
||||
# The game is paused and will only progress upon calling "step"
|
||||
state = game.step()
|
||||
|
||||
# Units can be queried from the game state
|
||||
citizen_soldiers = state.units(owner=1, type='infantry')
|
||||
# (including gaia units like trees or other resources)
|
||||
nearby_tree = closest(state.units(owner=0, type='tree'), center(citizen_soldiers))
|
||||
|
||||
# Action commands can be created using zero_ad.actions
|
||||
collect_wood = zero_ad.actions.gather(citizen_soldiers, nearby_tree)
|
||||
|
||||
female_citizens = state.units(owner=1, type='female_citizen')
|
||||
house_tpl = 'structures/spart_house'
|
||||
x = 680
|
||||
z = 640
|
||||
build_house = zero_ad.actions.construct(female_citizens, house_tpl, x, z, autocontinue=True)
|
||||
|
||||
# These commands can then be applied to the game in a `step` command
|
||||
state = game.step([collect_wood, build_house])
|
||||
|
||||
# We can also fetch units by id using the `unit` function on the game state
|
||||
female_id = female_citizens[0].id()
|
||||
female_citizen = state.unit(female_id)
|
||||
|
||||
# A variety of unit information can be queried from the unit:
|
||||
print('female citizen\'s max health is', female_citizen.max_health())
|
||||
|
||||
# Raw data for units and game states are available via the data attribute
|
||||
print(female_citizen.data)
|
||||
|
||||
# Units can be built using the "train action"
|
||||
civic_center = state.units(owner=1, type="civil_centre")[0]
|
||||
spearman_type = 'units/spart_infantry_spearman_b'
|
||||
train_spearmen = zero_ad.actions.train([civic_center], spearman_type)
|
||||
|
||||
state = game.step([train_spearmen])
|
||||
|
||||
# Let's step the engine until the house has been built
|
||||
is_unit_busy = lambda state, unit_id: len(state.unit(unit_id).data['unitAIOrderData']) > 0
|
||||
while is_unit_busy(state, female_id):
|
||||
state = game.step()
|
||||
|
||||
# The units for the other army can also be controlled
|
||||
enemy_units = state.units(owner=2)
|
||||
walk = zero_ad.actions.walk(enemy_units, *civic_center.position())
|
||||
game.step([walk], player=[2])
|
||||
|
||||
# Step the game engine a bit to give them some time to walk
|
||||
for _ in range(150):
|
||||
state = game.step()
|
||||
|
||||
# Let's attack with our entire military
|
||||
state = game.step([zero_ad.actions.chat('An attack is coming!')])
|
||||
|
||||
while len(state.units(owner=2, type='unit')) > 0:
|
||||
attack_units = [ unit for unit in state.units(owner=1, type='unit') if 'female' not in unit.type() ]
|
||||
target = closest(state.units(owner=2, type='unit'), center(attack_units))
|
||||
state = game.step([zero_ad.actions.attack(attack_units, target)])
|
||||
|
||||
while state.unit(target.id()):
|
||||
state = game.step()
|
||||
|
||||
game.step([zero_ad.actions.chat('The enemies have been vanquished. Our home is safe again.')])
|
||||
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from setuptools import setup
|
||||
|
||||
setup(name='zero_ad',
|
||||
version='0.0.1',
|
||||
description='Python client for 0 AD',
|
||||
url='https://code.wildfiregames.com',
|
||||
author='Brian Broll',
|
||||
author_email='brian.broll@gmail.com',
|
||||
install_requires=[],
|
||||
license='MIT',
|
||||
packages=['zero_ad'],
|
||||
zip_safe=False)
|
||||
@@ -0,0 +1,100 @@
|
||||
import zero_ad
|
||||
import json
|
||||
import math
|
||||
from os import path
|
||||
|
||||
game = zero_ad.ZeroAD('http://localhost:6000')
|
||||
scriptdir = path.dirname(path.realpath(__file__))
|
||||
with open(path.join(scriptdir, '..', 'samples', 'arcadia.json'), 'r') as f:
|
||||
config = f.read()
|
||||
|
||||
def dist (p1, p2):
|
||||
return math.sqrt(sum((math.pow(x2 - x1, 2) for (x1, x2) in zip(p1, p2))))
|
||||
|
||||
def center(units):
|
||||
sum_position = map(sum, zip(*map(lambda u: u.position(), units)))
|
||||
return [x/len(units) for x in sum_position]
|
||||
|
||||
def closest(units, position):
|
||||
dists = (dist(unit.position(), position) for unit in units)
|
||||
index = 0
|
||||
min_dist = next(dists)
|
||||
for (i, d) in enumerate(dists):
|
||||
if d < min_dist:
|
||||
index = i
|
||||
min_dist = d
|
||||
|
||||
return units[index]
|
||||
|
||||
def test_construct():
|
||||
state = game.reset(config)
|
||||
female_citizens = state.units(owner=1, type='female_citizen')
|
||||
house_tpl = 'structures/spart_house'
|
||||
house_count = len(state.units(owner=1, type=house_tpl))
|
||||
x = 680
|
||||
z = 640
|
||||
build_house = zero_ad.actions.construct(female_citizens, house_tpl, x, z, autocontinue=True)
|
||||
# Check that they start building the house
|
||||
state = game.step([build_house])
|
||||
while len(state.units(owner=1, type=house_tpl)) == house_count:
|
||||
state = game.step()
|
||||
|
||||
def test_gather():
|
||||
state = game.reset(config)
|
||||
female_citizen = state.units(owner=1, type='female_citizen')[0]
|
||||
trees = state.units(owner=0, type='tree')
|
||||
nearby_tree = closest(state.units(owner=0, type='tree'), female_citizen.position())
|
||||
|
||||
collect_wood = zero_ad.actions.gather([female_citizen], nearby_tree)
|
||||
state = game.step([collect_wood])
|
||||
while len(state.unit(female_citizen.id()).data['resourceCarrying']) == 0:
|
||||
state = game.step()
|
||||
|
||||
def test_train():
|
||||
state = game.reset(config)
|
||||
civic_centers = state.units(owner=1, type="civil_centre")
|
||||
spearman_type = 'units/spart_infantry_spearman_b'
|
||||
spearman_count = len(state.units(owner=1, type=spearman_type))
|
||||
train_spearmen = zero_ad.actions.train(civic_centers, spearman_type)
|
||||
|
||||
state = game.step([train_spearmen])
|
||||
while len(state.units(owner=1, type=spearman_type)) == spearman_count:
|
||||
state = game.step()
|
||||
|
||||
def test_walk():
|
||||
state = game.reset(config)
|
||||
female_citizens = state.units(owner=1, type='female_citizen')
|
||||
x = 680
|
||||
z = 640
|
||||
initial_distance = dist(center(female_citizens), [x, z])
|
||||
|
||||
walk = zero_ad.actions.walk(female_citizens, x, z)
|
||||
state = game.step([walk])
|
||||
distance = initial_distance
|
||||
while distance >= initial_distance:
|
||||
state = game.step()
|
||||
female_citizens = state.units(owner=1, type='female_citizen')
|
||||
distance = dist(center(female_citizens), [x, z])
|
||||
|
||||
def test_attack():
|
||||
state = game.reset(config)
|
||||
units = state.units(owner=1, type='cavalry')
|
||||
target = state.units(owner=2, type='female_citizen')[0]
|
||||
initial_health = target.health()
|
||||
|
||||
state = game.step([zero_ad.actions.reveal_map()])
|
||||
|
||||
attack = zero_ad.actions.attack(units, target)
|
||||
state = game.step([attack])
|
||||
while state.unit(target.id()).health() >= initial_health:
|
||||
state = game.step()
|
||||
|
||||
def test_debug_print():
|
||||
state = game.reset(config)
|
||||
debug_print = zero_ad.actions.debug_print('hello world!!')
|
||||
state = game.step([debug_print])
|
||||
|
||||
def test_chat():
|
||||
state = game.reset(config)
|
||||
chat = zero_ad.actions.chat('hello world!!')
|
||||
state = game.step([chat])
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import actions
|
||||
from . import environment
|
||||
ZeroAD = environment.ZeroAD
|
||||
GameState = environment.GameState
|
||||
@@ -0,0 +1,69 @@
|
||||
def construct(units, template, x, z, angle=0, autorepair=True, autocontinue=True, queued=False):
|
||||
unit_ids = [ unit.id() for unit in units ]
|
||||
return {
|
||||
'type': 'construct',
|
||||
'entities': unit_ids,
|
||||
'template': template,
|
||||
'x': x,
|
||||
'z': z,
|
||||
'angle': angle,
|
||||
'autorepair': autorepair,
|
||||
'autocontinue': autocontinue,
|
||||
'queued': queued,
|
||||
}
|
||||
|
||||
def gather(units, target, queued=False):
|
||||
unit_ids = [ unit.id() for unit in units ]
|
||||
return {
|
||||
'type': 'gather',
|
||||
'entities': unit_ids,
|
||||
'target': target.id(),
|
||||
'queued': queued,
|
||||
}
|
||||
|
||||
def train(entities, unit_type, count=1):
|
||||
entity_ids = [ unit.id() for unit in entities ]
|
||||
return {
|
||||
'type': 'train',
|
||||
'entities': entity_ids,
|
||||
'template': unit_type,
|
||||
'count': count,
|
||||
}
|
||||
|
||||
def debug_print(message):
|
||||
return {
|
||||
'type': 'debug-print',
|
||||
'message': message
|
||||
}
|
||||
|
||||
def chat(message):
|
||||
return {
|
||||
'type': 'aichat',
|
||||
'message': message
|
||||
}
|
||||
|
||||
def reveal_map():
|
||||
return {
|
||||
'type': 'reveal-map',
|
||||
'enable': True
|
||||
}
|
||||
|
||||
def walk(units, x, z, queued=False):
|
||||
ids = [ unit.id() for unit in units ]
|
||||
return {
|
||||
'type': 'walk',
|
||||
'entities': ids,
|
||||
'x': x,
|
||||
'z': z,
|
||||
'queued': queued
|
||||
}
|
||||
|
||||
def attack(units, target, queued=False, allow_capture=True):
|
||||
unit_ids = [ unit.id() for unit in units ]
|
||||
return {
|
||||
'type': 'attack',
|
||||
'entities': unit_ids,
|
||||
'target': target.id(),
|
||||
'allowCapture': allow_capture,
|
||||
'queued': queued
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import urllib
|
||||
from urllib import request
|
||||
import json
|
||||
|
||||
class RLAPI():
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
def post(self, route, data):
|
||||
response = request.urlopen(url=f'{self.url}/{route}', data=bytes(data, 'utf8'))
|
||||
return response.read()
|
||||
|
||||
def step(self, commands):
|
||||
post_data = '\n'.join((f'{player};{json.dumps(action)}' for (player, action) in commands))
|
||||
return self.post('step', post_data)
|
||||
|
||||
def reset(self, scenario_config, player_id, save_replay):
|
||||
path = 'reset?'
|
||||
if save_replay:
|
||||
path += 'saveReplay=1&'
|
||||
if player_id:
|
||||
path += f'playerID={player_id}&'
|
||||
|
||||
return self.post(path, scenario_config)
|
||||
|
||||
def get_templates(self, names):
|
||||
post_data = '\n'.join(names)
|
||||
response = self.post('templates', post_data)
|
||||
return zip(names, response.decode().split('\n'))
|
||||
@@ -0,0 +1,113 @@
|
||||
from .api import RLAPI
|
||||
import json
|
||||
import math
|
||||
from xml.etree import ElementTree
|
||||
from itertools import cycle
|
||||
|
||||
class ZeroAD():
|
||||
def __init__(self, uri='http://localhost:6000'):
|
||||
self.api = RLAPI(uri)
|
||||
self.current_state = None
|
||||
self.cache = {}
|
||||
self.player_id = 1
|
||||
|
||||
def step(self, actions=[], player=None):
|
||||
player_ids = cycle([self.player_id]) if player is None else cycle(player)
|
||||
|
||||
cmds = zip(player_ids, actions)
|
||||
cmds = ((player, action) for (player, action) in cmds if action is not None)
|
||||
state_json = self.api.step(cmds)
|
||||
self.current_state = GameState(json.loads(state_json), self)
|
||||
return self.current_state
|
||||
|
||||
def reset(self, config='', save_replay=False, player_id=1):
|
||||
state_json = self.api.reset(config, player_id, save_replay)
|
||||
self.current_state = GameState(json.loads(state_json), self)
|
||||
return self.current_state
|
||||
|
||||
def get_template(self, name):
|
||||
return self.get_templates([name])[0]
|
||||
|
||||
def get_templates(self, names):
|
||||
templates = self.api.get_templates(names)
|
||||
return [ (name, EntityTemplate(content)) for (name, content) in templates ]
|
||||
|
||||
def update_templates(self, types=[]):
|
||||
all_types = list(set([unit.type() for unit in self.current_state.units()]))
|
||||
all_types += types
|
||||
template_pairs = self.get_templates(all_types)
|
||||
|
||||
self.cache = {}
|
||||
for (name, tpl) in template_pairs:
|
||||
self.cache[name] = tpl
|
||||
|
||||
return template_pairs
|
||||
|
||||
class GameState():
|
||||
def __init__(self, data, game):
|
||||
self.data = data
|
||||
self.game = game
|
||||
self.mapSize = self.data['mapSize']
|
||||
|
||||
def units(self, owner=None, type=None):
|
||||
filter_fn = lambda e: (owner is None or e['owner'] == owner) and \
|
||||
(type is None or type in e['template'])
|
||||
return [ Entity(e, self.game) for e in self.data['entities'].values() if filter_fn(e) ]
|
||||
|
||||
def unit(self, id):
|
||||
id = str(id)
|
||||
return Entity(self.data['entities'][id], self.game) if id in self.data['entities'] else None
|
||||
|
||||
class Entity():
|
||||
|
||||
def __init__(self, data, game):
|
||||
self.data = data
|
||||
self.game = game
|
||||
self.template = self.game.cache.get(self.type(), None)
|
||||
|
||||
def type(self):
|
||||
return self.data['template']
|
||||
|
||||
def id(self):
|
||||
return self.data['id']
|
||||
|
||||
def owner(self):
|
||||
return self.data['owner']
|
||||
|
||||
def max_health(self):
|
||||
template = self.get_template()
|
||||
return float(template.get('Health/Max'))
|
||||
|
||||
def health(self, ratio=False):
|
||||
if ratio:
|
||||
return self.data['hitpoints']/self.max_health()
|
||||
|
||||
return self.data['hitpoints']
|
||||
|
||||
def position(self):
|
||||
return self.data['position']
|
||||
|
||||
def get_template(self):
|
||||
if self.template is None:
|
||||
self.game.update_templates([self.type()])
|
||||
self.template = self.game.cache[self.type()]
|
||||
|
||||
return self.template
|
||||
|
||||
class EntityTemplate():
|
||||
def __init__(self, xml):
|
||||
self.data = ElementTree.fromstring(f'<Entity>{xml}</Entity>')
|
||||
|
||||
def get(self, path):
|
||||
node = self.data.find(path)
|
||||
return node.text if node is not None else None
|
||||
|
||||
def set(self, path, value):
|
||||
node = self.data.find(path)
|
||||
if node:
|
||||
node.text = str(value)
|
||||
|
||||
return node is not None
|
||||
|
||||
def __str__(self):
|
||||
return ElementTree.tostring(self.data).decode('utf-8')
|
||||
Reference in New Issue
Block a user