├── players ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── delete_generated_players.py │ │ └── generate_players.py ├── migrations │ ├── __init__.py │ ├── 0006_game_static_data.py │ ├── 0002_auto_20160601_1914.py │ ├── 0001_initial.py │ ├── 0004_auto_20160808_1511.py │ ├── 0005_auto_20160808_1545.py │ └── 0003_auto_20160802_1418.py ├── templatetags │ ├── __init__.py │ └── players_utils.py ├── avatar_examples │ ├── __init__.py │ ├── dumb_avatar.py │ ├── attacking_avatar.py │ ├── winner_avatar.py │ └── health_seeker_avatar.py ├── static │ ├── css │ │ ├── program.css │ │ └── watch.css │ └── js │ │ ├── program.js │ │ └── watch │ │ ├── world-controls.js │ │ └── world-viewer.js ├── admin.py ├── templates │ ├── players │ │ ├── statistics.html │ │ ├── add_game.html │ │ ├── home.html │ │ ├── dropdown.html │ │ ├── program.html │ │ ├── watch.html │ │ └── base.html │ └── registration │ │ └── login.html ├── app_settings.py ├── forms.py ├── urls.py ├── autoconfig.py ├── models.py └── views.py ├── aimmo-game ├── tests │ ├── __init__.py │ ├── test_simulation │ │ ├── avatar │ │ │ ├── __init__.py │ │ │ └── test_avatar_wrapper.py │ │ ├── __init__.py │ │ ├── test_direction.py │ │ ├── test_location.py │ │ ├── test_effects.py │ │ ├── test_pickups.py │ │ ├── dummy_avatar.py │ │ ├── maps.py │ │ ├── test_worker_manager.py │ │ ├── test_game_state.py │ │ ├── test_action.py │ │ ├── test_map_generator.py │ │ └── test_turn_manager.py │ └── test_service.py ├── simulation │ ├── __init__.py │ ├── avatar │ │ ├── __init__.py │ │ ├── avatar_appearance.py │ │ ├── fog_of_war.py │ │ ├── avatar_manager.py │ │ └── avatar_wrapper.py │ ├── event.py │ ├── direction.py │ ├── location.py │ ├── effects.py │ ├── game_state.py │ ├── pickups.py │ ├── world_state.py │ ├── turn_manager.py │ ├── action.py │ ├── map_generator.py │ └── worker_manager.py ├── requirements.txt ├── Dockerfile ├── setup.py └── service.py ├── aimmo-game-creator ├── tests │ ├── __init__.py │ └── test_worker_manager.py ├── requirements.txt ├── Dockerfile ├── setup.py ├── update.sh └── service.py ├── aimmo-game-worker ├── tests │ ├── __init__.py │ ├── simulation │ │ ├── __init__.py │ │ └── test_world_map.py │ └── test_initialise.py ├── simulation │ ├── __init__.py │ ├── avatar_state.py │ ├── direction.py │ ├── event.py │ ├── location.py │ ├── action.py │ └── world_map.py ├── requirements.txt ├── Dockerfile ├── run.sh ├── setup.py ├── test-initialise.py ├── initialise.py ├── test-turn.py └── service.py ├── minikube_requirements.txt ├── MANIFEST.in ├── example_project ├── example_project │ ├── __init__.py │ ├── wsgi.py │ └── settings.py └── manage.py ├── .gitattributes ├── aimmo-reverse-proxy ├── Dockerfile └── nginx.conf ├── manifests-template ├── ingress.yaml ├── service-aimmo-reverse-proxy.yaml ├── deploy-aimmo-reverse-proxy.yaml └── rc-aimmo-game-creator.yaml ├── run ├── test_settings.py ├── setup.cfg ├── render-manifests.py ├── setup.py ├── .gitignore ├── all_tests.py ├── .travis.yml ├── CONTRIBUTING.md ├── run.py ├── minikube.py └── README.md /players/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game/simulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /players/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /players/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /players/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game-creator/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game-worker/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game/simulation/avatar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /players/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game-worker/tests/simulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/avatar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aimmo-game-worker/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | pykube 3 | -------------------------------------------------------------------------------- /aimmo-game-creator/requirements.txt: -------------------------------------------------------------------------------- 1 | pykube 2 | eventlet 3 | -------------------------------------------------------------------------------- /minikube_requirements.txt: -------------------------------------------------------------------------------- 1 | docker-py >= 1.10 2 | kubernetes 3 | -------------------------------------------------------------------------------- /players/avatar_examples/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Dan' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft players/static 2 | graft players/templates 3 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'c.brett' 2 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- 1 | '''example_project __init__''' 2 | -------------------------------------------------------------------------------- /players/static/css/program.css: -------------------------------------------------------------------------------- 1 | #editor { 2 | min-height: 500px; 3 | } 4 | -------------------------------------------------------------------------------- /aimmo-game/requirements.txt: -------------------------------------------------------------------------------- 1 | eventlet 2 | flask 3 | flask-socketio 4 | pykube 5 | requests 6 | six 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force Unix line endings, which is necessary for shell scripts executed by docker worker 2 | *.sh text eol=lf -------------------------------------------------------------------------------- /aimmo-game-creator/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-onbuild 2 | 3 | MAINTAINER code@ocado.com 4 | 5 | CMD ["python", "./service.py"] 6 | -------------------------------------------------------------------------------- /aimmo-game-worker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2-onbuild 2 | 3 | MAINTAINER code@ocado.com 4 | 5 | CMD ["bash", "./run.sh", "0.0.0.0", "5000"] 6 | -------------------------------------------------------------------------------- /aimmo-reverse-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | RUN mkdir -p /etc/nginx/html/ && touch /etc/nginx/html/index.html 3 | COPY nginx.conf /etc/nginx/nginx.conf 4 | -------------------------------------------------------------------------------- /players/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from models import Avatar, Game 4 | 5 | admin.site.register(Avatar) 6 | admin.site.register(Game) 7 | -------------------------------------------------------------------------------- /aimmo-game/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2-onbuild 2 | 3 | MAINTAINER code@ocado.com 4 | 5 | ENV WORKER_MANAGER=kubernetes 6 | 7 | CMD ["python", "./service.py", "0.0.0.0", "5000"] 8 | -------------------------------------------------------------------------------- /players/static/css/watch.css: -------------------------------------------------------------------------------- 1 | #watch-world-canvas { 2 | display: block; 3 | /*height: 95%;*/ 4 | height: 800px; 5 | 6 | margin: 0px; 7 | padding: 0px; 8 | box-sizing: border-box; 9 | } -------------------------------------------------------------------------------- /players/templates/players/statistics.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | 3 | {% block nav-statistics-class %}active{% endblock %} 4 | 5 | 6 | {% block content %} 7 | stats 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /manifests-template/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: aimmo-ingress 5 | spec: 6 | backend: 7 | serviceName: aimmo-reverse-proxy 8 | servicePort: 80 9 | -------------------------------------------------------------------------------- /aimmo-game-worker/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #TODO: no longer needed? 4 | 5 | set -e 6 | 7 | dir=$(mktemp -d) 8 | 9 | python ./initialise.py $dir 10 | 11 | export PYTHONPATH=$dir:$PYTHONPATH 12 | 13 | exec python ./service.py $1 $2 $dir 14 | -------------------------------------------------------------------------------- /players/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | #: URL function for locating the game server, takes one parameter `game` 4 | GAME_SERVER_LOCATION_FUNCTION = getattr(settings, 'AIMMO_GAME_SERVER_LOCATION_FUNCTION', None) 5 | 6 | MAX_LEVEL = 1 7 | -------------------------------------------------------------------------------- /manifests-template/service-aimmo-reverse-proxy.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | name: aimmo-reverse-proxy 5 | spec: 6 | selector: 7 | app: aimmo-reverse-proxy 8 | ports: 9 | - protocol: TCP 10 | port: 80 11 | type: NodePort 12 | -------------------------------------------------------------------------------- /players/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm 2 | 3 | from players.models import Game 4 | 5 | 6 | class AddGameForm(ModelForm): 7 | class Meta: 8 | model = Game 9 | exclude = ['Main', 'owner', 'auth_token', 'completed', 'main_user', 'static_data'] 10 | -------------------------------------------------------------------------------- /players/avatar_examples/dumb_avatar.py: -------------------------------------------------------------------------------- 1 | class Avatar(object): 2 | def handle_turn(self, world_view, events): 3 | from simulation.action import MoveAction 4 | from simulation.direction import ALL_DIRECTIONS 5 | import random 6 | 7 | return MoveAction(random.choice(ALL_DIRECTIONS)) 8 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/avatar_state.py: -------------------------------------------------------------------------------- 1 | from simulation.location import Location 2 | 3 | 4 | class AvatarState(object): 5 | 6 | def __init__(self, location, health, score, events): 7 | self.location = Location(**location) 8 | self.health = health 9 | self.score = score 10 | self.events = events 11 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cd "${BASH_SOURCE%/*}" 4 | 5 | trap 'kill -- -$$' INT TERM 6 | 7 | pip install -e . 8 | ./example_project/manage.py migrate --noinput 9 | ./example_project/manage.py collectstatic --noinput 10 | ./example_project/manage.py runserver "$@" & 11 | sleep 2 12 | ./aimmo-game-creator/service.py & 13 | 14 | wait 15 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | import logging 11 | logging.basicConfig() 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /aimmo-game/simulation/avatar/avatar_appearance.py: -------------------------------------------------------------------------------- 1 | class AvatarAppearance: 2 | """ 3 | The presentation aspect of an avatar. 4 | """ 5 | 6 | def __init__(self, body_stroke, body_fill, eye_stroke, eye_fill): 7 | self.body_stroke = body_stroke 8 | self.body_fill = body_fill 9 | self.eye_stroke = eye_stroke 10 | self.eye_fill = eye_fill 11 | -------------------------------------------------------------------------------- /players/templates/players/add_game.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | {% load bootstrap_tags %} 3 | 4 | {% block content %} 5 |

Add a new game

6 |
7 | {% csrf_token %} 8 |
9 | {{ form|as_bootstrap }} 10 | 11 |
12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /aimmo-reverse-proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | resolver 10.0.0.10; 3 | server { 4 | listen 80; 5 | location ~^/game/([0-9]+)/? { 6 | proxy_pass "http://game-$1.default.svc.cluster.local"; 7 | proxy_http_version 1.1; 8 | proxy_set_header Upgrade $http_upgrade; 9 | proxy_set_header Connection "Upgrade"; 10 | } 11 | } 12 | } 13 | 14 | events { 15 | } 16 | -------------------------------------------------------------------------------- /aimmo-game-creator/setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import find_packages, setup 3 | 4 | 5 | setup( 6 | name='aimmo-game-creator', 7 | packages=find_packages(), 8 | include_package_data=True, 9 | install_requires=[ 10 | 'eventlet', 11 | 'pykube', 12 | ], 13 | tests_require=[ 14 | 'httmock', 15 | ], 16 | test_suite='tests', 17 | zip_safe=False, 18 | ) 19 | -------------------------------------------------------------------------------- /aimmo-game-creator/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | kubectl delete rc aimmo-game-creator || true 4 | sleep 10 5 | kubectl delete rc -l app=aimmo-game 6 | sleep 10 7 | kubectl delete pod -l app=aimmo-game-worker 8 | kubectl delete service -l app=aimmo-game 9 | sleep 5 10 | kubectl create -f rc-aimmo-game-creator.yaml 11 | sleep 10 12 | kubectl get rc 13 | kubectl get pod 14 | kubectl get service 15 | kubectl get ingress 16 | -------------------------------------------------------------------------------- /aimmo-game-worker/setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import find_packages, setup 3 | 4 | 5 | setup( 6 | name='aimmo-game-worker', 7 | packages=find_packages(), 8 | include_package_data=True, 9 | install_requires=[ 10 | 'flask', 11 | 'requests', 12 | ], 13 | tests_require=[ 14 | 'httmock', 15 | ], 16 | test_suite='tests', 17 | zip_safe=False, 18 | ) 19 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | }, 5 | } 6 | INSTALLED_APPS = [ 7 | 'django.contrib.admin', 8 | 'players', 9 | ] 10 | PIPELINE_ENABLED = False 11 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 12 | STATIC_ROOT = '.tests_static/' 13 | 14 | from django_autoconfig.autoconfig import configure_settings # noqa: E402 15 | configure_settings(globals()) 16 | -------------------------------------------------------------------------------- /manifests-template/deploy-aimmo-reverse-proxy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: aimmo-reverse-proxy 5 | spec: 6 | replicas: 1 7 | template: 8 | metadata: 9 | labels: 10 | app: aimmo-reverse-proxy 11 | spec: 12 | containers: 13 | - name: aimmo-reverse-proxy 14 | image: ocadotechnology/aimmo-reverse-proxy:AIMMO_VERSION 15 | ports: 16 | - containerPort: 80 17 | -------------------------------------------------------------------------------- /players/templates/players/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | 3 | {% block content %} 4 |

Welcome to AI:MMO!

5 |

AI:MMO is a Massively Multi-player Online game, where players 6 | create Artificially Intelligent programs to play on their behalf.

7 |

Get started by trying level 1.

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /aimmo-game/setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import find_packages, setup 3 | 4 | 5 | setup( 6 | name='aimmo-game', 7 | packages=find_packages(), 8 | include_package_data=True, 9 | install_requires=[ 10 | 'eventlet', 11 | 'flask', 12 | 'flask-socketio', 13 | 'requests', 14 | 'six', 15 | 'pykube', 16 | ], 17 | tests_require=[ 18 | 'httmock', 19 | ], 20 | test_suite='tests', 21 | zip_safe=False, 22 | ) 23 | -------------------------------------------------------------------------------- /players/migrations/0006_game_static_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('players', '0005_auto_20160808_1545'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='game', 16 | name='static_data', 17 | field=models.TextField(null=True, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /players/management/commands/delete_generated_players.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | # Show this when the user types help 7 | help = "Delete generated users" 8 | 9 | # A command must define handle() 10 | def handle(self, *args, **options): 11 | for user in User.objects.filter(username__startswith='zombie-'): 12 | self.stdout.write('Deleting %s' % user.get_username()) 13 | user.delete() 14 | -------------------------------------------------------------------------------- /players/migrations/0002_auto_20160601_1914.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('players', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='avatar', 16 | name='player', 17 | ), 18 | migrations.DeleteModel( 19 | name='Avatar', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /players/templatetags/players_utils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from players import app_settings 4 | from players.models import Game 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.inclusion_tag('players/dropdown.html', takes_context=True) 10 | def game_dropdown_list(context, base_url): 11 | return { 12 | 'base_url': base_url, 13 | 'open_play_games': Game.objects.for_user(context.request.user).filter(levelattempt=None), 14 | 'level_numbers': xrange(1, app_settings.MAX_LEVEL+1), 15 | } 16 | -------------------------------------------------------------------------------- /aimmo-game-creator/service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | 5 | from worker_manager import WORKER_MANAGERS 6 | 7 | 8 | def main(): 9 | logging.basicConfig(level=logging.DEBUG) 10 | WorkerManagerClass = WORKER_MANAGERS[os.environ.get('WORKER_MANAGER', 'local')] 11 | worker_manager = WorkerManagerClass(os.environ.get('GAME_API_URL', 12 | 'http://localhost:8000/players/api/games/')) 13 | worker_manager.run() 14 | 15 | if __name__ == '__main__': 16 | main() 17 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/direction.py: -------------------------------------------------------------------------------- 1 | class Direction(object): 2 | 3 | def __init__(self, x, y): 4 | self.x = x 5 | self.y = y 6 | 7 | def __repr__(self): 8 | return 'Direction(x={}, y={})'.format(self.x, self.y) 9 | 10 | def serialise(self): 11 | return { 12 | 'x': self.x, 13 | 'y': self.y, 14 | } 15 | 16 | NORTH = Direction(0, 1) 17 | EAST = Direction(1, 0) 18 | SOUTH = Direction(0, -1) 19 | WEST = Direction(-1, 0) 20 | 21 | ALL_DIRECTIONS = (NORTH, EAST, SOUTH, WEST) 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [coverage:run] 5 | source = players, aimmo-game, aimmo-game-worker, aimmo-game-creator 6 | 7 | [coverage:report] 8 | omit = 9 | players/_version.py 10 | *test-* 11 | */tests/* 12 | */python?.?/* 13 | */site-packages/nose/* 14 | *.egg/* 15 | 16 | [pep8] 17 | max-line-length = 160 18 | 19 | [versioneer] 20 | VCS = git 21 | style = pep440-pre 22 | versionfile_source = players/_version.py 23 | versionfile_build = players/_version.py 24 | tag_prefix = 25 | parentdir_prefix = aimmo- 26 | -------------------------------------------------------------------------------- /players/templates/players/dropdown.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /aimmo-game-worker/test-initialise.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | import requests 5 | 6 | url = 'http://localhost:5001/initialise/' 7 | 8 | if len(sys.argv) > 1: 9 | avatar_file = sys.argv[1] 10 | else: 11 | avatar_file = '../players/avatar_examples/dumb_avatar.py' 12 | 13 | with open(avatar_file) as avatar_fileobj: 14 | avatar_data = avatar_fileobj.read() 15 | 16 | api_data = { 17 | 'code': avatar_data, 18 | 'options': {}, 19 | } 20 | 21 | print 'Posting: ', api_data 22 | result = requests.post(url, json=api_data) 23 | print result.content 24 | result.raise_for_status() 25 | -------------------------------------------------------------------------------- /aimmo-game/simulation/event.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | ReceivedAttackEvent = namedtuple( 4 | 'ReceivedAttackEvent', ['attacking_avatar', 'damage_dealt']) 5 | 6 | PerformedAttackEvent = namedtuple( 7 | 'PerformedAttackEvent', 8 | ['attacked_avatar', 'target_location', 'damage_dealt']) 9 | 10 | FailedAttackEvent = namedtuple( 11 | 'FailedAttackEvent', ['target_location']) 12 | 13 | MovedEvent = namedtuple( 14 | 'MovedEvent', ['source_location', 'target_location']) 15 | 16 | FailedMoveEvent = namedtuple( 17 | 'FailedMoveEvent', ['source_location', 'target_location']) 18 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/event.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | ReceivedAttackEvent = namedtuple( 4 | 'ReceivedAttackEvent', ['attacking_avatar', 'damage_dealt']) 5 | 6 | PerformedAttackEvent = namedtuple( 7 | 'PerformedAttackEvent', 8 | ['attacked_avatar', 'target_location', 'damage_dealt']) 9 | 10 | FailedAttackEvent = namedtuple( 11 | 'FailedAttackEvent', ['target_location']) 12 | 13 | MovedEvent = namedtuple( 14 | 'MovedEvent', ['source_location', 'target_location']) 15 | 16 | FailedMoveEvent = namedtuple( 17 | 'FailedMoveEvent', ['source_location', 'target_location']) 18 | -------------------------------------------------------------------------------- /render-manifests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import yaml 6 | 7 | try: 8 | os.makedirs(os.path.join(sys.argv[1], 'manifests')) 9 | except OSError: 10 | pass 11 | 12 | for filename in os.listdir(os.path.join(sys.argv[1], 'manifests-template')): 13 | content = open(os.path.join(sys.argv[1], 'manifests-template', filename)).read() 14 | content = content.replace('AIMMO_VERSION', sys.argv[2]) 15 | content = content.replace('AIMMO_UI_URL', sys.argv[3]) 16 | dest_filename = os.path.join(sys.argv[1], 'manifests', filename) 17 | with open(dest_filename, 'w') as fobj: 18 | fobj.write(content) 19 | -------------------------------------------------------------------------------- /aimmo-game/simulation/direction.py: -------------------------------------------------------------------------------- 1 | class Direction: 2 | def __init__(self, x, y): 3 | if abs(x) not in [0, 1]: 4 | raise ValueError 5 | if abs(y) not in [0, 1]: 6 | raise ValueError 7 | if abs(x) + abs(y) != 1: 8 | raise ValueError 9 | self.x = x 10 | self.y = y 11 | 12 | @property 13 | def dict(self): 14 | return {'x': self.x, 'y': self.y} 15 | 16 | def __repr__(self): 17 | return 'Direction(x={}, y={})'.format(self.x, self.y) 18 | 19 | NORTH = Direction(0, 1) 20 | EAST = Direction(1, 0) 21 | SOUTH = Direction(0, -1) 22 | WEST = Direction(-1, 0) 23 | 24 | ALL_DIRECTIONS = (NORTH, EAST, SOUTH, WEST) 25 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/location.py: -------------------------------------------------------------------------------- 1 | class Location(object): 2 | def __init__(self, x, y): 3 | self.x = x 4 | self.y = y 5 | 6 | def __add__(self, direction): 7 | return Location(self.x + direction.x, self.y + direction.y) 8 | 9 | def __sub__(self, direction): 10 | return Location(self.x - direction.x, self.y - direction.y) 11 | 12 | def __repr__(self): 13 | return 'Location({}, {})'.format(self.x, self.y) 14 | 15 | def __eq__(self, other): 16 | return self.x == other.x and self.y == other.y 17 | 18 | def __ne__(self, other): 19 | return not self == other 20 | 21 | def __hash__(self): 22 | return hash((self.x, self.y)) 23 | -------------------------------------------------------------------------------- /aimmo-game/simulation/location.py: -------------------------------------------------------------------------------- 1 | class Location(object): 2 | def __init__(self, x, y): 3 | self.x = x 4 | self.y = y 5 | 6 | def __add__(self, direction): 7 | return Location(self.x + direction.x, self.y + direction.y) 8 | 9 | def __sub__(self, direction): 10 | return Location(self.x - direction.x, self.y - direction.y) 11 | 12 | def __repr__(self): 13 | return 'Location({}, {})'.format(self.x, self.y) 14 | 15 | def __eq__(self, other): 16 | return self.x == other.x and self.y == other.y 17 | 18 | def __ne__(self, other): 19 | return not self == other 20 | 21 | def __hash__(self): 22 | return hash((self.x, self.y)) 23 | 24 | def serialise(self): 25 | return {'x': self.x, 'y': self.y} 26 | -------------------------------------------------------------------------------- /players/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | 3 | {% block content %} 4 | {% if form.errors %} 5 |

Your username and password didn't match. Please try again.

6 | {% endif %} 7 | 8 |
9 | {% csrf_token %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
20 | 21 | 22 | 23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import find_packages, setup 3 | 4 | import versioneer 5 | 6 | setup( 7 | name='aimmo', 8 | cmdclass=versioneer.get_cmdclass(), 9 | packages=find_packages(), 10 | include_package_data=True, 11 | install_requires=[ 12 | 'django >= 1.8.3, < 1.9.0', 13 | 'django-autoconfig >= 0.3.6, < 1.0.0', 14 | 'django-forms-bootstrap', 15 | 'django-js-reverse', 16 | 'eventlet', 17 | 'flask', 18 | 'flask-socketio', 19 | 'requests', 20 | 'six', 21 | 'pykube', 22 | ], 23 | tests_require=[ 24 | 'django-setuptest', 25 | 'httmock', 26 | ], 27 | test_suite='setuptest.setuptest.SetupTestSuite', 28 | version=versioneer.get_version(), 29 | zip_safe=False, 30 | ) 31 | -------------------------------------------------------------------------------- /aimmo-game-worker/initialise.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import logging 5 | import os 6 | import sys 7 | 8 | import requests 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | # TODO: Delete? Not used by LocalWorkerManager anymore. Is it used by anything else? 14 | def main(args, url): 15 | data_dir = args[1] 16 | LOGGER.debug('Data dir is %s', data_dir) 17 | 18 | data = requests.get(url).json() 19 | 20 | options = data['options'] 21 | with open('{}/options.json'.format(data_dir), 'w') as options_file: 22 | json.dump(options, options_file) 23 | 24 | code = data['code'] 25 | with open('{}/avatar.py'.format(data_dir), 'w') as avatar_file: 26 | avatar_file.write(code) 27 | 28 | if __name__ == '__main__': 29 | logging.basicConfig(level=logging.DEBUG) 30 | main(sys.argv, url=os.environ['DATA_URL']) 31 | -------------------------------------------------------------------------------- /aimmo-game-worker/test-turn.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import requests 3 | 4 | url = 'http://localhost:5001/turn/' 5 | 6 | avatar = {'location': {'x': 0, 'y': 0}, 'health': 5, 'score': 0, 'events': []} 7 | api_data = { 8 | 'avatar_state': avatar, 9 | 'world_map': { 10 | 'cells': [ 11 | {'location': {'x': 0, 'y': 0}, 'habitable': True, 12 | 'generates_score': True, 'avatar': avatar, 'pickup': None}, 13 | {'location': {'x': 1, 'y': 0}, 'habitable': False, 14 | 'generates_score': False, 'avatar': None, 'pickup': None}, 15 | {'location': {'x': -1, 'y': 0}, 'habitable': True, 'generates_score': 16 | False, 'avatar': avatar, 'pickup': {'health_restored': 3}}, 17 | ], 18 | } 19 | } 20 | 21 | print 'Posting:', api_data 22 | result = requests.post(url, json=api_data) 23 | result.raise_for_status() 24 | print 'Output:', result.json() 25 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from simulation.game_state import GameState 6 | from simulation.location import Location 7 | from simulation.turn_manager import state_provider 8 | from simulation.world_map import WorldMap 9 | from simulation.world_state import WorldState 10 | 11 | import service 12 | 13 | from .test_simulation.dummy_avatar import MoveEastDummy 14 | from .test_simulation.maps import MockPickup 15 | from .test_simulation.test_world_map import MockCell 16 | 17 | class SimpleAvatarManager(object): 18 | avatars = [MoveEastDummy(1, Location(0, -1))] 19 | 20 | # TODO: Write test for the new API... 21 | class TestService(TestCase): 22 | def test_healthy(self): 23 | service.app.config['TESTING'] = True 24 | self.app = service.app.test_client() 25 | response = self.app.get('/') 26 | self.assertEqual(response.data, 'HEALTHY') 27 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/action.py: -------------------------------------------------------------------------------- 1 | class Action(object): 2 | 3 | def serialise(self): 4 | raise NotImplementedError 5 | 6 | 7 | class WaitAction(Action): 8 | 9 | def serialise(self): 10 | return { 11 | 'action_type': 'wait', 12 | } 13 | 14 | 15 | class MoveAction(Action): 16 | 17 | def __init__(self, direction): 18 | self.direction = direction 19 | 20 | def serialise(self): 21 | return { 22 | 'action_type': 'move', 23 | 'options': { 24 | 'direction': self.direction.serialise() 25 | }, 26 | } 27 | 28 | 29 | class AttackAction(Action): 30 | 31 | def __init__(self, direction): 32 | self.direction = direction 33 | 34 | def serialise(self): 35 | return { 36 | 'action_type': 'attack', 37 | 'options': { 38 | 'direction': self.direction.serialise() 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /manifests-template/rc-aimmo-game-creator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ReplicationController 3 | metadata: 4 | name: aimmo-game-creator 5 | spec: 6 | replicas: 1 7 | # selector identifies the set of Pods that this 8 | # replication controller is responsible for managing 9 | selector: 10 | app: aimmo-game-creator 11 | # podTemplate defines the 'cookie cutter' used for creating 12 | # new pods when necessary 13 | template: 14 | metadata: 15 | labels: 16 | # Important: these labels need to match the selector above 17 | # The api server enforces this constraint. 18 | app: aimmo-game-creator 19 | spec: 20 | containers: 21 | - name: aimmo-game-creator 22 | image: ocadotechnology/aimmo-game-creator:AIMMO_VERSION 23 | ports: 24 | - containerPort: 80 25 | env: 26 | - name: IMAGE_SUFFIX 27 | value: AIMMO_VERSION 28 | - name: GAME_API_URL 29 | value: AIMMO_UI_URL 30 | - name: WORKER_MANAGER 31 | value: kubernetes 32 | -------------------------------------------------------------------------------- /players/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Avatar', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('player', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='Player', 24 | fields=[ 25 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 26 | ('code', models.TextField()), 27 | ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_direction.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from simulation.direction import Direction 6 | 7 | 8 | class TestDirection(TestCase): 9 | def test_good_data(self): 10 | d = Direction(0, 1) 11 | self.assertEqual(d.x, 0) 12 | self.assertEqual(d.y, 1) 13 | 14 | def test_high_x(self): 15 | with self.assertRaises(ValueError): 16 | Direction(1.5, 0) 17 | 18 | def test_low_x(self): 19 | with self.assertRaises(ValueError): 20 | Direction(-1.5, 0) 21 | 22 | def test_high_y(self): 23 | with self.assertRaises(ValueError): 24 | Direction(0, 1.5) 25 | 26 | def test_low_y(self): 27 | with self.assertRaises(ValueError): 28 | Direction(0, -1.5) 29 | 30 | def test_too_far(self): 31 | with self.assertRaises(ValueError): 32 | Direction(1, 1) 33 | 34 | def test_repr(self): 35 | txt = repr(Direction(1, 0)) 36 | self.assertRegexpMatches(txt, 'x *= *1') 37 | self.assertRegexpMatches(txt, 'y *= *0') 38 | -------------------------------------------------------------------------------- /players/templates/players/program.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | 3 | {% block nav-program-class %}active{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Program

8 |

Use the box below to program your Avatar. Save using the button on the right.

9 |
10 |
11 | 13 |
14 |
15 |
16 |
17 | {% csrf_token %} 18 |
19 | 20 |
21 |
22 |
23 | {% endblock %} 24 | 25 | {% block scripts %} 26 | 29 | 30 | 31 | 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | pep8.txt 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # DB 61 | db.sqlite3 62 | 63 | # IntelliJ 64 | .idea 65 | *.iml 66 | 67 | # static folder 68 | example_project/example_project/static/ 69 | 70 | # Created during minikube testing 71 | manifests 72 | test-bin/ 73 | -------------------------------------------------------------------------------- /aimmo-game-worker/tests/test_initialise.py: -------------------------------------------------------------------------------- 1 | from httmock import all_requests, with_httmock 2 | import initialise 3 | import json 4 | from os.path import join 5 | from tempfile import mkdtemp 6 | from unittest import TestCase 7 | 8 | CODE = "class Avatar: pass" 9 | OPTIONS = {'test': True} 10 | 11 | 12 | @all_requests 13 | def return_data(url, request): 14 | global url_requested 15 | url_requested = url 16 | return json.dumps({ 17 | 'code': CODE, 18 | 'options': OPTIONS, 19 | }) 20 | 21 | 22 | class TestInitialise(TestCase): 23 | @classmethod 24 | def setUpClass(cls): 25 | cls.TMP_DIR = mkdtemp() 26 | 27 | @with_httmock(return_data) 28 | def test_fetching_and_writing(self): 29 | args = ('', self.TMP_DIR) 30 | initialise.main(args, 'http://test') 31 | self.assertRegexpMatches(url_requested.geturl(), 'http://test/?') 32 | options_path = join(self.TMP_DIR, 'options.json') 33 | avatar_path = join(self.TMP_DIR, 'avatar.py') 34 | with open(options_path) as options_file: 35 | self.assertEqual(json.load(options_file), OPTIONS) 36 | with open(avatar_path) as avatar_file: 37 | self.assertEqual(avatar_file.read(), CODE) 38 | -------------------------------------------------------------------------------- /aimmo-game-worker/service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import logging 4 | import sys 5 | 6 | import flask 7 | 8 | from simulation.avatar_state import AvatarState 9 | from simulation.world_map import WorldMap 10 | 11 | app = flask.Flask(__name__) 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | worker_avatar = None 15 | 16 | 17 | @app.route('/turn/', methods=['POST']) 18 | def process_turn(): 19 | LOGGER.info('Calculating action') 20 | data = flask.request.get_json() 21 | 22 | world_map = WorldMap(**data['world_map']) 23 | avatar_state = AvatarState(**data['avatar_state']) 24 | 25 | action = worker_avatar.handle_turn(avatar_state, world_map) 26 | 27 | return flask.jsonify(action=action.serialise()) 28 | 29 | 30 | def run(host, port, directory): 31 | logging.basicConfig(level=logging.DEBUG) 32 | 33 | with open('{}/options.json'.format(directory)) as option_file: 34 | options = json.load(option_file) 35 | from avatar import Avatar 36 | global worker_avatar 37 | worker_avatar = Avatar(**options) 38 | 39 | app.config['DEBUG'] = False 40 | app.run(host, port) 41 | 42 | if __name__ == '__main__': 43 | run(host=sys.argv[1], port=int(sys.argv[2]), directory=sys.argv[3]) 44 | -------------------------------------------------------------------------------- /players/templates/players/watch.html: -------------------------------------------------------------------------------- 1 | {% extends 'players/base.html' %} 2 | 3 | {% block nav-watch-class %}active{% endblock %} 4 | 5 | {% block styles %} 6 | 7 | {% endblock %} 8 | 9 | {% block scripts %} 10 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% endblock %} 28 | 29 | {% block content %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /players/avatar_examples/attacking_avatar.py: -------------------------------------------------------------------------------- 1 | class Avatar(object): 2 | def handle_turn(self, avatar_state, world_map): 3 | from simulation.action import MoveAction 4 | from simulation import direction 5 | self.avatar_state = avatar_state 6 | 7 | self.location = self.avatar_state.location 8 | directions = (direction.EAST, direction.SOUTH, direction.WEST, direction.NORTH) 9 | direction_of_other_avatar = next((d for d in directions if world_map.is_visible(self.location + d) and world_map.get_cell(self.location + d).avatar), None) 10 | if direction_of_other_avatar: 11 | from simulation.action import AttackAction 12 | return AttackAction(direction_of_other_avatar) 13 | import random 14 | 15 | direction_to_other_player = self.direction_to(next(cell.location for cell in world_map.all_cells() if cell.avatar and cell.location != avatar_state.location)) 16 | if direction_to_other_player: 17 | return MoveAction(random.choice(directions + ((direction_to_other_player,) * 10))) 18 | return MoveAction(random.choice(directions)) 19 | 20 | def direction_to(self, location): 21 | from simulation import direction 22 | vector_to = location - self.location 23 | if vector_to.x != 0: 24 | return direction.Direction(1 if vector_to.x > 0 else -1, 0) 25 | if vector_to.y != 0: 26 | return direction.Direction(0, 1 if vector_to.y > 0 else -1) 27 | return None 28 | -------------------------------------------------------------------------------- /players/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib.auth import views as auth_views 3 | from django.contrib.auth.decorators import login_required 4 | from django.views.generic import TemplateView 5 | 6 | from players import views 7 | 8 | urlpatterns = [ 9 | url(r'^$', TemplateView.as_view(template_name='players/home.html'), name='aimmo/home'), 10 | 11 | url(r'^accounts/login/$', auth_views.login), 12 | 13 | url(r'^program/(?P[0-9]+)/$', login_required(views.ProgramView.as_view()), name='aimmo/program'), 14 | url(r'^program_level/(?P[0-9]+)/$', login_required(views.program_level), name='aimmo/program_level'), 15 | url(r'^watch/(?P[0-9]+)/$', login_required(views.watch_game), name='aimmo/watch'), 16 | url(r'^watch_level/(?P[0-9]+)/$', login_required(views.watch_level), name='aimmo/watch_level'), 17 | url(r'^statistics/$', TemplateView.as_view(template_name='players/statistics.html'), name='aimmo/statistics'), 18 | 19 | url(r'^api/code/(?P[0-9]+)/$', views.code, name='aimmo/code'), 20 | url(r'^api/games/$', views.list_games, name='aimmo/games'), 21 | url(r'^api/games/(?P[0-9]+)/$', views.get_game, name='aimmo/game_details'), 22 | url(r'^api/games/(?P[0-9]+)/complete/$', views.mark_game_complete, name='aimmo/complete_game'), 23 | 24 | url(r'^jsreverse/$', 'django_js_reverse.views.urls_js', name='aimmo/js_reverse'), # TODO: Pull request to make django_js_reverse.urls 25 | url(r'^games/new/$', views.add_game, name='aimmo/new_game'), 26 | ] 27 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_location.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from simulation.location import Location 6 | 7 | 8 | class TestLocation(TestCase): 9 | def test_equal(self): 10 | loc_1 = Location(3, 3) 11 | loc_2 = Location(3, 3) 12 | self.assertEqual(loc_1, loc_2) 13 | self.assertFalse(loc_1 != loc_2) 14 | 15 | def test_x_not_equal(self): 16 | loc_1 = Location(3, 3) 17 | loc_2 = Location(4, 3) 18 | self.assertNotEqual(loc_1, loc_2) 19 | self.assertFalse(loc_1 == loc_2) 20 | 21 | def test_y_not_equal(self): 22 | loc_1 = Location(4, 4) 23 | loc_2 = Location(4, 3) 24 | self.assertNotEqual(loc_1, loc_2) 25 | self.assertFalse(loc_1 == loc_2) 26 | 27 | def test_add(self): 28 | loc_1 = Location(1, 2) 29 | loc_2 = Location(3, 4) 30 | expected = Location(4, 6) 31 | self.assertEqual(loc_1 + loc_2, expected) 32 | 33 | def test_sub(self): 34 | loc_1 = Location(1, 2) 35 | loc_2 = Location(3, 4) 36 | expected = Location(-2, -2) 37 | self.assertEqual(loc_1 - loc_2, expected) 38 | 39 | def test_hash_equal(self): 40 | loc_1 = Location(3, 3) 41 | loc_2 = Location(3, 3) 42 | self.assertEqual(hash(loc_1), hash(loc_2)) 43 | 44 | def test_serialise(self): 45 | loc = Location(3, 9) 46 | expected = {'x': 3, 'y': 9} 47 | self.assertEqual(loc.serialise(), expected) 48 | -------------------------------------------------------------------------------- /aimmo-game/simulation/effects.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class _Effect(object): 5 | __metaclass__ = ABCMeta 6 | 7 | def __init__(self, avatar): 8 | self._avatar = avatar 9 | self.is_expired = False 10 | 11 | @abstractmethod 12 | def on_turn(self): 13 | raise NotImplementedError() 14 | 15 | 16 | class _TimedEffect(_Effect): 17 | __metaclass__ = ABCMeta 18 | EFFECT_TIME = 10 19 | 20 | def __init__(self, *args): 21 | super(_TimedEffect, self).__init__(*args) 22 | self._time_remaining = self.EFFECT_TIME 23 | 24 | def remove(self): 25 | self._avatar.effects.remove(self) 26 | 27 | def on_turn(self): 28 | self._time_remaining -= 1 29 | if self._time_remaining <= 0: 30 | self.is_expired = True 31 | 32 | 33 | class InvulnerabilityPickupEffect(_TimedEffect): 34 | def __init__(self, *args): 35 | super(InvulnerabilityPickupEffect, self).__init__(*args) 36 | self._avatar.resistance += 1000 37 | 38 | def remove(self): 39 | super(InvulnerabilityPickupEffect, self).remove() 40 | self._avatar.resistance -= 1000 41 | 42 | 43 | class DamagePickupEffect(_TimedEffect): 44 | def __init__(self, damage_boost, *args): 45 | self._damage_boost = damage_boost 46 | super(DamagePickupEffect, self).__init__(*args) 47 | self._avatar.attack_strength += self._damage_boost 48 | 49 | def remove(self): 50 | super(DamagePickupEffect, self).remove() 51 | self._avatar.attack_strength -= self._damage_boost 52 | -------------------------------------------------------------------------------- /players/avatar_examples/winner_avatar.py: -------------------------------------------------------------------------------- 1 | class Avatar(object): 2 | def handle_turn(self, avatar_state, world_map): 3 | from simulation.action import MoveAction 4 | from simulation import direction 5 | import random 6 | from simulation.action import WaitAction 7 | 8 | self.world_map = world_map 9 | self.avatar_state = avatar_state 10 | 11 | if world_map.get_cell(avatar_state.location).generates_score: 12 | return WaitAction() 13 | 14 | possible_directions = self.get_possible_directions() 15 | directions_to_emphasise = [d for d in possible_directions if self.is_towards(d, self.get_closest_score_location())] 16 | return MoveAction(random.choice(possible_directions + (directions_to_emphasise * 5))) 17 | 18 | def is_towards(self, direction, location): 19 | if location: 20 | return self.distance_between(self.avatar_state.location, location) > \ 21 | self.distance_between(self.avatar_state.location + direction, location) 22 | else: 23 | return False 24 | 25 | def distance_between(self, a, b): 26 | return abs(a.x - b.x) + abs(a.y - b.y) 27 | 28 | def get_closest_score_location(self): 29 | score_cells = list(self.world_map.score_cells()) 30 | if score_cells: 31 | return min(score_cells, key=lambda cell: self.distance_between(cell.location, self.avatar_state.location)).location 32 | else: 33 | return None 34 | 35 | def get_possible_directions(self): 36 | from simulation import direction 37 | directions = (direction.EAST, direction.SOUTH, direction.WEST, direction.NORTH) 38 | return [d for d in directions if self.world_map.can_move_to(self.avatar_state.location + d)] 39 | -------------------------------------------------------------------------------- /players/migrations/0004_auto_20160808_1511.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('players', '0003_auto_20160802_1418'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='game', 16 | name='obstacle_ratio', 17 | field=models.FloatField(default=0.1), 18 | ), 19 | migrations.AddField( 20 | model_name='game', 21 | name='pickup_spawn_chance', 22 | field=models.FloatField(default=0.02), 23 | ), 24 | migrations.AddField( 25 | model_name='game', 26 | name='score_despawn_chance', 27 | field=models.FloatField(default=0.02), 28 | ), 29 | migrations.AddField( 30 | model_name='game', 31 | name='start_height', 32 | field=models.IntegerField(default=11), 33 | ), 34 | migrations.AddField( 35 | model_name='game', 36 | name='start_width', 37 | field=models.IntegerField(default=11), 38 | ), 39 | migrations.AddField( 40 | model_name='game', 41 | name='target_num_cells_per_avatar', 42 | field=models.FloatField(default=16), 43 | ), 44 | migrations.AddField( 45 | model_name='game', 46 | name='target_num_pickups_per_avatar', 47 | field=models.FloatField(default=0.5), 48 | ), 49 | migrations.AddField( 50 | model_name='game', 51 | name='target_num_score_locations_per_avatar', 52 | field=models.FloatField(default=0.5), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /players/avatar_examples/health_seeker_avatar.py: -------------------------------------------------------------------------------- 1 | class Avatar(object): 2 | def handle_turn(self, avatar_state, world_map): 3 | from simulation.action import MoveAction 4 | from simulation import direction 5 | import random 6 | from simulation.action import WaitAction 7 | 8 | self.world_map = world_map 9 | self.avatar_state = avatar_state 10 | 11 | if world_map.get_cell(avatar_state.location).generates_score: 12 | return WaitAction() 13 | 14 | possible_directions = self.get_possible_directions() 15 | directions_to_emphasise = [d for d in possible_directions if self.is_towards(d, self.get_closest_pickup_location())] 16 | return MoveAction(random.choice(possible_directions + (directions_to_emphasise * 5))) 17 | 18 | def is_towards(self, direction, location): 19 | if location: 20 | return self.distance_between(self.avatar_state.location, location) > \ 21 | self.distance_between(self.avatar_state.location + direction, location) 22 | else: 23 | return None 24 | 25 | def distance_between(self, a, b): 26 | return abs(a.x - b.x) + abs(a.y - b.y) 27 | 28 | def get_closest_pickup_location(self): 29 | pickup_cells = list(self.world_map.pickup_cells()) 30 | if pickup_cells: 31 | c = min(pickup_cells, key=lambda cell: self.distance_between(cell.location, self.avatar_state.location)) 32 | print 'targetting', c 33 | return c.location 34 | else: 35 | return None 36 | 37 | def get_possible_directions(self): 38 | from simulation import direction 39 | directions = (direction.EAST, direction.SOUTH, direction.WEST, direction.NORTH) 40 | return [d for d in directions if self.world_map.can_move_to(self.avatar_state.location + d)] 41 | -------------------------------------------------------------------------------- /all_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Run all tests for the project. 3 | 4 | Usage: 5 | run_tests.py [--coverage] 6 | 7 | Optional arguments: 8 | -c, --coverage compute the coverage while running tests. 9 | """ 10 | 11 | import os 12 | import subprocess 13 | import sys 14 | 15 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 16 | APPS = ('', 'aimmo-game/', 'aimmo-game-worker/', 'aimmo-game-creator/') 17 | 18 | 19 | def print_help(): 20 | print(globals()['__docstring__']) 21 | 22 | 23 | def main(): 24 | if '--help' in sys.argv or '-h' in sys.argv: 25 | print_help() 26 | sys.exit(0) 27 | else: 28 | compute_coverage = '--coverage' in sys.argv or '-c' in sys.argv 29 | sys.exit(run_tests(compute_coverage)) 30 | 31 | 32 | def run_tests(compute_coverage): 33 | def app_name(app): 34 | return 'players' if app == '' else app 35 | 36 | failed_apps = [] 37 | for app in APPS: 38 | print('Testing {}'.format(app)) 39 | 40 | dir = os.path.join(BASE_DIR, app) 41 | if compute_coverage and app != '': 42 | result = subprocess.call(['coverage', 'run', '--concurrency=eventlet', '--source=.', 'setup.py', 'test'], cwd=dir) 43 | else: 44 | result = subprocess.call([sys.executable, 'setup.py', 'test'], cwd=dir) 45 | if result != 0: 46 | print('Tests failed: '.format(result)) 47 | failed_apps.append(app_name(app)) 48 | if compute_coverage: 49 | coverage_files = [app + '.coverage' for app in APPS] 50 | subprocess.call(['coverage', 'combine'] + coverage_files, cwd=BASE_DIR) 51 | if failed_apps: 52 | print('The app(s) %s had failed tests' % ', '.join(failed_apps)) 53 | return 1 54 | else: 55 | print('All tests ran successfully') 56 | return 0 57 | 58 | 59 | if __name__ == '__main__': 60 | main() 61 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_effects.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import abc 4 | from unittest import TestCase 5 | 6 | from simulation import effects 7 | from .dummy_avatar import DummyAvatar 8 | 9 | 10 | class _BaseCases(object): 11 | class BaseTimedEffectTestCase(TestCase): 12 | __metaclass__ = abc.ABCMeta 13 | 14 | @abc.abstractmethod 15 | def make_effect(self, avatar): 16 | pass 17 | 18 | def setUp(self): 19 | self.avatar = DummyAvatar(1, None) 20 | self.effect = self.make_effect(self.avatar) 21 | self.avatar.effects.add(self.effect) 22 | 23 | def assertNoEffects(self): 24 | self.assertEqual(len(list(self.avatar.effects)), 0) 25 | 26 | def test_effect_removed(self): 27 | self.effect.remove() 28 | self.assertNoEffects() 29 | 30 | def test_effect_expires(self): 31 | for _ in xrange(10): 32 | self.effect.on_turn() 33 | self.assertTrue(self.effect.is_expired) 34 | 35 | 36 | class TestInvulnerabilityEffect(_BaseCases.BaseTimedEffectTestCase): 37 | def make_effect(self, *args): 38 | return effects.InvulnerabilityPickupEffect(*args) 39 | 40 | def test_resistance_increases(self): 41 | self.assertEqual(self.avatar.resistance, 1000) 42 | 43 | def test_resistance_decreases(self): 44 | self.effect.remove() 45 | self.assertEqual(self.avatar.resistance, 0) 46 | 47 | 48 | class TestDamageEffect(_BaseCases.BaseTimedEffectTestCase): 49 | def make_effect(self, *args): 50 | return effects.DamagePickupEffect(5, *args) 51 | 52 | def test_damage_increases(self): 53 | self.assertEqual(self.avatar.attack_strength, 6) 54 | 55 | def test_damage_decreases(self): 56 | self.effect.remove() 57 | self.assertEqual(self.avatar.attack_strength, 1) 58 | -------------------------------------------------------------------------------- /aimmo-game/simulation/game_state.py: -------------------------------------------------------------------------------- 1 | from simulation.avatar import fog_of_war 2 | 3 | 4 | class GameState(object): 5 | """ 6 | Encapsulates the entire game state, including avatars, their code, and the world. 7 | """ 8 | def __init__(self, world_map, avatar_manager, completion_check_callback=lambda: None): 9 | self.world_map = world_map 10 | self.avatar_manager = avatar_manager 11 | self._completion_callback = completion_check_callback 12 | self.main_avatar_id = None 13 | 14 | def get_state_for(self, avatar_wrapper, fog_of_war=fog_of_war): 15 | processed_world_map = fog_of_war.apply_fog_of_war(self.world_map, avatar_wrapper) 16 | return { 17 | 'avatar_state': avatar_wrapper.serialise(), 18 | 'world_map': { 19 | 'cells': [cell.serialise() for cell in processed_world_map.all_cells()] 20 | } 21 | } 22 | 23 | def add_avatar(self, user_id, worker_url, location=None): 24 | location = self.world_map.get_random_spawn_location() if location is None else location 25 | avatar = self.avatar_manager.add_avatar(user_id, worker_url, location) 26 | self.world_map.get_cell(location).avatar = avatar 27 | 28 | def remove_avatar(self, user_id): 29 | try: 30 | avatar = self.avatar_manager.get_avatar(user_id) 31 | except KeyError: 32 | return 33 | self.world_map.get_cell(avatar.location).avatar = None 34 | self.avatar_manager.remove_avatar(user_id) 35 | 36 | def _update_effects(self): 37 | for avatar in self.avatar_manager.active_avatars: 38 | avatar.update_effects() 39 | 40 | def update_environment(self): 41 | self._update_effects() 42 | num_avatars = len(self.avatar_manager.active_avatars) 43 | self.world_map.update(num_avatars) 44 | 45 | def is_complete(self): 46 | return self._completion_callback(self) 47 | 48 | def get_main_avatar(self): 49 | return self.avatar_manager.avatars_by_id[self.main_avatar_id] 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | group: edge 4 | language: python 5 | python: 6 | - 2.7 7 | env: 8 | global: 9 | - secure: "TNhj8oXrtBzCkkJDA8qbDmqUggK9Kbsy8Itgi0+VmXB+3bVRornZMS38ppaFz+BVOTdL80ZvN2s/OPV106QjFv0Hx1MmWAw4kNST2QBtxFXBHRKYtr7NtBN0jr11el1fG83YdpeZYQbc5aqbJ4OPz2GpfhGfDVhVGPjMFMKXI5XbTbbl+HCEL67ywozt964LhpuuXTaX7jgYFiJUtcwkYRUaDYY3ryJvMSOx95AjKyRMNC5JlgAqbJuYsOTm1eVZtfQ1jYVvd/NuAHOMDNpZWvcIaxTuc3k4XZh4UPryuRJWfjgjIq6kua7Q6ho6W2GbDgN2b/9lIldkTR8QfLSnCNLJIg6KJZ2gmIQg7u+nZHemdugo9XkvfmKXfB/t3HChFX1HNtS4gSeIn874IynLHx3UJ1lxm7BdDbF4Jjijffj5uWDGqVj3/Myd2jdFTJCoLJXvYI7la6ouMzXW5aDFhy2UXK2A3q7aBbaD64+U1R7YPGIyvfAd7NCF11vtRvJGI/fNjO5S1EuSacrQm7CiXu0rd0L5EOSU85XNTQsWN6xxJKEcc8Hx9YLRXkmR7gK9LoEPTUwFbfVXBUvnOsZav3MOBBxzj4+eLxkx2B1vbY2Lx5yPAqyWwi3vet46NEZUIKgqK+xRYQKj6dj3OF1gx7LOcyhpyevdpTZotiEx0C4=" # SNAP_API_AUTH 10 | git: 11 | depth: 9999999 # Building untagged builds needs enough depth to get the latest tag 12 | install: 13 | - pip install . 14 | - pip install coveralls 15 | script: 16 | - python all_tests.py --coverage 17 | after_success: 18 | - coveralls 19 | deploy: 20 | provider: pypi 21 | user: ocadotechnology 22 | password: 23 | secure: "dX2M4PpyGwa1bjpJN/Wk3EORWRXuTS14ZQEQ7Ndqk/WnOZlNqD5t/WiITTdqBWoR/ScvQiGBzR/VdRLZllkGrhupoMrtkfCldaxEI3/wbbwW9CiLuwyR/V5xgVj9TA+PtXErjgAqG9KRtyaBEOaC20t13Uc6vuIc9e6aXkzhQ9hf3vVMlnABOZP3f/2R3+sJQMLylGPCz/6BAs2U+nyxGJCpfq0aJK7H2aVPUKtM1/nscuhvsrl8yU+RWYS3idgfXDuPlwqKvRL9xbEm1DC3ByCFwwzAGCPlUtzYlx6Ttrg8Jv6S1cgsjxijzV5QqU+k1JhmY3jDqqmMROFAaY3wJ6038xUbn4zlgvXgtCayiebJukSMR7tJoVrs6ao26QPsbLNZdReDzSXJR05pgB4I8gGTlIKeZE8zOS75hkv0CdMjmIsQhTeMKqQcATuN8QszYRP4uO7vG+3I7hNSK8HcqxHtNLEDq2/QKDAVBve0R5CeEvLMnSxj7UydO+HuJUgickjHuJUmLZ+7iR9i1p6G0MUpKq/n8izSponvShu0lDshrNWJ9PYuhRD4Yvnp4/FCSxdY0+u7pHJsbluGcIvaAlwoa5O01Seu2pVpox8hVTIhn9LuR0wP7Ed4Nxper7cMNgugqb8J9BybSlMjRlBYmI7FQePCdsu1ETNI9JDHAug=" 24 | distributions: "bdist_wheel sdist" 25 | on: 26 | repo: ocadotechnology/aimmo 27 | after_deploy: 28 | - "curl -d POST -v https://semaphoreci.com/api/v1/projects/${SEMAPHORE_PROJECT_ID}/master/build?auth_token=${SEMAPHORE_API_AUTH}" 29 | -------------------------------------------------------------------------------- /players/migrations/0005_auto_20160808_1545.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('players', '0004_auto_20160808_1511'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='LevelAttempt', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('level_number', models.IntegerField()), 21 | ], 22 | ), 23 | migrations.AddField( 24 | model_name='game', 25 | name='completed', 26 | field=models.BooleanField(default=False), 27 | ), 28 | migrations.AddField( 29 | model_name='game', 30 | name='generator', 31 | field=models.CharField(default=b'Main', max_length=20, choices=[(b'Main', b'Open World'), (b'Level1', b'Level 1')]), 32 | ), 33 | migrations.AddField( 34 | model_name='game', 35 | name='main_user', 36 | field=models.ForeignKey(related_name='games_for_user', blank=True, to=settings.AUTH_USER_MODEL, null=True), 37 | ), 38 | migrations.AlterField( 39 | model_name='game', 40 | name='owner', 41 | field=models.ForeignKey(related_name='owned_games', blank=True, to=settings.AUTH_USER_MODEL, null=True), 42 | ), 43 | migrations.AddField( 44 | model_name='levelattempt', 45 | name='game', 46 | field=models.OneToOneField(to='players.Game'), 47 | ), 48 | migrations.AddField( 49 | model_name='levelattempt', 50 | name='user', 51 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL), 52 | ), 53 | migrations.AlterUniqueTogether( 54 | name='levelattempt', 55 | unique_together=set([('level_number', 'user')]), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## So you want to **clone** the project and figure out **what** to do... 2 | * The good practice is to: 3 | * Fork the project on your account 4 | * Clone your repo using HTTPS 5 | * Work on a new git branch. 6 | * Need help with [git](https://git-scm.com/docs/gittutorial)? 7 | Anyway you can't use Ocado Technology's master. 8 | * The [issues are listed on ocadotechnology/aimmo](https://github.com/ocadotechnology/aimmo/issues). 9 | It's even better if you're using [ZenHub](https://www.zenhub.com/) because it will allow you to look at a [Kanban-ish board](https://github.com/ocadotechnology/aimmo/issues#boards) for the project. 10 | 11 | ## Now you want to **test** your changes and **run** the project locally... 12 | * To work on the project, you can use whichever editor you like. Lots here like [IntelliJ or PyCharm](https://www.jetbrains.com/), for instance. 13 | * As said in the [readme](https://github.com/ocadotechnology/aimmo), you should set up a virtual environment. 14 | * e.g. the first time, `mkvirtualenv -a path/to/aimmo aimmo` 15 | * and thereafter: `workon aimmo` 16 | * You can test your change by running the test suite - you can go to the root of the project and type: `python example_project/manage.py test` ; but Travis uses `python setup.py test` (will also install stuff in your virtualenv needed for the tests) 17 | * To manually test things and run the project, `./run` in the root. 18 | 19 | ## Great, you can **commit**, open a **Pull Request**, and we'll **review** it... 20 | * Then you can commit! On a new branch for a new Pull Request please. 21 | * If your commit resolves a GitHub issue, please include “fixes #123” in the commit message. 22 | * Then you can push to your forked repo, and create a pull request from your branch to ocadotechnology's master branch. 23 | * Some tests will run automatically: Travis will run the automated tests, coverage will test the test coverage. Please fix found issues, then repush on your branch - it will rerun the tests. 24 | * Do not accept a PR yourself - at least someone else should review your code and approve it first. 25 | * Some old PRs will need to see the branch rebased on the current master 26 | * When a PR is accepted, **congrats!** It will be merged on master. 27 | -------------------------------------------------------------------------------- /aimmo-game/simulation/avatar/fog_of_war.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from simulation.location import Location 4 | from simulation.world_map import WorldMap 5 | 6 | 7 | def apply_fog_of_war(world_map, avatar_wrapper): 8 | """ 9 | Takes a world state and an avatar and returns a personalised view of the world. 10 | :param world_map: the state of the game 11 | :param avatar_wrapper: the application's view of the avatar 12 | :return: a world state tailored to the given avatar 13 | """ 14 | 15 | location = avatar_wrapper.location 16 | no_fog_distance = avatar_wrapper.fog_of_war_modifier + world_map.get_no_fog_distance() 17 | partial_fog_distance = avatar_wrapper.fog_of_war_modifier + world_map.get_partial_fog_distance() 18 | 19 | lower_x = max(location.x - partial_fog_distance, world_map.min_x()) 20 | lower_y = max(location.y - partial_fog_distance, world_map.min_y()) 21 | upper_x = min(location.x + partial_fog_distance, world_map.max_x()) 22 | upper_y = min(location.y + partial_fog_distance, world_map.max_y()) 23 | 24 | grid = {} 25 | for x in range(lower_x, upper_x + 1): 26 | for y in range(lower_y, upper_y + 1): 27 | cell_location = Location(x, y) 28 | if world_map.is_on_map(cell_location): 29 | x_dist = abs(cell_location.x - location.x) 30 | y_dist = abs(cell_location.y - location.y) 31 | if should_partially_fog(no_fog_distance, partial_fog_distance, x_dist, y_dist): 32 | grid[location] = partially_fog_cell(world_map.get_cell(cell_location)) 33 | else: 34 | grid[location] = world_map.get_cell(cell_location) 35 | return WorldMap(grid, world_map.settings) 36 | 37 | 38 | def should_partially_fog(no_fog_distance, partial_fog_distance, x_dist, y_dist): 39 | return x_dist > no_fog_distance or y_dist > no_fog_distance 40 | 41 | 42 | def partially_fog_cell(cell): 43 | partially_fogged_cell = copy.deepcopy(cell) 44 | partially_fogged_cell.habitable = True 45 | partially_fogged_cell.avatar = None 46 | partially_fogged_cell.pickup = None 47 | partially_fogged_cell.partially_fogged = True 48 | return partially_fogged_cell 49 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_pickups.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import abc 4 | from unittest import TestCase 5 | 6 | from simulation import effects, pickups 7 | from .dummy_avatar import DummyAvatar 8 | from .maps import MockCell 9 | 10 | 11 | class _BaseCases(object): 12 | class BasePickupTestCase(TestCase): 13 | __metaclass__ = abc.ABCMeta 14 | 15 | def setUp(self): 16 | self.avatar = DummyAvatar(1, None) 17 | self.cell = MockCell() 18 | self.pickup = self.pickup_class(self.cell) 19 | 20 | def apply_pickup(self): 21 | self.pickup.apply(self.avatar) 22 | 23 | def test_pickup_removed(self): 24 | self.apply_pickup() 25 | self.assertIs(self.cell.pickup, None) 26 | 27 | @abc.abstractproperty 28 | def pickup_class(self): 29 | pass 30 | 31 | class BasePickupEffectTestCase(BasePickupTestCase): 32 | __metaclass__ = abc.ABCMeta 33 | 34 | @abc.abstractproperty 35 | def effect_class(self): 36 | pass 37 | 38 | def test_effect_added(self): 39 | self.apply_pickup() 40 | self.assertEqual(len(self.avatar.effects), 1) 41 | self.assertIsInstance(list(self.avatar.effects)[0], self.effect_class) 42 | 43 | 44 | class TestHealthPickup(_BaseCases.BasePickupTestCase): 45 | pickup_class = pickups.HealthPickup 46 | 47 | def test_health_increases(self): 48 | self.apply_pickup() 49 | self.assertEqual(self.avatar.health, 8) 50 | 51 | def test_serialise(self): 52 | self.assertEqual(self.pickup.serialise(), {'type': 'health', 'health_restored': 3}) 53 | 54 | 55 | class TestInvulnerabilityPickup(_BaseCases.BasePickupEffectTestCase): 56 | pickup_class = pickups.InvulnerabilityPickup 57 | effect_class = effects.InvulnerabilityPickupEffect 58 | 59 | def test_serialise(self): 60 | self.assertEqual(self.pickup.serialise(), {'type': 'invulnerability'}) 61 | 62 | 63 | class TestDamagePickup(_BaseCases.BasePickupEffectTestCase): 64 | pickup_class = pickups.DamagePickup 65 | effect_class = effects.DamagePickupEffect 66 | 67 | def test_serialise(self): 68 | self.assertEqual(self.pickup.serialise(), {'type': 'damage', 'damage_boost': 5}) 69 | -------------------------------------------------------------------------------- /aimmo-game-worker/simulation/world_map.py: -------------------------------------------------------------------------------- 1 | from .avatar_state import AvatarState 2 | from .location import Location 3 | 4 | 5 | class Cell(object): 6 | 7 | """ 8 | Any position on the world grid. 9 | """ 10 | 11 | def __init__(self, location, avatar=None, **kwargs): 12 | self.location = Location(**location) 13 | if avatar: 14 | self.avatar = AvatarState(**avatar) 15 | for (key, value) in kwargs.items(): 16 | setattr(self, key, value) 17 | 18 | def __repr__(self): 19 | return 'Cell({} h={} s={} a={} p={})'.format( 20 | self.location, 21 | getattr(self, 'habitable', 0), 22 | self.generates_score, 23 | getattr(self, 'avatar', 0), 24 | getattr(self, 'pickup', 0)) 25 | 26 | def __eq__(self, other): 27 | return self.location == other.location 28 | 29 | def __ne__(self, other): 30 | return not self == other 31 | 32 | 33 | class WorldMap(object): 34 | 35 | """ 36 | The non-player world state. 37 | """ 38 | 39 | def __init__(self, cells): 40 | self.cells = {} 41 | for cell_data in cells: 42 | cell = Cell(**cell_data) 43 | self.cells[cell.location] = cell 44 | 45 | def all_cells(self): 46 | return self.cells.values() 47 | 48 | def score_cells(self): 49 | return (c for c in self.all_cells() if c.generates_score) 50 | 51 | def pickup_cells(self): 52 | return (c for c in self.all_cells() if getattr(c, 'pickup', False)) 53 | 54 | def partially_fogged_cells(self): 55 | return (c for c in self.all_cells() if c.partially_fogged) 56 | 57 | def is_visible(self, location): 58 | return location in self.cells 59 | 60 | def get_cell(self, location): 61 | cell = self.cells[location] 62 | assert cell.location == location, 'location lookup mismatch: arg={}, found={}'.format( 63 | location, cell.location) 64 | return cell 65 | 66 | def can_move_to(self, target_location): 67 | try: 68 | cell = self.get_cell(target_location) 69 | except KeyError: 70 | return False 71 | return getattr(cell, 'habitable', False) and not getattr(cell, 'avatar', False) 72 | 73 | def __repr__(self): 74 | return repr(self.cells) 75 | -------------------------------------------------------------------------------- /aimmo-game/simulation/avatar/avatar_manager.py: -------------------------------------------------------------------------------- 1 | from simulation.avatar.avatar_wrapper import AvatarWrapper 2 | from simulation.avatar.avatar_appearance import AvatarAppearance 3 | import copy 4 | 5 | class AvatarManager(object): 6 | """ 7 | Stores all game avatars. An avatar can belong to on of the following three lists: 8 | - avatars_by_id: If the avatar is on the game. 9 | - created_avatars_by_id: If the avatar has ust been created. The avatar is put in 10 | this list at first then when the client know about it it's but to avatars_by_id. 11 | - deleted_avatars_by_id: Before completely removing the avatar from the list, the 12 | avatar is put in this list and then, when the client knows that it has to delete 13 | it from the scene, it's removed completely. 14 | """ 15 | 16 | def __init__(self): 17 | self.avatars_by_id = {} 18 | self.avatars_to_create_by_id = {} 19 | self.avatars_to_delete_by_id = {} 20 | 21 | def add_avatar(self, player_id, worker_url, location): 22 | avatar = AvatarWrapper(player_id, location, worker_url, AvatarAppearance("#000", "#ddd", "#777", "#fff")) 23 | self.avatars_to_create_by_id[player_id] = avatar 24 | return avatar 25 | 26 | def get_avatar(self, user_id): 27 | return self.avatars_by_id[user_id] 28 | 29 | def remove_avatar(self, user_id): 30 | if user_id in self.avatars_by_id: 31 | self.avatars_to_delete_by_id[user_id] = copy.deepcopy(self.avatars_by_id[user_id]) 32 | del self.avatars_by_id[user_id] 33 | 34 | @property 35 | def avatars(self): 36 | return self.avatars_by_id.viewvalues() 37 | 38 | # Returns the newly created avatars and then puts them in the normal avatars list. 39 | def avatars_to_create(self): 40 | avatars_to_create_array = list(self.avatars_to_create_by_id.viewvalues())[:] 41 | self.avatars_by_id.update(dict(self.avatars_to_create_by_id)) 42 | self.avatars_to_create_by_id = {} 43 | 44 | return avatars_to_create_array 45 | 46 | # Returns the avatars that need to be removed from the scene. 47 | def avatars_to_delete(self): 48 | avatars_to_delete_array = list(self.avatars_to_delete_by_id.viewvalues())[:] 49 | self.avatars_to_delete_by_id = {} 50 | 51 | return avatars_to_delete_array 52 | 53 | @property 54 | def active_avatars(self): 55 | return [player for player in self.avatars] 56 | -------------------------------------------------------------------------------- /aimmo-game/simulation/pickups.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod, abstractproperty 2 | import effects 3 | 4 | 5 | class _Pickup(object): 6 | __metaclass__ = ABCMeta 7 | 8 | def __init__(self, cell): 9 | self.cell = cell 10 | 11 | def __str__(self): 12 | return self.__class__.__name__ 13 | 14 | def delete(self): 15 | self.cell.pickup = None 16 | 17 | def apply(self, avatar): 18 | self._apply(avatar) 19 | self.delete() 20 | 21 | @abstractmethod 22 | def _apply(self, avatar): 23 | raise NotImplementedError() 24 | 25 | @abstractmethod 26 | def serialise(self): 27 | raise NotImplementedError() 28 | 29 | 30 | class HealthPickup(_Pickup): 31 | def __init__(self, cell, health_restored=3): 32 | super(HealthPickup, self).__init__(cell) 33 | self.health_restored = health_restored 34 | 35 | def __repr__(self): 36 | return 'HealthPickup(health_restored={})'.format(self.health_restored) 37 | 38 | def serialise(self): 39 | return { 40 | 'type': 'health', 41 | 'health_restored': self.health_restored, 42 | } 43 | 44 | def _apply(self, avatar): 45 | avatar.health += self.health_restored 46 | 47 | 48 | class _PickupEffect(_Pickup): 49 | __metaclass__ = ABCMeta 50 | 51 | def __init__(self, *args): 52 | super(_PickupEffect, self).__init__(*args) 53 | self.params = [] 54 | 55 | @abstractproperty 56 | def EFFECT(self): 57 | raise NotImplementedError() 58 | 59 | def _apply(self, avatar): 60 | self.params.append(avatar) 61 | avatar.effects.add(self.EFFECT(*self.params)) 62 | 63 | 64 | class InvulnerabilityPickup(_PickupEffect): 65 | EFFECT = effects.InvulnerabilityPickupEffect 66 | 67 | def serialise(self): 68 | return { 69 | 'type': 'invulnerability', 70 | } 71 | 72 | 73 | class DamagePickup(_PickupEffect): 74 | EFFECT = effects.DamagePickupEffect 75 | 76 | def __init__(self, *args): 77 | super(DamagePickup, self).__init__(*args) 78 | self.damage_boost = 5 79 | self.params.append(self.damage_boost) 80 | 81 | def __repr__(self): 82 | return 'DamagePickup(damage_boost={})'.format(self.damage_boost) 83 | 84 | def serialise(self): 85 | return { 86 | 'type': 'damage', 87 | 'damage_boost': self.damage_boost, 88 | } 89 | 90 | 91 | ALL_PICKUPS = ( 92 | HealthPickup, 93 | InvulnerabilityPickup, 94 | DamagePickup, 95 | ) 96 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Code for Life 3 | # 4 | # Copyright (C) 2015, Ocado Innovation Limited 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with this program. If not, see . 18 | # 19 | # ADDITIONAL TERMS – Section 7 GNU General Public Licence 20 | # 21 | # This licence does not grant any right, title or interest in any “Ocado” logos, 22 | # trade names or the trademark “Ocado” or any other trademarks or domain names 23 | # owned by Ocado Innovation Limited or the Ocado group of companies or any other 24 | # distinctive brand features of “Ocado” as may be secured from time to time. You 25 | # must not distribute any modification of this program using the trademark 26 | # “Ocado” or claim any affiliation or association with Ocado or its employees. 27 | # 28 | # You are not authorised to use the name Ocado (or any of its trade names) or 29 | # the names of any author or contributor in advertising or for publicity purposes 30 | # pertaining to the distribution of this program, without the prior written 31 | # authorisation of Ocado. 32 | # 33 | # Any propagation, distribution or conveyance of this program must include this 34 | # copyright notice and these terms. You must not misrepresent the origins of this 35 | # program; modified versions of the program must be marked as such and not 36 | # identified as the original program. 37 | """ 38 | WSGI config for example_project project. 39 | 40 | This module contains the WSGI application used by Django's development server 41 | and any production WSGI deployments. It should expose a module-level variable 42 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 43 | this application via the ``WSGI_APPLICATION`` setting. 44 | 45 | Usually you will have the standard Django WSGI application here, but it also 46 | might make sense to replace the whole Django WSGI application with a custom one 47 | that later delegates to the Django one. For example, you could introduce WSGI 48 | middleware here, or combine a Django application with an application of another 49 | framework. 50 | 51 | """ 52 | import os 53 | 54 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 55 | 56 | from django.core.wsgi import get_wsgi_application 57 | application = get_wsgi_application() 58 | -------------------------------------------------------------------------------- /players/autoconfig.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Code for Life 3 | # 4 | # Copyright (C) 2015, Ocado Innovation Limited 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with this program. If not, see . 18 | # 19 | # ADDITIONAL TERMS – Section 7 GNU General Public Licence 20 | # 21 | # This licence does not grant any right, title or interest in any “Ocado” logos, 22 | # trade names or the trademark “Ocado” or any other trademarks or domain names 23 | # owned by Ocado Innovation Limited or the Ocado group of companies or any other 24 | # distinctive brand features of “Ocado” as may be secured from time to time. You 25 | # must not distribute any modification of this program using the trademark 26 | # “Ocado” or claim any affiliation or association with Ocado or its employees. 27 | # 28 | # You are not authorised to use the name Ocado (or any of its trade names) or 29 | # the names of any author or contributor in advertising or for publicity purposes 30 | # pertaining to the distribution of this program, without the prior written 31 | # authorisation of Ocado. 32 | # 33 | # Any propagation, distribution or conveyance of this program must include this 34 | # copyright notice and these terms. You must not misrepresent the origins of this 35 | # program; modified versions of the program must be marked as such and not 36 | # identified as the original program. 37 | '''Players autoconfig''' 38 | 39 | DEFAULT_SETTINGS = { 40 | 'AUTOCONFIG_INDEX_VIEW': 'aimmo/home', 41 | 'STATIC_URL': '/static/', 42 | } 43 | 44 | SETTINGS = { 45 | 'INSTALLED_APPS': [ 46 | 'django.contrib.auth', 47 | 'django.contrib.messages', 48 | 'django.contrib.staticfiles', 49 | 'django_js_reverse', 50 | ], 51 | 'TEMPLATES': [ 52 | { 53 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 54 | 'APP_DIRS': True, 55 | 'OPTIONS': { 56 | 'context_processors': [ 57 | 'django.template.context_processors.debug', 58 | 'django.template.context_processors.request', 59 | 'django.contrib.auth.context_processors.auth', 60 | 'django.contrib.messages.context_processors.messages', 61 | ] 62 | } 63 | } 64 | ], 65 | 'USE_TZ': True, 66 | } 67 | -------------------------------------------------------------------------------- /aimmo-game/service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import cPickle as pickle 3 | import logging 4 | import os 5 | import sys 6 | 7 | import eventlet 8 | 9 | eventlet.sleep() 10 | eventlet.monkey_patch() 11 | 12 | import flask 13 | from flask_socketio import SocketIO 14 | 15 | from simulation.turn_manager import state_provider 16 | from simulation import map_generator 17 | from simulation.avatar.avatar_manager import AvatarManager 18 | from simulation.turn_manager import ConcurrentTurnManager 19 | from simulation.worker_manager import WORKER_MANAGERS 20 | from simulation.world_state import WorldState 21 | 22 | app = flask.Flask(__name__) 23 | socketio = SocketIO() 24 | 25 | worker_manager = None 26 | 27 | # Every user has its own world state. 28 | world_state_manager = {} 29 | 30 | @socketio.on('connect') 31 | def world_init(): 32 | socketio.emit('world-init') 33 | 34 | @socketio.on('disconnect') 35 | def exit_game(): 36 | del world_state_manager[flask.session['id']] 37 | 38 | @socketio.on('client-ready') 39 | def client_ready(client_id): 40 | flask.session['id'] = client_id 41 | world_state = WorldState(state_provider) 42 | world_state_manager[client_id] = world_state 43 | 44 | def send_world_update(): 45 | for world_state in world_state_manager.values(): 46 | socketio.emit( 47 | 'world-update', 48 | world_state.get_updates(), 49 | broadcast=True, 50 | ) 51 | 52 | @app.route('/') 53 | def healthcheck(): 54 | return 'HEALTHY' 55 | 56 | @app.route('/player/') 57 | def player_data(player_id): 58 | player_id = int(player_id) 59 | return flask.jsonify({ 60 | 'code': worker_manager.get_code(player_id), 61 | 'options': {}, # Game options 62 | 'state': None, 63 | }) 64 | 65 | def run_game(port): 66 | global worker_manager 67 | 68 | print("Running game...") 69 | settings = pickle.loads(os.environ['settings']) 70 | 71 | api_url = os.environ.get('GAME_API_URL', 'http://localhost:8000/players/api/games/') 72 | generator = getattr(map_generator, settings['GENERATOR'])(settings) 73 | player_manager = AvatarManager() 74 | game_state = generator.get_game_state(player_manager) 75 | 76 | turn_manager = ConcurrentTurnManager(game_state=game_state, end_turn_callback=send_world_update, completion_url=api_url+'complete/') 77 | WorkerManagerClass = WORKER_MANAGERS[os.environ.get('WORKER_MANAGER', 'local')] 78 | worker_manager = WorkerManagerClass(game_state=game_state, users_url=api_url, port=port) 79 | 80 | worker_manager.start() 81 | turn_manager.start() 82 | 83 | 84 | if __name__ == '__main__': 85 | logging.basicConfig(level=logging.INFO) 86 | 87 | socketio.init_app(app, resource=os.environ.get('SOCKETIO_RESOURCE', 'socket.io')) 88 | 89 | run_game(int(sys.argv[2])) 90 | 91 | socketio.run( 92 | app, 93 | debug=False, 94 | host=sys.argv[1], 95 | port=int(sys.argv[2]), 96 | use_reloader=False, 97 | ) 98 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import signal 5 | import subprocess 6 | import sys 7 | import time 8 | import traceback 9 | from subprocess import CalledProcessError 10 | 11 | _SCRIPT_LOCATION = os.path.abspath(os.path.dirname(__file__)) 12 | _MANAGE_PY = os.path.join(_SCRIPT_LOCATION, 'example_project', 'manage.py') 13 | _SERVICE_PY = os.path.join(_SCRIPT_LOCATION, 'aimmo-game-creator', 'service.py') 14 | 15 | 16 | if __name__ == '__main__': 17 | logging.basicConfig() 18 | sys.path.append(os.path.join(_SCRIPT_LOCATION, 'example_project')) 19 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 20 | 21 | 22 | def log(message): 23 | print >> sys.stderr, message 24 | 25 | 26 | def run_command(args, capture_output=False): 27 | try: 28 | if capture_output: 29 | return subprocess.check_output(args) 30 | else: 31 | subprocess.check_call(args) 32 | except CalledProcessError as e: 33 | log('Command failed with exit status %d: %s' % (e.returncode, ' '.join(args))) 34 | raise 35 | 36 | 37 | PROCESSES = [] 38 | 39 | 40 | def run_command_async(args): 41 | p = subprocess.Popen(args) 42 | PROCESSES.append(p) 43 | return p 44 | 45 | 46 | def create_superuser_if_missing(username, password): 47 | from django.contrib.auth.models import User 48 | try: 49 | User.objects.get_by_natural_key(username) 50 | except User.DoesNotExist: 51 | log('Creating superuser %s with password %s' % (username, password)) 52 | User.objects.create_superuser(username=username, email='admin@admin.com', password=password) 53 | 54 | 55 | def main(use_minikube): 56 | 57 | run_command(['pip', 'install', '-e', _SCRIPT_LOCATION]) 58 | run_command(['python', _MANAGE_PY, 'migrate', '--noinput']) 59 | run_command(['python', _MANAGE_PY, 'collectstatic', '--noinput']) 60 | 61 | create_superuser_if_missing(username='admin', password='admin') 62 | 63 | server_args = [] 64 | if use_minikube: 65 | # Import minikube here, so we can install the deps first 66 | run_command(['pip', 'install', '-r', os.path.join(_SCRIPT_LOCATION, 'minikube_requirements.txt')]) 67 | import minikube 68 | 69 | minikube.start() 70 | server_args.append('0.0.0.0:8000') 71 | os.environ['AIMMO_MODE'] = 'minikube' 72 | else: 73 | time.sleep(2) 74 | game = run_command_async(['python', _SERVICE_PY, '127.0.0.1', '5000']) 75 | os.environ['AIMMO_MODE'] = 'threads' 76 | server = run_command_async(['python', _MANAGE_PY, 'runserver'] + server_args) 77 | 78 | try: 79 | game.wait() 80 | except NameError: 81 | pass 82 | server.wait() 83 | 84 | 85 | if __name__ == '__main__': 86 | try: 87 | main('--kube' in sys.argv or '-k' in sys.argv) 88 | except Exception as err: 89 | traceback.print_exc() 90 | raise 91 | finally: 92 | os.killpg(0, signal.SIGTERM) 93 | time.sleep(0.9) 94 | os.killpg(0, signal.SIGKILL) 95 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/dummy_avatar.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from simulation.action import MoveAction, WaitAction 4 | from simulation.avatar.avatar_manager import AvatarManager 5 | from simulation.avatar.avatar_wrapper import AvatarWrapper 6 | from simulation.direction import NORTH, EAST, SOUTH, WEST 7 | 8 | 9 | class DummyAvatar(AvatarWrapper): 10 | def __init__(self, player_id=1, initial_location=(0, 0)): 11 | # TODO: extract avatar state and state-altering methods into a new class. 12 | # The new class is to be shared between DummyAvatarRunner and AvatarRunner 13 | super(DummyAvatar, self).__init__(player_id, initial_location, None, None) 14 | self.times_died = 0 15 | self.attack_strength = 1 16 | self.effects = set() 17 | self.resistance = 0 18 | 19 | def decide_action(self, state_view): 20 | self._action = self.handle_turn(state_view) 21 | return True 22 | 23 | def handle_turn(self, state_view): 24 | raise NotImplementedError() 25 | 26 | def add_event(self, event): 27 | self.events.append(event) 28 | 29 | def die(self, respawn_loc): 30 | self.location = respawn_loc 31 | self.times_died += 1 32 | 33 | def serialise(self): 34 | return 'Dummy' 35 | 36 | def damage(self, amount): 37 | self.health -= amount 38 | return amount 39 | 40 | 41 | class WaitDummy(DummyAvatar): 42 | ''' 43 | Avatar that always waits. 44 | ''' 45 | def handle_turn(self, state_view): 46 | return WaitAction(self) 47 | 48 | 49 | class MoveDummy(DummyAvatar): 50 | ''' 51 | Avatar that always moves in one direction. 52 | ''' 53 | def __init__(self, player_id, initial_location, direction): 54 | super(MoveDummy, self).__init__(player_id, initial_location) 55 | self._direction = direction 56 | 57 | def handle_turn(self, state_view): 58 | return MoveAction(self, self._direction.dict) 59 | 60 | 61 | class MoveNorthDummy(MoveDummy): 62 | def __init__(self, player_id, initial_location): 63 | super(MoveNorthDummy, self).__init__(player_id, initial_location, NORTH) 64 | 65 | 66 | class MoveEastDummy(MoveDummy): 67 | def __init__(self, player_id, initial_location): 68 | super(MoveEastDummy, self).__init__(player_id, initial_location, EAST) 69 | 70 | 71 | class MoveSouthDummy(MoveDummy): 72 | def __init__(self, player_id, initial_location): 73 | super(MoveSouthDummy, self).__init__(player_id, initial_location, SOUTH) 74 | 75 | 76 | class MoveWestDummy(MoveDummy): 77 | def __init__(self, player_id, initial_location): 78 | super(MoveWestDummy, self).__init__(player_id, initial_location, WEST) 79 | 80 | 81 | class DummyAvatarManager(AvatarManager): 82 | def __init__(self, dummy_list=[]): 83 | super(DummyAvatarManager, self).__init__() 84 | self.dummy_list = dummy_list 85 | 86 | def add_avatar(self, player_id, worker_url, location): 87 | try: 88 | dummy = self.dummy_list.pop(0) 89 | except IndexError: 90 | dummy = WaitDummy 91 | self.avatars_by_id[player_id] = dummy(player_id, location) 92 | return self.avatars_by_id[player_id] 93 | 94 | def add_avatar_directly(self, avatar): 95 | self.avatars_by_id[avatar.player_id] = avatar 96 | -------------------------------------------------------------------------------- /players/static/js/program.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | $( document ).ready(function() { 4 | //CONSTANTS 5 | const DANGER_CLASS = 'alert-danger'; 6 | const SUCCESS_CLASS = 'alert-success'; 7 | const defaultProgram = "print 'Sorry, could not retrieve saved data'\n"; 8 | 9 | 10 | const StatusCode = Object.freeze({ 11 | "SUCCESS": function(message) { showAlert('Success:

' + message, SUCCESS_CLASS); }, 12 | "SERVER_ERROR": function(message) { showAlert(message, DANGER_CLASS); }, 13 | "USER_ERROR": function(message) { showAlert('Your code has some problems:

' + message, DANGER_CLASS); }, 14 | }); 15 | 16 | //HELPER FUNCTIONS 17 | function showAlert (alertString, alertType) { 18 | if (alertType === DANGER_CLASS || alertType === SUCCESS_CLASS) { 19 | var alertText = $('#alerts'); 20 | alertText.removeClass('alert-success alert-danger'); 21 | alertText.addClass(alertType); 22 | alertText.html(alertString + ''); 23 | $(".close").click(function(){ 24 | alertText.hide(); 25 | }); 26 | alertText.show(); 27 | } 28 | } 29 | 30 | // trigger extension 31 | ace.require("ace/ext/language_tools"); 32 | var editor = ace.edit("editor"); 33 | editor.setTheme("ace/theme/monokai"); 34 | editor.getSession().setMode("ace/mode/python"); 35 | // enable autocompletion and snippets 36 | editor.setOptions({ 37 | enableBasicAutocompletion: true, 38 | enableSnippets: true, 39 | enableLiveAutocompletion: true 40 | }); 41 | 42 | var checkStatus = function(data) { 43 | if (data != undefined && StatusCode[data.status] != undefined) { 44 | return StatusCode[data.status](data.message); 45 | } else { 46 | return showAlert('An unknown error has occurred whilst saving:', DANGER_CLASS); 47 | } 48 | }; 49 | 50 | //EVENTS 51 | $('#saveBtn').click(function(event) { 52 | event.preventDefault(); 53 | $.ajax({ 54 | url: Urls['aimmo/code'](id=GAME_ID), 55 | type: 'POST', 56 | dataType: 'json', 57 | data: {code: editor.getValue(), csrfmiddlewaretoken: $('#saveForm input[name=csrfmiddlewaretoken]').val()}, 58 | success: function(data) { 59 | $('#alerts').hide(); 60 | checkStatus(data); 61 | }, 62 | error: function(jqXHR, textStatus, errorThrown) { 63 | showAlert('An error has occurred whilst saving: ' + errorThrown, DANGER_CLASS); 64 | } 65 | }); 66 | }); 67 | 68 | //DISPLAY CODE 69 | $.ajax({ 70 | url: Urls['aimmo/code'](id=GAME_ID), 71 | type: 'GET', 72 | dataType: 'text', 73 | success: function(data) { 74 | editor.setValue(data); 75 | editor.selection.moveCursorFileStart(); 76 | editor.setReadOnly(false); 77 | }, 78 | error: function(jqXHR, textStatus, errorThrown) { 79 | showAlert('Could not retrieve saved data', DANGER_CLASS); 80 | editor.setValue(defaultProgram); 81 | editor.selection.moveCursorFileStart(); 82 | editor.setReadOnly(true); 83 | } 84 | }); 85 | 86 | }); 87 | 88 | 89 | -------------------------------------------------------------------------------- /players/migrations/0003_auto_20160802_1418.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models, transaction, IntegrityError 6 | 7 | import players.models 8 | 9 | 10 | def migrate_data_forward(apps, schema_editor): 11 | Player = apps.get_model("players", "Player") 12 | Avatar = apps.get_model("players", "Avatar") 13 | Game = apps.get_model("players", "Game") 14 | 15 | if Player.objects.count() == 0: 16 | return 17 | main_game = Game(pk=1, name="main") 18 | main_game.save() 19 | 20 | avatars = [Avatar(game=main_game, owner=player.user, code=player.code) 21 | for player in Player.objects.all()] 22 | Avatar.objects.bulk_create(avatars) 23 | 24 | 25 | def migrate_data_backward(apps, schema_editor): 26 | Player = apps.get_model("players", "Player") 27 | Avatar = apps.get_model("players", "Avatar") 28 | 29 | if Avatar.objects.count() == 0: 30 | return 31 | for avatar in Avatar.objects.all(): 32 | player = Player(user=avatar.owner, code=avatar.code) 33 | try: 34 | with transaction.atomic(): 35 | player.save() 36 | except IntegrityError: 37 | # Stuff doesn't map as can have more than one Avatar but only one Player 38 | pass 39 | 40 | 41 | class Migration(migrations.Migration): 42 | 43 | dependencies = [ 44 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 45 | ('players', '0002_auto_20160601_1914'), 46 | ] 47 | 48 | operations = [ 49 | migrations.CreateModel( 50 | name='Avatar', 51 | fields=[ 52 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 53 | ('code', models.TextField()), 54 | ('auth_token', models.CharField(default=players.models.generate_auth_token, max_length=24)), 55 | ], 56 | ), 57 | migrations.CreateModel( 58 | name='Game', 59 | fields=[ 60 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 61 | ('name', models.CharField(max_length=100)), 62 | ('auth_token', models.CharField(default=players.models.generate_auth_token, max_length=24)), 63 | ('public', models.BooleanField(default=True)), 64 | ('can_play', models.ManyToManyField(related_name='playable_games', to=settings.AUTH_USER_MODEL)), 65 | ('owner', models.ForeignKey(related_name='owned_games', to=settings.AUTH_USER_MODEL, null=True)), 66 | ], 67 | ), 68 | migrations.AddField( 69 | model_name='avatar', 70 | name='game', 71 | field=models.ForeignKey(to='players.Game'), 72 | ), 73 | migrations.AddField( 74 | model_name='avatar', 75 | name='owner', 76 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL), 77 | ), 78 | migrations.AlterUniqueTogether( 79 | name='avatar', 80 | unique_together=set([('owner', 'game')]), 81 | ), 82 | migrations.RunPython( 83 | migrate_data_forward, 84 | migrate_data_backward 85 | ), 86 | migrations.RemoveField( 87 | model_name='player', 88 | name='user', 89 | ), 90 | migrations.DeleteModel( 91 | name='Player', 92 | ), 93 | ] 94 | -------------------------------------------------------------------------------- /aimmo-game-creator/tests/test_worker_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import cPickle as pickle 4 | import unittest 5 | from json import dumps 6 | 7 | from httmock import HTTMock 8 | 9 | from worker_manager import WorkerManager 10 | 11 | 12 | class ConcreteWorkerManager(WorkerManager): 13 | def __init__(self, *args, **kwargs): 14 | self.final_workers = set() 15 | self.clear() 16 | super(ConcreteWorkerManager, self).__init__(*args, **kwargs) 17 | 18 | def clear(self): 19 | self.removed_workers = [] 20 | self.added_workers = {} 21 | 22 | def create_worker(self, game_id, data): 23 | self.added_workers[game_id] = data 24 | self.final_workers.add(game_id) 25 | 26 | def remove_worker(self, game_id): 27 | self.removed_workers.append(game_id) 28 | try: 29 | self.final_workers.remove(game_id) 30 | except KeyError: 31 | pass 32 | 33 | 34 | class RequestMock(object): 35 | def __init__(self, num_games): 36 | self.value = self._generate_response(num_games) 37 | self.urls_requested = [] 38 | 39 | def _generate_response(self, num_games): 40 | return { 41 | str(i): { 42 | 'name': 'Game %s' % i, 43 | 'settings': pickle.dumps({ 44 | 'test': i, 45 | 'test2': 'Settings %s' % i, 46 | }) 47 | } for i in xrange(num_games) 48 | } 49 | 50 | def __call__(self, url, request): 51 | self.urls_requested.append(url.geturl()) 52 | return dumps(self.value) 53 | 54 | 55 | class TestWorkerManager(unittest.TestCase): 56 | def setUp(self): 57 | self.worker_manager = ConcreteWorkerManager('http://test/') 58 | 59 | def test_correct_url_requested(self): 60 | mocker = RequestMock(0) 61 | with HTTMock(mocker): 62 | self.worker_manager.update() 63 | self.assertEqual(len(mocker.urls_requested), 1) 64 | self.assertRegexpMatches(mocker.urls_requested[0], 'http://test/*') 65 | 66 | def test_workers_added(self): 67 | mocker = RequestMock(3) 68 | with HTTMock(mocker): 69 | self.worker_manager.update() 70 | self.assertEqual(len(self.worker_manager.final_workers), 3) 71 | self.assertEqual(len(list(self.worker_manager._data.get_games())), 3) 72 | for i in xrange(3): 73 | self.assertIn(str(i), self.worker_manager.final_workers) 74 | self.assertEqual( 75 | pickle.loads(str(self.worker_manager.added_workers[str(i)]['settings'])), 76 | {'test': i, 'test2': 'Settings %s' % i} 77 | ) 78 | self.assertEqual(self.worker_manager.added_workers[str(i)]['name'], 'Game %s' % i) 79 | 80 | def test_remove_games(self): 81 | mocker = RequestMock(3) 82 | with HTTMock(mocker): 83 | self.worker_manager.update() 84 | del mocker.value['1'] 85 | self.worker_manager.update() 86 | self.assertNotIn(1, self.worker_manager.final_workers) 87 | 88 | def test_added_workers_given_correct_url(self): 89 | mocker = RequestMock(3) 90 | with HTTMock(mocker): 91 | self.worker_manager.update() 92 | for i in xrange(3): 93 | self.assertEqual( 94 | self.worker_manager.added_workers[str(i)]['GAME_API_URL'], 95 | 'http://test/{}/'.format(i) 96 | ) 97 | self.assertEqual(self.worker_manager.added_workers[str(i)]['name'], 'Game %s' % i) 98 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/maps.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from collections import defaultdict 4 | 5 | from simulation.location import Location 6 | from simulation.world_map import Cell, WorldMap 7 | 8 | 9 | class MockPickup(object): 10 | def __init__(self, name='', cell=None): 11 | self.applied_to = None 12 | self.name = name 13 | self.cell = None 14 | 15 | def apply(self, avatar): 16 | self.applied_to = avatar 17 | if self.cell: 18 | self.cell.pickup = None 19 | 20 | def serialise(self): 21 | return {'name': self.name} 22 | 23 | 24 | class MockCell(Cell): 25 | def __init__(self, location=1, habitable=True, generates_score=False, 26 | avatar=None, pickup=None, name=None, actions=[]): 27 | self.location = location 28 | self.habitable = habitable 29 | self.generates_score = generates_score 30 | self.avatar = avatar 31 | self.pickup = pickup 32 | self.name = name 33 | self.actions = actions 34 | self.partially_fogged = False 35 | 36 | def __eq__(self, other): 37 | return self is other 38 | 39 | 40 | class InfiniteMap(WorldMap): 41 | def __init__(self): 42 | self._cell_cache = {} 43 | [self.get_cell(Location(x, y)) for x in range(5) for y in range(5)] 44 | self.updates = 0 45 | self.num_avatars = None 46 | self.settings = defaultdict(lambda: 0) 47 | 48 | def is_on_map(self, target_location): 49 | self.get_cell(target_location) 50 | return True 51 | 52 | def all_cells(self): 53 | return (cell for cell in self._cell_cache.values()) 54 | 55 | def get_cell(self, location): 56 | return self._cell_cache.setdefault(location, Cell(location)) 57 | 58 | def update(self, num_avatars): 59 | self.updates += 1 60 | self.num_avatars = num_avatars 61 | 62 | @property 63 | def num_rows(self): 64 | return float('inf') 65 | 66 | @property 67 | def num_cols(self): 68 | return float('inf') 69 | 70 | 71 | class EmptyMap(WorldMap): 72 | def __init__(self): 73 | pass 74 | 75 | def get_random_spawn_location(self): 76 | return Location(10, 10) 77 | 78 | def can_move_to(self, target_location): 79 | return False 80 | 81 | def all_cells(self): 82 | return iter(()) 83 | 84 | def get_cell(self, location): 85 | return Cell(location) 86 | 87 | 88 | class ScoreOnOddColumnsMap(InfiniteMap): 89 | def get_cell(self, location): 90 | default_cell = Cell(location, generates_score=(location.x % 2 == 1)) 91 | return self._cell_cache.setdefault(location, default_cell) 92 | 93 | 94 | class AvatarMap(WorldMap): 95 | def __init__(self, avatar): 96 | self._avatar = avatar 97 | self._cell_cache = {} 98 | 99 | def get_cell(self, location): 100 | if location not in self._cell_cache: 101 | cell = Cell(location) 102 | cell.avatar = self._avatar 103 | self._cell_cache[location] = cell 104 | return self._cell_cache[location] 105 | 106 | def get_random_spawn_location(self): 107 | return Location(10, 10) 108 | 109 | 110 | class PickupMap(WorldMap): 111 | def __init__(self, pickup): 112 | self._pickup = pickup 113 | 114 | def get_cell(self, location): 115 | cell = Cell(location) 116 | cell.pickup = self._pickup 117 | return cell 118 | -------------------------------------------------------------------------------- /players/models.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64encode 2 | from os import urandom 3 | 4 | from django.contrib.auth.models import User 5 | from django.db import models 6 | 7 | from players import app_settings 8 | 9 | GAME_GENERATORS = [ 10 | ('Main', 'Open World'), # Default 11 | ] + [('Level%s' % i, 'Level %s' % i) for i in xrange(1, app_settings.MAX_LEVEL+1)] 12 | 13 | 14 | def generate_auth_token(): 15 | return urlsafe_b64encode(urandom(16)) 16 | 17 | 18 | class GameQuerySet(models.QuerySet): 19 | def for_user(self, user): 20 | if user.is_authenticated(): 21 | return self.filter(models.Q(public=True) | models.Q(can_play=user)) 22 | else: 23 | return self.filter(public=True) 24 | 25 | def exclude_inactive(self): 26 | return self.filter(completed=False) 27 | 28 | 29 | class Game(models.Model): 30 | name = models.CharField(max_length=100) 31 | auth_token = models.CharField(max_length=24, default=generate_auth_token) 32 | owner = models.ForeignKey(User, blank=True, null=True, related_name='owned_games') 33 | public = models.BooleanField(default=True) 34 | can_play = models.ManyToManyField(User, related_name='playable_games') 35 | completed = models.BooleanField(default=False) 36 | main_user = models.ForeignKey(User, blank=True, null=True, related_name='games_for_user') 37 | objects = GameQuerySet.as_manager() 38 | static_data = models.TextField(blank=True, null=True) 39 | 40 | # Game config 41 | generator = models.CharField(max_length=20, choices=GAME_GENERATORS, default=GAME_GENERATORS[0][0]) 42 | target_num_cells_per_avatar = models.FloatField(default=16) 43 | target_num_score_locations_per_avatar = models.FloatField(default=0.5) 44 | score_despawn_chance = models.FloatField(default=0.02) 45 | target_num_pickups_per_avatar = models.FloatField(default=0.5) 46 | pickup_spawn_chance = models.FloatField(default=0.02) 47 | obstacle_ratio = models.FloatField(default=0.1) 48 | start_height = models.IntegerField(default=11) 49 | start_width = models.IntegerField(default=11) 50 | 51 | @property 52 | def is_active(self): 53 | return not self.completed 54 | 55 | def can_user_play(self, user): 56 | return self.public or user in self.can_play.all() 57 | 58 | def settings_as_dict(self): 59 | return { 60 | 'TARGET_NUM_CELLS_PER_AVATAR': self.target_num_cells_per_avatar, 61 | 'TARGET_NUM_SCORE_LOCATIONS_PER_AVATAR': self.target_num_score_locations_per_avatar, 62 | 'SCORE_DESPAWN_CHANCE': self.score_despawn_chance, 63 | 'TARGET_NUM_PICKUPS_PER_AVATAR': self.target_num_pickups_per_avatar, 64 | 'PICKUP_SPAWN_CHANCE': self.pickup_spawn_chance, 65 | 'OBSTACLE_RATIO': self.obstacle_ratio, 66 | 'START_HEIGHT': self.start_height, 67 | 'START_WIDTH': self.start_width, 68 | 'GENERATOR': self.generator, 69 | } 70 | 71 | def save(self, *args, **kwargs): 72 | super(Game, self).full_clean() 73 | super(Game, self).save(*args, **kwargs) 74 | 75 | 76 | class Avatar(models.Model): 77 | owner = models.ForeignKey(User) 78 | game = models.ForeignKey(Game) 79 | code = models.TextField() 80 | auth_token = models.CharField(max_length=24, default=generate_auth_token) 81 | 82 | class Meta: 83 | unique_together = ('owner', 'game') 84 | 85 | 86 | class LevelAttempt(models.Model): 87 | level_number = models.IntegerField() 88 | user = models.ForeignKey(User) 89 | game = models.OneToOneField(Game) 90 | 91 | class Meta: 92 | unique_together = ('level_number', 'user') 93 | -------------------------------------------------------------------------------- /players/templates/players/base.html: -------------------------------------------------------------------------------- 1 | {% load players_utils %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {# The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags #} 9 | 10 | AI:MMO 11 | 12 | 13 | {# TODO: favicon #} 14 | 15 | {# bootstrap #} 16 | {# Latest compiled and minified CSS #} 17 | 18 | 19 | {# Optional theme #} 20 | 21 | 22 | {% block styles %} 23 | {% endblock %} 24 | 25 | 26 | 27 | 54 |
55 | {% block content %} 56 | {% endblock %} 57 |
58 | 59 | 60 | 61 | {# Latest compiled and minified JavaScript #} 62 | 63 | 64 | 65 | 66 | {# HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries #} 67 | 71 | {% block scripts %} 72 | {% endblock %} 73 | 74 | 75 | -------------------------------------------------------------------------------- /aimmo-game/simulation/avatar/avatar_wrapper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | from simulation.action import ACTIONS, MoveAction, WaitAction 5 | 6 | 7 | LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class AvatarWrapper(object): 11 | """ 12 | The application's view of a character, not to be confused with "Avatar", 13 | the player-supplied code. 14 | """ 15 | 16 | def __init__(self, player_id, initial_location, worker_url, avatar_appearance): 17 | self.player_id = player_id 18 | self.location = initial_location 19 | self.health = 5 20 | self.score = 0 21 | self.events = [] 22 | self.avatar_appearance = avatar_appearance 23 | self.worker_url = worker_url 24 | self.effects = set() 25 | self.resistance = 0 26 | self.attack_strength = 1 27 | self.fog_of_war_modifier = 0 28 | self._action = None 29 | 30 | def update_effects(self): 31 | effects_to_remove = set() 32 | for effect in self.effects: 33 | effect.on_turn() 34 | if effect.is_expired: 35 | effects_to_remove.add(effect) 36 | for effect in effects_to_remove: 37 | effect.remove() 38 | 39 | @property 40 | def action(self): 41 | return self._action 42 | 43 | @property 44 | def is_moving(self): 45 | return isinstance(self.action, MoveAction) 46 | 47 | def _fetch_action(self, state_view): 48 | return requests.post(self.worker_url, json=state_view).json() 49 | 50 | def _construct_action(self, data): 51 | action_data = data['action'] 52 | action_type = action_data['action_type'] 53 | action_args = action_data.get('options', {}) 54 | action_args['avatar'] = self 55 | return ACTIONS[action_type](**action_args) 56 | 57 | def decide_action(self, state_view): 58 | try: 59 | data = self._fetch_action(state_view) 60 | action = self._construct_action(data) 61 | 62 | except (KeyError, ValueError) as err: 63 | LOGGER.info('Bad action data supplied: %s', err) 64 | except requests.exceptions.ConnectionError: 65 | LOGGER.info('Could not connect to worker, probably not ready yet') 66 | except Exception: 67 | LOGGER.exception("Unknown error while fetching turn data") 68 | 69 | else: 70 | self._action = action 71 | return True 72 | 73 | self._action = WaitAction(self) 74 | return False 75 | 76 | def clear_action(self): 77 | self._action = None 78 | 79 | def die(self, respawn_location): 80 | # TODO: extract settings for health and score loss on death 81 | self.health = 5 82 | self.score = max(0, self.score - 2) 83 | self.location = respawn_location 84 | 85 | def add_event(self, event): 86 | self.events.append(event) 87 | 88 | def damage(self, amount): 89 | applied_dmg = max(0, amount - self.resistance) 90 | self.health -= applied_dmg 91 | return applied_dmg 92 | 93 | def serialise(self): 94 | return { 95 | 'events': [ 96 | # { 97 | # 'event_name': event.__class__.__name__.lower(), 98 | # 'event_options': event.__dict__, 99 | # } for event in self.events 100 | ], 101 | 'health': self.health, 102 | 'location': self.location.serialise(), 103 | 'score': self.score, 104 | } 105 | 106 | def __repr__(self): 107 | return 'Avatar(id={}, location={}, health={}, score={})'.format(self.player_id, self.location, self.health, self.score) 108 | -------------------------------------------------------------------------------- /players/management/commands/generate_players.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import uuid 4 | 5 | from django.core.management import BaseCommand 6 | from django.test import RequestFactory 7 | from importlib import import_module 8 | from django.conf import settings 9 | from django.contrib.auth.models import User 10 | from players.views import code as code_view 11 | 12 | 13 | def _nth_dirname(path, n): 14 | for _ in xrange(n): 15 | path = os.path.dirname(path) 16 | return path 17 | 18 | _PLAYERS_DIRECTORY = _nth_dirname(__file__, 3) 19 | _AVATAR_CODES_DIRECTORY = os.path.join(_PLAYERS_DIRECTORY, 'avatar_examples') 20 | 21 | # Code file listing 22 | 23 | 24 | def _strip_prefix(prefix, string): 25 | if string.startswith(prefix): 26 | return string[len(prefix):] 27 | 28 | 29 | def _get_available_code_files(base_directory): 30 | for dirpath, dirnames, filenames in os.walk(base_directory): 31 | for f in filenames: 32 | if not f.startswith('_') and f.endswith('.py'): 33 | parent_dir = _strip_prefix(base_directory, dirpath) 34 | yield os.path.join(parent_dir, f) 35 | 36 | _AVATAR_CODES = list(_get_available_code_files(_AVATAR_CODES_DIRECTORY)) 37 | 38 | # Code file loading 39 | 40 | 41 | def _load_code_file(filename): 42 | if not filename.endswith('.py'): 43 | filename = filename + '.py' 44 | 45 | filepath = os.path.join(_AVATAR_CODES_DIRECTORY, filename) 46 | 47 | with open(filepath) as f: 48 | return f.read() 49 | 50 | 51 | class LoadCodeAction(argparse.Action): 52 | 53 | def __init__(self, option_strings, dest, **kwargs): 54 | super(LoadCodeAction, self).__init__(option_strings, dest, **kwargs) 55 | 56 | def __call__(self, parser, namespace, values, option_string): 57 | values = _load_code_file(values) 58 | setattr(namespace, self.dest, values) 59 | 60 | 61 | class Command(BaseCommand): 62 | # Show this when the user types help 63 | help = "Generate users for the game" 64 | 65 | def __init__(self, *args, **kwargs): 66 | super(Command, self).__init__(*args, **kwargs) 67 | self.engine = import_module(settings.SESSION_ENGINE) 68 | self.request_factory = RequestFactory() 69 | 70 | def add_arguments(self, parser): 71 | parser.add_argument('num-users', type=int, 72 | help='Number of users to create') 73 | parser.add_argument('avatar-code', choices=_AVATAR_CODES, 74 | action=LoadCodeAction, 75 | help='The code to use for the avatar.') 76 | 77 | # A command must define handle() 78 | def handle(self, *args, **options): 79 | num_users = options['num-users'] 80 | code = options['avatar-code'] 81 | 82 | for _ in xrange(num_users): 83 | random_string = str(uuid.uuid4())[:8] 84 | username = 'zombie-%s' % random_string 85 | password = '123' 86 | user = self.create_user(username, password) 87 | self.post_code(user, code) 88 | 89 | def create_user(self, username, password): 90 | user = User.objects.create_user(username, 'user@example.com', password) 91 | self.stdout.write('Created user %s with password: %s' % (username, password)) 92 | return user 93 | 94 | def post_code(self, user, player_code): 95 | request = self.request_factory.post('/any_path', data={'code': player_code}) 96 | session_key = None 97 | request.session = self.engine.SessionStore(session_key) 98 | request.user = user 99 | 100 | response = code_view(request) 101 | if response.status_code == 200: 102 | self.stdout.write('Posted code for player %s' % user) 103 | else: 104 | raise Exception('Failed to submit code for player %s' % user) 105 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_worker_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import unittest 4 | from json import dumps 5 | 6 | from httmock import HTTMock 7 | 8 | from simulation.avatar.avatar_manager import AvatarManager 9 | from simulation.game_state import GameState 10 | from simulation.worker_manager import WorkerManager 11 | from .maps import InfiniteMap 12 | 13 | 14 | class ConcreteWorkerManager(WorkerManager): 15 | def __init__(self, *args, **kwargs): 16 | self.final_workers = set() 17 | self.clear() 18 | super(ConcreteWorkerManager, self).__init__(*args, **kwargs) 19 | 20 | def clear(self): 21 | self.removed_workers = [] 22 | self.added_workers = [] 23 | 24 | def create_worker(self, player_id): 25 | self.added_workers.append(player_id) 26 | self.final_workers.add(player_id) 27 | 28 | def remove_worker(self, player_id): 29 | self.removed_workers.append(player_id) 30 | try: 31 | self.final_workers.remove(player_id) 32 | except KeyError: 33 | pass 34 | 35 | 36 | class RequestMock(object): 37 | def __init__(self, num_users): 38 | self.value = self._generate_response(num_users) 39 | self.urls_requested = [] 40 | 41 | def _generate_response(self, num_users): 42 | return { 43 | 'main': { 44 | 'parameters': [], 45 | 'main_avatar': None, 46 | 'users': [{ 47 | 'id': i, 48 | 'code': 'code for %s' % i, 49 | } for i in xrange(num_users)] 50 | } 51 | } 52 | 53 | def change_code(self, id, new_code): 54 | users = self.value['main']['users'] 55 | for i in xrange(len(users)): 56 | if users[i]['id'] == id: 57 | users[i]['code'] = new_code 58 | 59 | def __call__(self, url, request): 60 | self.urls_requested.append(url.geturl()) 61 | return dumps(self.value) 62 | 63 | 64 | class TestWorkerManager(unittest.TestCase): 65 | def setUp(self): 66 | self.game_state = GameState(InfiniteMap(), AvatarManager()) 67 | self.worker_manager = ConcreteWorkerManager(self.game_state, 'http://test') 68 | 69 | def test_correct_url(self): 70 | mocker = RequestMock(0) 71 | with HTTMock(mocker): 72 | self.worker_manager.update() 73 | self.assertEqual(len(mocker.urls_requested), 1) 74 | self.assertRegexpMatches(mocker.urls_requested[0], 'http://test/*') 75 | 76 | def test_workers_added(self): 77 | mocker = RequestMock(3) 78 | with HTTMock(mocker): 79 | self.worker_manager.update() 80 | self.assertEqual(len(self.worker_manager.final_workers), 3) 81 | for i in xrange(3): 82 | self.assertIn(i, self.game_state.avatar_manager.avatars_to_create_by_id) 83 | self.assertIn(i, self.worker_manager.final_workers) 84 | self.assertEqual(self.worker_manager.get_code(i), 'code for %s' % i) 85 | 86 | def test_changed_code(self): 87 | mocker = RequestMock(4) 88 | with HTTMock(mocker): 89 | self.worker_manager.update() 90 | mocker.change_code(0, 'changed 0') 91 | mocker.change_code(2, 'changed 2') 92 | self.worker_manager.update() 93 | 94 | for i in xrange(4): 95 | self.assertIn(i, self.worker_manager.final_workers) 96 | self.assertIn(i, self.game_state.avatar_manager.avatars_to_create_by_id) 97 | 98 | for i in (1, 3): 99 | self.assertEqual(self.worker_manager.get_code(i), 'code for %s' % i) 100 | for i in (0, 2): 101 | self.assertIn(i, self.worker_manager.added_workers) 102 | self.assertIn(i, self.worker_manager.removed_workers) 103 | self.assertEqual(self.worker_manager.get_code(i), 'changed %s' % i) 104 | 105 | def test_remove_avatars(self): 106 | mocker = RequestMock(3) 107 | with HTTMock(mocker): 108 | self.worker_manager.update() 109 | del mocker.value['main']['users'][1] 110 | self.worker_manager.update() 111 | self.assertNotIn(1, self.worker_manager.final_workers) 112 | self.assertNotIn(1, self.game_state.avatar_manager.avatars_to_delete_by_id) 113 | -------------------------------------------------------------------------------- /aimmo-game/simulation/world_state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from collections import defaultdict 3 | 4 | class MapFeature(Enum): 5 | HEALTH_POINT = 'health_point' 6 | SCORE_POINT = 'score_point' 7 | PICKUP = 'pickup' 8 | OBSTACLE = 'obstacle' 9 | 10 | class WorldState(): 11 | """ 12 | The world updates class serves as a buffer between the updates generated 13 | in different parts of the back-end and the data emitted by the socket. 14 | 15 | It contains all the parameters that need to be sent to the client every 16 | time the world is updated. These are: 17 | - Player (aka avatars) creation, deletion and update. 18 | - Changes in map features, i.e. creation and deletion of: 19 | * Health points. 20 | * Score points. 21 | * Pickups. 22 | * Obstacles. 23 | """ 24 | 25 | def __init__(self, game_state): 26 | self.game_state = game_state 27 | self.players = defaultdict(dict) 28 | self.map_features = defaultdict(dict) 29 | self.clear_updates() 30 | 31 | def get_updates(self): 32 | self.refresh() 33 | updates = { 34 | 'players' : dict(self.players), 35 | 'map_features' : dict(self.map_features) 36 | } 37 | self.clear_updates() 38 | 39 | return updates 40 | 41 | def clear_updates(self): 42 | self.players = { 43 | 'update': [], 44 | 'create': [], 45 | 'delete': [] 46 | } 47 | for map_feature in MapFeature: 48 | self.map_features[map_feature.value] = { 49 | 'create': [], 50 | 'delete': [] 51 | } 52 | 53 | # Player updates. 54 | 55 | def create_player(self, player_data): 56 | # Player data: {id, x, y, rotation, health, score, appearance?} 57 | self.players["create"].append(player_data) 58 | 59 | def delete_player(self, player_id): 60 | # Player id: {id} 61 | self.players["delete"].append(player_id) 62 | 63 | def update_player(self, player_update): 64 | # Player_update: {id, x, y, rotation, health, score} 65 | self.players["update"].append(player_update) 66 | 67 | # Map features updates. 68 | 69 | def create_map_feature(self, map_feature, map_feature_data): 70 | self.map_features[map_feature]["create"].append(map_feature_data) 71 | 72 | def delete_map_feature(self, map_feature, map_feature_id): 73 | self.map_features[map_feature]["delete"].append(map_feature_id) 74 | 75 | # Refresh the world state. Basically gather information from the avatar manager 76 | # and the world map and organise it. 77 | 78 | def refresh(self): 79 | def player_dict(avatar): 80 | return { 81 | 'id' : avatar.player_id, 82 | 'x' : avatar.location.x, 83 | 'y' : avatar.location.y, 84 | 'score' : avatar.score, 85 | 'health': avatar.health 86 | } 87 | 88 | def map_feature_dict(map_feature): 89 | return { 90 | 'id' : hash(map_feature), 91 | 'x' : map_feature.location.x, 92 | 'y' : map_feature.location.y 93 | } 94 | 95 | with self.game_state as game_state: 96 | world = game_state.world_map 97 | 98 | # Refresh players dictionary. 99 | 100 | for player in game_state.avatar_manager.avatars: 101 | self.update_player(player_dict(player)) 102 | 103 | for player in game_state.avatar_manager.avatars_to_create(): 104 | self.create_player(player_dict(player)) 105 | 106 | for player in game_state.avatar_manager.avatars_to_delete(): 107 | self.delete_player(player_dict(player)) 108 | 109 | # Refresh map features dictionary. 110 | 111 | for cell in world.cells_to_create(): 112 | 113 | # Cell is an obstacle. 114 | if not cell.habitable: 115 | self.create_map_feature(MapFeature.OBSTACLE.value, map_feature_dict(cell)) 116 | cell.created = True 117 | 118 | # Cell is a score point. 119 | if cell.generates_score: 120 | self.create_map_feature(MapFeature.SCORE_POINT.value, map_feature_dict(cell)) 121 | cell.created = True 122 | -------------------------------------------------------------------------------- /example_project/example_project/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Code for Life 3 | # 4 | # Copyright (C) 2015, Ocado Innovation Limited 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with this program. If not, see . 18 | # 19 | # ADDITIONAL TERMS – Section 7 GNU General Public Licence 20 | # 21 | # This licence does not grant any right, title or interest in any “Ocado” logos, 22 | # trade names or the trademark “Ocado” or any other trademarks or domain names 23 | # owned by Ocado Innovation Limited or the Ocado group of companies or any other 24 | # distinctive brand features of “Ocado” as may be secured from time to time. You 25 | # must not distribute any modification of this program using the trademark 26 | # “Ocado” or claim any affiliation or association with Ocado or its employees. 27 | # 28 | # You are not authorised to use the name Ocado (or any of its trade names) or 29 | # the names of any author or contributor in advertising or for publicity purposes 30 | # pertaining to the distribution of this program, without the prior written 31 | # authorisation of Ocado. 32 | # 33 | # Any propagation, distribution or conveyance of this program must include this 34 | # copyright notice and these terms. You must not misrepresent the origins of this 35 | # program; modified versions of the program must be marked as such and not 36 | # identified as the original program. 37 | """Django settings for example_project project.""" 38 | import subprocess 39 | 40 | import os 41 | 42 | ALLOWED_HOSTS = ['*'] 43 | 44 | DEBUG = True 45 | 46 | DATABASES = { 47 | 'default': { 48 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 49 | 'NAME': os.path.join(os.path.abspath(os.path.dirname(__file__)), 'db.sqlite3'), # Or path to database file if using sqlite3. 50 | } 51 | } 52 | 53 | USE_I18N = True 54 | USE_L10N = True 55 | 56 | TIME_ZONE = 'Europe/London' 57 | LANGUAGE_CODE = 'en-gb' 58 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static') 59 | STATIC_URL = '/static/' 60 | SECRET_KEY = 'not-a-secret' 61 | 62 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 63 | 64 | WSGI_APPLICATION = 'example_project.wsgi.application' 65 | 66 | INSTALLED_APPS = ( 67 | 'django.contrib.admin', 68 | 'django.contrib.auth', 69 | 'django.contrib.contenttypes', 70 | 'players', 71 | 'django_forms_bootstrap', 72 | ) 73 | 74 | LOGGING = { 75 | 'version': 1, 76 | 'disable_existing_loggers': False, 77 | 'filters': { 78 | 'require_debug_false': { 79 | '()': 'django.utils.log.RequireDebugFalse' 80 | } 81 | }, 82 | 'handlers': { 83 | 'console': { 84 | 'level': 'DEBUG', 85 | 'class': 'logging.StreamHandler' 86 | }, 87 | }, 88 | 'loggers': { 89 | 'views': { 90 | 'handlers': ['console'], 91 | 'level': 'DEBUG' 92 | }, 93 | } 94 | } 95 | 96 | LOGIN_URL = '/players/accounts/login/' 97 | 98 | LOGIN_REDIRECT_URL = '/players/' 99 | 100 | MIDDLEWARE_CLASSES = [ 101 | 'django.contrib.sessions.middleware.SessionMiddleware', 102 | 'django.middleware.locale.LocaleMiddleware', 103 | 'django.middleware.common.CommonMiddleware', 104 | 'django.middleware.csrf.CsrfViewMiddleware', 105 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 106 | 'django.contrib.messages.middleware.MessageMiddleware', 107 | ] 108 | 109 | def get_url(game): 110 | if os.environ.get('AIMMO_MODE', '') == 'minikube': 111 | return (os.environ['MINIKUBE_PROXY_URL'], "/game/%s/socket.io" % game) 112 | else: 113 | return ('http://localhost:%d' % (6001 + int(game) * 1000), '/socket.io') 114 | 115 | AIMMO_GAME_SERVER_LOCATION_FUNCTION = get_url 116 | 117 | try: 118 | from example_project.local_settings import * # pylint: disable=E0611 119 | except ImportError: 120 | pass 121 | 122 | from django_autoconfig import autoconfig 123 | autoconfig.configure_settings(globals()) 124 | -------------------------------------------------------------------------------- /aimmo-game/simulation/turn_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from threading import RLock 4 | from threading import Thread 5 | 6 | from simulation.action import PRIORITIES 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class GameStateProvider: 12 | """ 13 | Thread-safe container for the world state. 14 | 15 | TODO: think about changing to snapshot rather than lock? 16 | """ 17 | 18 | def __init__(self): 19 | self._game_state = None 20 | self._lock = RLock() 21 | 22 | def __enter__(self): 23 | self._lock.acquire() 24 | return self._game_state 25 | 26 | def __exit__(self, type, value, traceback): 27 | self._lock.release() 28 | 29 | def set_world(self, new_game_state): 30 | self._lock.acquire() 31 | self._game_state = new_game_state 32 | self._lock.release() 33 | 34 | 35 | state_provider = GameStateProvider() 36 | 37 | 38 | class TurnManager(Thread): 39 | """ 40 | Game loop 41 | """ 42 | daemon = True 43 | 44 | def __init__(self, game_state, end_turn_callback, completion_url): 45 | state_provider.set_world(game_state) 46 | self.end_turn_callback = end_turn_callback 47 | self._completion_url = completion_url 48 | super(TurnManager, self).__init__() 49 | 50 | def run_turn(self): 51 | raise NotImplementedError("Abstract method.") 52 | 53 | def _register_action(self, avatar): 54 | """ 55 | Send an avatar its view of the game state and register its chosen action. 56 | """ 57 | with state_provider as game_state: 58 | state_view = game_state.get_state_for(avatar) 59 | 60 | if avatar.decide_action(state_view): 61 | with state_provider as game_state: 62 | avatar.action.register(game_state.world_map) 63 | 64 | def _update_environment(self, game_state): 65 | num_avatars = len(game_state.avatar_manager.active_avatars) 66 | game_state.world_map.reconstruct_interactive_state(num_avatars) 67 | 68 | def _mark_complete(self): 69 | pass 70 | # TODO: Make completion request work. For now, we assume games don't finish. 71 | #from world_state import WorldState 72 | #requests.post(self._completion_url, json=world_state.get_update()) 73 | 74 | def run(self): 75 | while True: 76 | try: 77 | self.run_turn() 78 | 79 | with state_provider as game_state: 80 | game_state.update_environment() 81 | 82 | self.end_turn_callback() 83 | except Exception: 84 | LOGGER.exception('Error while running turn') 85 | 86 | if game_state.is_complete(): 87 | LOGGER.info('Game complete') 88 | self._mark_complete() 89 | time.sleep(0.5) 90 | 91 | 92 | class SequentialTurnManager(TurnManager): 93 | def run_turn(self): 94 | """ 95 | Get and apply each avatar's action in turn. 96 | """ 97 | with state_provider as game_state: 98 | avatars = game_state.avatar_manager.active_avatars 99 | 100 | for avatar in avatars: 101 | self._register_action(avatar) 102 | with state_provider as game_state: 103 | location_to_clear = avatar.action.target_location 104 | avatar.action.process(game_state.world_map) 105 | game_state.world_map.clear_cell_actions(location_to_clear) 106 | 107 | 108 | class ConcurrentTurnManager(TurnManager): 109 | def run_turn(self): 110 | """ 111 | Concurrently get the intended actions from all avatars and register 112 | them on the world map. Then apply actions in order of priority. 113 | """ 114 | with state_provider as game_state: 115 | avatars = game_state.avatar_manager.active_avatars 116 | 117 | threads = [Thread(target=self._register_action, 118 | args=(avatar,)) for avatar in avatars] 119 | 120 | [thread.start() for thread in threads] 121 | [thread.join() for thread in threads] 122 | 123 | # Waits applied first, then attacks, then moves. 124 | avatars.sort(key=lambda a: PRIORITIES[type(a.action)]) 125 | 126 | locations_to_clear = {a.action.target_location for a in avatars 127 | if a.action is not None} 128 | 129 | for action in (a.action for a in avatars if a.action is not None): 130 | with state_provider as game_state: 131 | action.process(game_state.world_map) 132 | 133 | for location in locations_to_clear: 134 | with state_provider as game_state: 135 | game_state.world_map.clear_cell_actions(location) 136 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_game_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from simulation.game_state import GameState 6 | from simulation.location import Location 7 | from .dummy_avatar import DummyAvatar 8 | from .dummy_avatar import DummyAvatarManager 9 | from .maps import InfiniteMap, AvatarMap, EmptyMap 10 | 11 | 12 | class FogToEmpty(object): 13 | def apply_fog_of_war(self, world_map, wrapper): 14 | return EmptyMap() 15 | 16 | 17 | class TestGameState(TestCase): 18 | def test_remove_non_existant_avatar(self): 19 | state = GameState(None, DummyAvatarManager()) 20 | state.remove_avatar(10) 21 | 22 | def test_remove_avatar(self): 23 | world_map = InfiniteMap() 24 | manager = DummyAvatarManager() 25 | game_state = GameState(world_map, manager) 26 | 27 | avatar1 = DummyAvatar(1, Location(0, 0)) 28 | avatar2 = DummyAvatar(2, Location(1, 1)) 29 | avatar2.marked = True 30 | 31 | manager.add_avatar_directly(avatar1) 32 | world_map.get_cell(Location(0, 0)).avatar = avatar1 33 | manager.add_avatar_directly(avatar2) 34 | world_map.get_cell(Location(1, 1)).avatar = avatar2 35 | 36 | game_state.remove_avatar(1) 37 | 38 | self.assertNotIn(1, manager.avatars_by_id) 39 | self.assertEqual(world_map.get_cell(Location(0, 0)).avatar, None) 40 | 41 | self.assertTrue(manager.avatars_by_id[2].marked) 42 | self.assertTrue(world_map.get_cell(Location(1, 1)).avatar.marked) 43 | 44 | def game_state_with_two_avatars(self, world_map=None, avatar_manager=None): 45 | if world_map is None: 46 | world_map = EmptyMap() 47 | if avatar_manager is None: 48 | avatar_manager = DummyAvatarManager() 49 | 50 | avatar = DummyAvatar(1, (0, 0)) 51 | other_avatar = DummyAvatar(2, (0, 0)) 52 | other_avatar.marked = True 53 | avatar_manager.avatars_by_id[1] = avatar 54 | avatar_manager.avatars_by_id[2] = other_avatar 55 | game_state = GameState(world_map, avatar_manager) 56 | return (game_state, avatar, world_map, avatar_manager) 57 | 58 | self.assertTrue(avatar_manager.avatars_by_id[2].marked) 59 | self.assertNotIn(1, avatar_manager.avatars_by_id) 60 | self.assertEqual(world_map.get_cell((0, 0)).avatar, None) 61 | 62 | def test_add_avatar(self): 63 | state = GameState(AvatarMap(None), DummyAvatarManager()) 64 | state.add_avatar(7, "") 65 | self.assertIn(7, state.avatar_manager.avatars_by_id) 66 | avatar = state.avatar_manager.avatars_by_id[7] 67 | self.assertEqual(avatar.location.x, 10) 68 | self.assertEqual(avatar.location.y, 10) 69 | 70 | def test_fog_of_war(self): 71 | state = GameState(InfiniteMap(), DummyAvatarManager()) 72 | view = state.get_state_for(DummyAvatar(None, None), FogToEmpty()) 73 | self.assertEqual(len(view['world_map']['cells']), 0) 74 | self.assertEqual(view['avatar_state'], 'Dummy') 75 | 76 | def test_updates_map(self): 77 | map = InfiniteMap() 78 | state = GameState(map, DummyAvatarManager()) 79 | state.update_environment() 80 | self.assertEqual(map.updates, 1) 81 | 82 | def test_updates_map_with_correct_num_avatars(self): 83 | map = InfiniteMap() 84 | manager = DummyAvatarManager() 85 | manager.add_avatar(1, '', None) 86 | state = GameState(map, manager) 87 | state.update_environment() 88 | self.assertEqual(map.num_avatars, 1) 89 | manager.add_avatar(2, '', None) 90 | manager.add_avatar(3, '', None) 91 | state.update_environment() 92 | self.assertEqual(map.num_avatars, 3) 93 | 94 | def test_no_main_avatar_by_default(self): 95 | state = GameState(EmptyMap(), DummyAvatarManager()) 96 | with self.assertRaises(KeyError): 97 | state.get_main_avatar() 98 | 99 | def test_get_main_avatar(self): 100 | (game_state, avatar, _, _) = self.game_state_with_two_avatars() 101 | game_state.main_avatar_id = avatar.player_id 102 | self.assertEqual(game_state.get_main_avatar(), avatar) 103 | 104 | def test_is_complete_calls_lambda(self): 105 | class LambdaTest(object): 106 | def __init__(self, return_value): 107 | self.return_value = return_value 108 | 109 | def __call__(self, game_state): 110 | self.game_state = game_state 111 | return self.return_value 112 | 113 | test = LambdaTest(True) 114 | game_state = GameState(EmptyMap(), DummyAvatarManager(), test) 115 | self.assertTrue(game_state.is_complete()) 116 | test.return_value = False 117 | self.assertFalse(game_state.is_complete()) 118 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_action.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import unittest 4 | 5 | from simulation import action 6 | from simulation import event 7 | from simulation.avatar.avatar_manager import AvatarManager 8 | from simulation.direction import EAST 9 | from simulation.game_state import GameState 10 | from simulation.location import Location 11 | from .dummy_avatar import MoveDummy 12 | from .maps import InfiniteMap, EmptyMap, AvatarMap 13 | 14 | ORIGIN = Location(x=0, y=0) 15 | EAST_OF_ORIGIN = Location(x=1, y=0) 16 | NORTH_OF_ORIGIN = Location(x=0, y=1) 17 | 18 | 19 | class TestAction(unittest.TestCase): 20 | def setUp(self): 21 | self.avatar = MoveDummy(1, ORIGIN, EAST) 22 | self.other_avatar = MoveDummy(2, EAST_OF_ORIGIN, EAST) 23 | self.avatar_manager = AvatarManager() 24 | 25 | def test_successful_move_north_action(self): 26 | game_state = GameState(InfiniteMap(), self.avatar_manager) 27 | action.MoveAction(self.avatar, {'x': 0, 'y': 1}).process(game_state.world_map) 28 | 29 | target_cell = game_state.world_map.get_cell(NORTH_OF_ORIGIN) 30 | self.assertEqual(self.avatar.location, NORTH_OF_ORIGIN) 31 | self.assertEqual(self.avatar, target_cell.avatar) 32 | 33 | self.assertEqual(self.avatar.events, [event.MovedEvent(ORIGIN, NORTH_OF_ORIGIN)]) 34 | 35 | def test_successful_move_east_action(self): 36 | game_state = GameState(InfiniteMap(), self.avatar_manager) 37 | action.MoveAction(self.avatar, {'x': 1, 'y': 0}).process(game_state.world_map) 38 | 39 | self.assertEqual(self.avatar.location, EAST_OF_ORIGIN) 40 | self.assertEqual(self.avatar.events, [event.MovedEvent(ORIGIN, EAST_OF_ORIGIN)]) 41 | 42 | def test_failed_move_action(self): 43 | game_state = GameState(EmptyMap(), self.avatar_manager) 44 | action.MoveAction(self.avatar, {'x': 0, 'y': 1}).process(game_state.world_map) 45 | 46 | self.assertEqual(self.avatar.location, ORIGIN) 47 | self.assertEqual(self.avatar.events, [event.FailedMoveEvent(ORIGIN, NORTH_OF_ORIGIN)]) 48 | 49 | def test_successful_attack_action(self): 50 | game_state = GameState(AvatarMap(self.other_avatar), self.avatar_manager) 51 | action.AttackAction(self.avatar, {'x': 0, 'y': 1}).process(game_state.world_map) 52 | 53 | target_location = NORTH_OF_ORIGIN 54 | damage_dealt = 1 55 | 56 | self.assertEqual(self.avatar.location, ORIGIN) 57 | self.assertEqual(self.other_avatar.location, EAST_OF_ORIGIN) 58 | self.assertEqual(self.other_avatar.times_died, 0) 59 | self.assertEqual(self.other_avatar.health, 4) 60 | 61 | self.assertEqual(self.avatar.events, 62 | [event.PerformedAttackEvent( 63 | self.other_avatar, 64 | target_location, 65 | damage_dealt)]) 66 | self.assertEqual(self.other_avatar.events, 67 | [event.ReceivedAttackEvent(self.avatar, damage_dealt)]) 68 | 69 | def test_failed_attack_action(self): 70 | game_state = GameState(InfiniteMap(), self.avatar_manager) 71 | action.AttackAction(self.avatar, {'x': 0, 'y': 1}).process(game_state.world_map) 72 | 73 | target_location = NORTH_OF_ORIGIN 74 | 75 | self.assertEqual(self.avatar.location, ORIGIN) 76 | self.assertEqual(self.other_avatar.location, EAST_OF_ORIGIN) 77 | self.assertEqual(self.avatar.events, [event.FailedAttackEvent(target_location)]) 78 | self.assertEqual(self.other_avatar.events, []) 79 | 80 | def test_avatar_dies(self): 81 | self.other_avatar.health = 1 82 | game_state = GameState(AvatarMap(self.other_avatar), self.avatar_manager) 83 | action.AttackAction(self.avatar, {'x': 0, 'y': 1}).process(game_state.world_map) 84 | 85 | target_location = NORTH_OF_ORIGIN 86 | damage_dealt = 1 87 | self.assertEqual(self.avatar.events, 88 | [event.PerformedAttackEvent( 89 | self.other_avatar, 90 | target_location, 91 | damage_dealt)]) 92 | self.assertEqual(self.other_avatar.events, 93 | [event.ReceivedAttackEvent(self.avatar, damage_dealt)]) 94 | 95 | self.assertEqual(self.avatar.location, ORIGIN) 96 | self.assertEqual(self.other_avatar.health, 0) 97 | self.assertEqual(self.other_avatar.times_died, 1) 98 | self.assertEqual(self.other_avatar.location, Location(10, 10)) 99 | 100 | def test_no_move_in_wait(self): 101 | game_state = GameState(InfiniteMap(), self.avatar_manager) 102 | action.WaitAction(self.avatar).process(game_state.world_map) 103 | self.assertEqual(self.avatar.location, ORIGIN) 104 | -------------------------------------------------------------------------------- /aimmo-game-worker/tests/simulation/test_world_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from simulation.location import Location 6 | from simulation.world_map import WorldMap 7 | 8 | 9 | class TestWorldMap(TestCase): 10 | AVATAR = {'location': {'x': 0, 'y': 0}, 'health': True, 'score': 3, 'events': []} 11 | 12 | def _generate_cells(self, columns=3, rows=3): 13 | cells = [{ 14 | 'location': {'x': x, 'y': y}, 15 | 'habitable': True, 16 | 'generates_score': False, 17 | 'avatar': None, 18 | 'pickup': None, 19 | } for x in xrange(-columns / 2 + 1, 1 + columns / 2) for y in xrange(-rows / 2 + 1, 1 + rows / 2)] 20 | return cells 21 | 22 | def assertGridSize(self, map, expected_rows, expected_columns=None): 23 | if expected_columns is None: 24 | expected_columns = expected_rows 25 | self.assertEqual(len(list(map.all_cells())), expected_rows*expected_columns) 26 | 27 | def assertLocationsEqual(self, actual_cells, expected_locations): 28 | actual_cells = list(actual_cells) 29 | actual = frozenset(cell.location for cell in actual_cells) 30 | self.assertEqual(actual, frozenset(expected_locations)) 31 | self.assertEqual(len(actual_cells), len(list(expected_locations))) 32 | 33 | def test_grid_size(self): 34 | map = WorldMap(self._generate_cells(1, 3)) 35 | self.assertGridSize(map, 1, 3) 36 | 37 | def test_all_cells(self): 38 | map = WorldMap(self._generate_cells()) 39 | self.assertLocationsEqual(map.all_cells(), 40 | [Location(x, y) for x in xrange(-1, 2) for y in xrange(-1, 2)]) 41 | 42 | def test_score_cells(self): 43 | cells = self._generate_cells() 44 | cells[0]['generates_score'] = True 45 | cells[5]['generates_score'] = True 46 | map = WorldMap(cells) 47 | self.assertLocationsEqual(map.score_cells(), (Location(-1, -1), Location(0, 1))) 48 | 49 | def test_pickup_cells(self): 50 | cells = self._generate_cells() 51 | cells[0]['pickup'] = {'health_restored': 5} 52 | cells[8]['pickup'] = {'health_restored': 2} 53 | map = WorldMap(cells) 54 | self.assertLocationsEqual(map.pickup_cells(), (Location(-1, -1), Location(1, 1))) 55 | 56 | def test_location_is_visible(self): 57 | map = WorldMap(self._generate_cells()) 58 | for x in (0, 1): 59 | for y in (0, 1): 60 | self.assertTrue(map.is_visible(Location(x, y))) 61 | 62 | def test_x_off_map_is_not_visible(self): 63 | map = WorldMap(self._generate_cells()) 64 | for y in (0, 1): 65 | self.assertFalse(map.is_visible(Location(-2, y))) 66 | self.assertFalse(map.is_visible(Location(2, y))) 67 | 68 | def test_y_off_map_is_not_visible(self): 69 | map = WorldMap(self._generate_cells()) 70 | for x in (0, 1): 71 | self.assertFalse(map.is_visible(Location(x, -2))) 72 | self.assertFalse(map.is_visible(Location(x, 2))) 73 | 74 | def test_get_valid_cell(self): 75 | map = WorldMap(self._generate_cells()) 76 | for x in (0, 1): 77 | for y in (0, 1): 78 | location = Location(x, y) 79 | self.assertEqual(map.get_cell(location).location, location) 80 | 81 | def test_get_x_off_map(self): 82 | map = WorldMap(self._generate_cells()) 83 | for y in (0, 1): 84 | with self.assertRaises(KeyError): 85 | map.get_cell(Location(-2, y)) 86 | with self.assertRaises(KeyError): 87 | map.get_cell(Location(2, y)) 88 | 89 | def test_get_y_off_map(self): 90 | map = WorldMap(self._generate_cells()) 91 | for x in (0, 1): 92 | with self.assertRaises(KeyError): 93 | map.get_cell(Location(x, -2)) 94 | with self.assertRaises(KeyError): 95 | map.get_cell(Location(x, 2)) 96 | 97 | def test_can_move_to(self): 98 | map = WorldMap(self._generate_cells()) 99 | target = Location(1, 1) 100 | self.assertTrue(map.can_move_to(target)) 101 | 102 | def test_cannot_move_to_cell_off_grid(self): 103 | map = WorldMap(self._generate_cells()) 104 | target = Location(4, 1) 105 | self.assertFalse(map.can_move_to(target)) 106 | 107 | def test_cannot_move_to_uninhabitable_cell(self): 108 | cells = self._generate_cells() 109 | cells[0]['habitable'] = False 110 | map = WorldMap(cells) 111 | self.assertFalse(map.can_move_to(Location(-1, -1))) 112 | 113 | def test_cannot_move_to_habited_cell(self): 114 | cells = self._generate_cells() 115 | cells[1]['avatar'] = self.AVATAR 116 | map = WorldMap(cells) 117 | self.assertFalse(map.can_move_to(Location(-1, 0))) 118 | -------------------------------------------------------------------------------- /players/static/js/watch/world-controls.js: -------------------------------------------------------------------------------- 1 | // World Manipulation 2 | const CONTROLS = Object.create({ 3 | init: function (world, viewer) { 4 | this.world = world; 5 | this.viewer = viewer; 6 | }, 7 | initialiseWorld: function (width, height, worldLayout, minX, minY, maxX, maxY) { 8 | this.world.width = width; 9 | this.world.height = height; 10 | this.world.layout = worldLayout; 11 | this.world.minX = minX; 12 | this.world.minY = minY; 13 | this.world.maxX = maxX; 14 | this.world.maxY = maxY; 15 | 16 | this.viewer.reDrawWorldLayout(); 17 | }, 18 | processUpdate: function (players, mapFeatures) { 19 | var i, j; 20 | 21 | // Create players. 22 | for (i = 0; i < players["create"].length; i++) { 23 | this.world.players.push(players["create"][i]); 24 | } 25 | 26 | // Delete players. 27 | for (i = 0; i < players["delete"].length; i++) { 28 | for (j = 0; j < this.world.players.length; j++) { 29 | if (this.world.players[j]["id"] === players["delete"][i]["id"]) { 30 | this.world.players.splice(j, 1); 31 | } 32 | } 33 | } 34 | 35 | // Update players. 36 | for (i = 0; i < players["update"].length; i++) { 37 | for (j = 0; j < this.world.players.length; j++) { 38 | if (this.world.players[j]["id"] === players["update"][i]["id"]) { 39 | this.world.players[j] = players["update"][i]; 40 | } 41 | } 42 | } 43 | 44 | // Map features. 45 | var obstacles = mapFeatures["obstacle"]; 46 | var scorePoints = mapFeatures["score_point"]; 47 | var healthPoints = mapFeatures["health_point"]; 48 | var pickups = mapFeatures["pickup"]; 49 | 50 | // Create obstacles. 51 | for (i = 0; i < obstacles["create"].length; i++) { 52 | this.world.layout[obstacles["create"][i]["x"]][obstacles["create"][i]["y"]] = 1; 53 | } 54 | 55 | // Delete obstacles. 56 | for (i = 0; i < obstacles["delete"].length; i++) { 57 | this.world.layout[obstacles["delete"][i]["x"]][obstacles["delete"][i]["y"]] = 0; 58 | } 59 | 60 | // Create score points. 61 | for (i = 0; i < scorePoints["create"].length; i++) { 62 | this.world.layout[scorePoints["create"][i]["x"]][scorePoints["create"][i]["y"]] = 2; 63 | } 64 | 65 | // Delete score points. 66 | for (i = 0; i < scorePoints["delete"].length; i++) { 67 | this.world.layout[scorePoints["delete"][i]["x"]][scorePoints["delete"][i]["y"]] = 0; 68 | } 69 | 70 | // Create health points. 71 | for (i = 0; i < healthPoints["create"].length; i++) { 72 | this.world.layout[healthPoints["create"][i]["x"]][healthPoints["create"][i]["y"]] = 3; 73 | } 74 | 75 | // Delete health points. 76 | for (i = 0; i < healthPoints["delete"].length; i++) { 77 | this.world.layout[healthPoints["delete"][i]["x"]][healthPoints["delete"][i]["y"]] = 0; 78 | } 79 | 80 | // Create pickups. 81 | for (i = 0; i < pickups["create"].length; i++) { 82 | this.world.pickups.push(pickups["create"][i]); 83 | } 84 | 85 | // Delete pickups. 86 | for (i = 0; i < pickups["delete"].length; i++) { 87 | for (j = 0; j < this.world.pickups.length; j++) { 88 | if (this.world.pickups[j]["id"] === players["delete"][i]["id"]) { 89 | this.world.pickups.splice(j, 1); 90 | } 91 | } 92 | } 93 | 94 | this.viewer.reDrawState(); 95 | } 96 | }); 97 | 98 | // Updates. 99 | function worldUpdate(data) { 100 | CONTROLS.processUpdate(data["players"], data["map_features"]); 101 | } 102 | 103 | // Initialisation. 104 | function worldInit() { 105 | var width = 15; 106 | var height = 15; 107 | var minX = -7; 108 | var minY = -7; 109 | var maxX = 7; 110 | var maxY = 7; 111 | 112 | var layout = {}; 113 | for (var x = minX; x <= maxX; x++) { 114 | layout[x] = {}; 115 | for (var y = minY; y <= maxY; y++) { 116 | layout[x][y] = 0; 117 | } 118 | } 119 | 120 | CONTROLS.initialiseWorld(width, height, layout, minX, minY, maxX, maxY); 121 | } 122 | 123 | $(document).ready(function() { 124 | 125 | var world = {}; 126 | world.players = []; 127 | world.pickups = []; 128 | VIEWER.init(document.getElementById("watch-world-canvas"), world, APPEARANCE); 129 | CONTROLS.init(world, VIEWER); 130 | 131 | if (ACTIVE) { 132 | var socket = io.connect(GAME_URL_BASE, { path: GAME_URL_PATH }); 133 | socket.on('world-init', function() { 134 | worldInit(); 135 | socket.emit('client-ready', 1); 136 | }); 137 | 138 | socket.on('world-update', function(msg) { 139 | worldUpdate(msg); 140 | }); 141 | } 142 | }); 143 | -------------------------------------------------------------------------------- /aimmo-game/simulation/action.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from simulation.direction import Direction 3 | from simulation.event import FailedAttackEvent, FailedMoveEvent, MovedEvent, PerformedAttackEvent, ReceivedAttackEvent 4 | 5 | LOGGER = getLogger(__name__) 6 | 7 | 8 | class Action(object): 9 | def __init__(self, avatar): 10 | self._avatar = avatar 11 | try: 12 | self._target_location = self._avatar.location + self.direction 13 | except AttributeError: 14 | self._target_location = self._avatar.location 15 | 16 | @property 17 | def avatar(self): 18 | return self._avatar 19 | 20 | @property 21 | def target_location(self): 22 | return self._target_location 23 | 24 | def register(self, world_map): 25 | if world_map.is_on_map(self.target_location): 26 | world_map.get_cell(self.target_location).actions.append(self) 27 | 28 | def process(self, world_map): 29 | if self.is_legal(world_map): 30 | self.apply(world_map) 31 | else: 32 | self.reject() 33 | 34 | def is_legal(self, world_map): 35 | raise NotImplementedError('Abstract method') 36 | 37 | def apply(self, world_map): 38 | raise NotImplementedError('Abstract method') 39 | 40 | def reject(self): 41 | raise NotImplementedError('Abstract method') 42 | 43 | 44 | class WaitAction(Action): 45 | def __init__(self, avatar): 46 | super(WaitAction, self).__init__(avatar) 47 | 48 | def is_legal(self, world_map): 49 | return True 50 | 51 | def apply(self, world_map): 52 | self.avatar.clear_action() 53 | 54 | 55 | class MoveAction(Action): 56 | def __init__(self, avatar, direction): 57 | # Untrusted data! 58 | self.direction = Direction(**direction) 59 | super(MoveAction, self).__init__(avatar) 60 | 61 | def is_legal(self, world_map): 62 | return world_map.can_move_to(self.target_location) 63 | 64 | def process(self, world_map): 65 | self.chain(world_map, {self.avatar.location}) 66 | 67 | def apply(self, world_map): 68 | event = MovedEvent(self.avatar.location, self.target_location) 69 | self.avatar.add_event(event) 70 | 71 | world_map.get_cell(self.avatar.location).avatar = None 72 | self.avatar.location = self.target_location 73 | world_map.get_cell(self.target_location).avatar = self.avatar 74 | self.avatar.clear_action() 75 | return True 76 | 77 | def chain(self, world_map, visited): 78 | if not self.is_legal(world_map): 79 | return self.reject() 80 | 81 | # Detect cycles 82 | if self.target_location in visited: 83 | return self.reject() 84 | 85 | next_cell = world_map.get_cell(self.target_location) 86 | if not next_cell.is_occupied: 87 | return self.apply(world_map) 88 | 89 | next_action = next_cell.avatar.action 90 | if next_action.chain(world_map, visited | {self.target_location}): 91 | return self.apply(world_map) 92 | 93 | return self.reject() 94 | 95 | def reject(self): 96 | event = FailedMoveEvent(self.avatar.location, self.target_location) 97 | self.avatar.add_event(event) 98 | self.avatar.clear_action() 99 | return False 100 | 101 | 102 | class AttackAction(Action): 103 | def __init__(self, avatar, direction): 104 | # Untrusted data! 105 | self.direction = Direction(**direction) 106 | super(AttackAction, self).__init__(avatar) 107 | 108 | def is_legal(self, world_map): 109 | return True if world_map.attackable_avatar(self.target_location) else False 110 | 111 | def apply(self, world_map): 112 | attacked_avatar = world_map.attackable_avatar(self.target_location) 113 | damage_dealt = 1 114 | self.avatar.add_event(PerformedAttackEvent(attacked_avatar, 115 | self.target_location, 116 | damage_dealt)) 117 | attacked_avatar.add_event(ReceivedAttackEvent(self.avatar, 118 | damage_dealt)) 119 | attacked_avatar.damage(damage_dealt) 120 | 121 | LOGGER.debug('{} dealt {} damage to {}'.format(self.avatar, 122 | damage_dealt, 123 | attacked_avatar)) 124 | self.avatar.clear_action() 125 | 126 | if attacked_avatar.health <= 0: 127 | # Move responsibility for this to avatar.die() ? 128 | respawn_location = world_map.get_random_spawn_location() 129 | attacked_avatar.die(respawn_location) 130 | world_map.get_cell(self.target_location).avatar = None 131 | world_map.get_cell(respawn_location).avatar = attacked_avatar 132 | 133 | def reject(self): 134 | self.avatar.add_event(FailedAttackEvent(self.target_location)) 135 | self.avatar.clear_action() 136 | 137 | ACTIONS = { 138 | 'attack': AttackAction, 139 | 'move': MoveAction, 140 | 'wait': WaitAction, 141 | } 142 | 143 | PRIORITIES = { 144 | WaitAction: 0, 145 | AttackAction: 1, 146 | MoveAction: 2, 147 | } 148 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/avatar/test_avatar_wrapper.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | from unittest import TestCase 5 | 6 | from httmock import HTTMock 7 | 8 | from simulation.avatar import avatar_wrapper 9 | 10 | 11 | class MockEffect(object): 12 | def __init__(self, avatar): 13 | self.turns = 0 14 | self.is_expired = False 15 | self.removed = False 16 | self.expire = False 17 | self.avatar = avatar 18 | 19 | def on_turn(self): 20 | self.is_expired = self.expire 21 | self.turns += 1 22 | 23 | def remove(self): 24 | self.removed = True 25 | self.avatar.effects.remove(self) 26 | 27 | 28 | class MockAction(object): 29 | def __init__(self, avatar, **options): 30 | global actions_created 31 | self.options = options 32 | self.avatar = avatar 33 | actions_created.append(self) 34 | 35 | 36 | class ActionRequest(object): 37 | def __init__(self, options=None): 38 | if options is None: 39 | options = {} 40 | self.options = options 41 | 42 | def __call__(self, url, request): 43 | return json.dumps({'action': {'action_type': 'test', 'options': self.options}}) 44 | 45 | 46 | def InvalidJSONRequest(url, request): 47 | return 'EXCEPTION' 48 | 49 | 50 | def NonExistentActionRequest(url, request): 51 | return json.dumps({'action': {'action_type': 'fake', 'option': {}}}) 52 | 53 | 54 | avatar_wrapper.ACTIONS = { 55 | 'test': MockAction 56 | } 57 | 58 | 59 | class TestAvatarWrapper(TestCase): 60 | def setUp(self): 61 | global actions_created 62 | actions_created = [] 63 | self.avatar = avatar_wrapper.AvatarWrapper(None, None, 'http://test', None) 64 | 65 | def take_turn(self, request_mock=None): 66 | if request_mock is None: 67 | request_mock = ActionRequest() 68 | with HTTMock(request_mock): 69 | self.avatar.decide_action(None) 70 | 71 | def test_action_has_created_correctly(self): 72 | self.take_turn() 73 | self.assertGreater(len(actions_created), 0, 'No action applied') 74 | self.assertEqual(len(actions_created), 1, 'Too many actions applied') 75 | self.assertEqual(actions_created[0].avatar, self.avatar, 'Action applied on wrong avatar') 76 | 77 | def test_bad_action_data_given(self): 78 | request_mock = InvalidJSONRequest 79 | self.take_turn(request_mock) 80 | self.assertEqual(actions_created, [], 'No action should have been applied') 81 | 82 | def test_non_existant_action(self): 83 | request_mock = NonExistentActionRequest 84 | self.take_turn(request_mock) 85 | self.assertEqual(actions_created, [], 'No action should have been applied') 86 | 87 | def add_effects(self, num=2): 88 | effects = [] 89 | for _ in xrange(num): 90 | effect = MockEffect(self.avatar) 91 | self.avatar.effects.add(effect) 92 | effects.append(effect) 93 | return effects 94 | 95 | def test_effects_on_turn_are_called(self): 96 | effect1, effect2 = self.add_effects() 97 | self.avatar.update_effects() 98 | self.assertEqual(effect1.turns, 1) 99 | self.assertEqual(effect2.turns, 1) 100 | 101 | def test_effects_not_removed(self): 102 | effect1, effect2 = self.add_effects() 103 | self.avatar.update_effects() 104 | self.assertEqual(set((effect1, effect2)), self.avatar.effects) 105 | 106 | def test_expired_effects_removed(self): 107 | effect1, effect2 = self.add_effects() 108 | effect1.expire = True 109 | self.avatar.update_effects() 110 | self.assertEqual(effect2.turns, 1) 111 | self.assertEqual(self.avatar.effects, set((effect2,))) 112 | 113 | def test_effects_applied_on_invalid_action(self): 114 | self.take_turn(InvalidJSONRequest) 115 | effect = self.add_effects(1)[0] 116 | self.avatar.update_effects() 117 | self.assertEqual(effect.turns, 1) 118 | 119 | def test_avatar_dies_health(self): 120 | self.avatar.die(None) 121 | self.assertEqual(self.avatar.health, 5) 122 | 123 | def test_avatar_dies_score_when_large(self): 124 | self.avatar.score = 10 125 | self.avatar.die(None) 126 | self.assertEqual(self.avatar.score, 8) 127 | 128 | def test_avatar_dies_score_when_small(self): 129 | self.avatar.score = 1 130 | self.avatar.die(None) 131 | self.assertEqual(self.avatar.score, 0) 132 | 133 | def test_avatar_dies_location(self): 134 | self.avatar.die('test') 135 | self.assertEqual(self.avatar.location, 'test') 136 | 137 | def test_damage_applied(self): 138 | self.avatar.health = 10 139 | self.assertEqual(self.avatar.damage(1), 1) 140 | self.assertEqual(self.avatar.health, 9) 141 | 142 | def test_resistance_reduces_damage(self): 143 | self.avatar.health = 10 144 | self.avatar.resistance = 3 145 | self.assertEqual(self.avatar.damage(5), 2) 146 | self.assertEqual(self.avatar.health, 8) 147 | 148 | def test_no_negative_damage(self): 149 | self.avatar.health = 10 150 | self.avatar.resistance = 3 151 | self.assertEqual(self.avatar.damage(1), 0) 152 | self.assertEqual(self.avatar.health, 10) 153 | -------------------------------------------------------------------------------- /minikube.py: -------------------------------------------------------------------------------- 1 | #!/user/bin/env python 2 | from __future__ import print_function 3 | 4 | import docker 5 | import errno 6 | import kubernetes 7 | import platform 8 | import os 9 | import re 10 | import socket 11 | import stat 12 | import tarfile 13 | import yaml 14 | from run import run_command 15 | from urllib import urlretrieve 16 | from urllib2 import urlopen 17 | from zipfile import ZipFile 18 | 19 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 20 | TEST_BIN = os.path.join(BASE_DIR, 'test-bin') 21 | MANIFESTS = os.path.join(BASE_DIR, 'manifests/') 22 | RENDER_MANIFESTS = os.path.join(BASE_DIR, 'render-manifests.py') 23 | OS = platform.system().lower() 24 | FILE_SUFFIX = '.exe' if OS == 'windows' else '' 25 | KUBECTL = os.path.join(TEST_BIN, 'kubectl%s' % FILE_SUFFIX) 26 | MINIKUBE = os.path.join(TEST_BIN, 'minikube%s' % FILE_SUFFIX) 27 | 28 | 29 | def create_test_bin(): 30 | try: 31 | os.makedirs(TEST_BIN) 32 | except OSError as err: 33 | if err.errno != errno.EEXIST: 34 | raise 35 | 36 | 37 | def get_latest_github_version(repo): 38 | result = urlopen('https://github.com/%s/releases/latest' % repo) 39 | return result.geturl().split('/')[-1] 40 | 41 | 42 | def download_exec(url, dest): 43 | dest = urlretrieve(url, dest)[0] 44 | make_exec(dest) 45 | 46 | 47 | def make_exec(file): 48 | current_stat = os.stat(file) 49 | os.chmod(file, current_stat.st_mode | stat.S_IEXEC) 50 | 51 | 52 | def binary_exists(filename): 53 | # Check if binary is callable on our path 54 | try: 55 | run_command([filename], True) 56 | return True 57 | except OSError: 58 | return False 59 | 60 | 61 | def download_kubectl(): 62 | if binary_exists('kubectl'): 63 | return 'kubectl' 64 | if os.path.isfile(KUBECTL): 65 | return KUBECTL 66 | print('Downloading kubectl...') 67 | version = get_latest_github_version('kubernetes/kubernetes') 68 | url = 'http://storage.googleapis.com/kubernetes-release/release/%s/bin/%s/amd64/kubectl%s' % (version, OS, FILE_SUFFIX) 69 | download_exec(url, KUBECTL) 70 | return KUBECTL 71 | 72 | 73 | def download_minikube(): 74 | # First check for the user's installation. Don't break it if they have one 75 | if binary_exists('minikube'): 76 | return 'minikube' 77 | 78 | if os.path.isfile(MINIKUBE): 79 | return MINIKUBE 80 | print('Downloading minikube') 81 | version = get_latest_github_version('kubernetes/minikube') 82 | url = 'https://storage.googleapis.com/minikube/releases/%s/minikube-%s-amd64%s' % (version, OS, FILE_SUFFIX) 83 | download_exec(url, MINIKUBE) 84 | return MINIKUBE 85 | 86 | 87 | def get_ip(): 88 | # http://stackoverflow.com/a/28950776/671626 89 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 90 | try: 91 | # doesn't even have to be reachable 92 | s.connect(('10.255.255.255', 0)) 93 | IP = s.getsockname()[0] 94 | except: 95 | IP = '127.0.0.1' 96 | finally: 97 | s.close() 98 | return IP 99 | 100 | 101 | def render_manifests(): 102 | run_command([RENDER_MANIFESTS, BASE_DIR, 'test', 'http://%s:8000/players/api/games/' % get_ip()]) 103 | 104 | 105 | def start_cluster(minikube): 106 | status = run_command([minikube, 'status'], True) 107 | if 'Running' in status: 108 | print('Cluster already running') 109 | else: 110 | run_command([minikube, 'start', '--memory=2048', '--cpus=2']) 111 | 112 | 113 | def build_docker_images(minikube): 114 | print('Building docker images') 115 | raw_env_settings = run_command([minikube, 'docker-env', '--shell="bash"'], True) 116 | matches = re.finditer(r'^export (.+)="(.+)"$', raw_env_settings, re.MULTILINE) 117 | env = dict([(m.group(1), m.group(2)) for m in matches]) 118 | 119 | client = docker.from_env( 120 | environment=env, 121 | version='auto', 122 | ) 123 | 124 | dirs = ('aimmo-game', 'aimmo-game-creator', 'aimmo-game-worker', 'aimmo-reverse-proxy') 125 | for dir in dirs: 126 | path = os.path.join(BASE_DIR, dir) 127 | tag = 'ocadotechnology/%s:test' % dir 128 | print("Building %s..." % tag) 129 | status = client.build( 130 | decode=True, 131 | path=dir, 132 | tag=tag, 133 | ) 134 | for line in status: 135 | if 'stream' in line: 136 | print(line['stream'], end='') 137 | 138 | 139 | def apply_manifests(kubectl): 140 | print('Applying manifests...') 141 | run_command([kubectl, '--context=minikube', 'apply', '-f', MANIFESTS]) 142 | print('Deleting existing pods...') 143 | kubernetes.config.load_kube_config(context='minikube') 144 | v1_api = kubernetes.client.CoreV1Api() 145 | for pod in v1_api.list_namespaced_pod('default').items: 146 | v1_api.delete_namespaced_pod(body=kubernetes.client.V1DeleteOptions(), name=pod.metadata.name, namespace='default') 147 | 148 | 149 | def start(): 150 | if platform.machine().lower() not in ('amd64', 'x86_64'): 151 | raise ValueError('Requires 64-bit') 152 | create_test_bin() 153 | kubectl = download_kubectl() 154 | minikube = download_minikube() 155 | start_cluster(minikube) 156 | build_docker_images(minikube) 157 | render_manifests() 158 | apply_manifests(kubectl) 159 | os.environ['MINIKUBE_PROXY_URL'] = run_command([minikube, 'service', 'aimmo-reverse-proxy', '--url'], True).strip() 160 | print('Cluster ready') 161 | -------------------------------------------------------------------------------- /players/views.py: -------------------------------------------------------------------------------- 1 | import cPickle as pickle 2 | import logging 3 | import os 4 | 5 | from django.contrib.auth.decorators import login_required 6 | from django.core.exceptions import ValidationError 7 | from django.core.urlresolvers import reverse 8 | from django.http import HttpResponse, Http404 9 | from django.http import JsonResponse 10 | from django.shortcuts import redirect, render, get_object_or_404 11 | from django.views.decorators.csrf import csrf_exempt 12 | from django.views.decorators.http import require_http_methods 13 | from django.views.generic import TemplateView 14 | 15 | from models import Avatar, Game, LevelAttempt 16 | from players import forms 17 | from . import app_settings 18 | 19 | LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | def _post_code_success_response(message): 23 | return _create_response("SUCCESS", message) 24 | 25 | 26 | def _create_response(status, message): 27 | response = { 28 | "status": status, 29 | "message": message 30 | } 31 | return JsonResponse(response) 32 | 33 | 34 | @login_required 35 | def code(request, id): 36 | game = get_object_or_404(Game, id=id) 37 | if not game.can_user_play(request.user): 38 | raise Http404 39 | try: 40 | avatar = game.avatar_set.get(owner=request.user) 41 | except Avatar.DoesNotExist: 42 | initial_code_file_name = os.path.join( 43 | os.path.abspath(os.path.dirname(__file__)), 44 | 'avatar_examples/dumb_avatar.py', 45 | ) 46 | with open(initial_code_file_name) as initial_code_file: 47 | initial_code = initial_code_file.read() 48 | avatar = Avatar.objects.create(owner=request.user, code=initial_code, 49 | game_id=id) 50 | if request.method == 'POST': 51 | avatar.code = request.POST['code'] 52 | avatar.save() 53 | return _post_code_success_response('Your code was saved!

Watch' % reverse('aimmo/watch', kwargs={'id': game.id})) 54 | else: 55 | return HttpResponse(avatar.code) 56 | 57 | 58 | def list_games(request): 59 | response = { 60 | game.pk: 61 | { 62 | 'name': game.name, 63 | 'settings': pickle.dumps(game.settings_as_dict()), 64 | } for game in Game.objects.exclude_inactive() 65 | } 66 | return JsonResponse(response) 67 | 68 | 69 | def get_game(request, id): 70 | game = get_object_or_404(Game, id=id) 71 | response = { 72 | 'main': { 73 | 'parameters': [], 74 | 'main_avatar': None, 75 | 'users': [], 76 | } 77 | } 78 | for avatar in game.avatar_set.all(): 79 | if avatar.owner_id == game.main_user_id: 80 | response['main']['main_avatar'] = avatar.owner_id 81 | response['main']['users'].append({ 82 | 'id': avatar.owner_id, 83 | 'code': avatar.code, 84 | }) 85 | return JsonResponse(response) 86 | 87 | 88 | @csrf_exempt 89 | @require_http_methods(['POST']) 90 | def mark_game_complete(request, id): 91 | game = get_object_or_404(Game, id=id) 92 | game.completed = True 93 | game.static_data = request.body 94 | game.save() 95 | return HttpResponse('Done!') 96 | 97 | 98 | class ProgramView(TemplateView): 99 | template_name = 'players/program.html' 100 | 101 | def get_context_data(self, **kwargs): 102 | context = super(ProgramView, self).get_context_data(**kwargs) 103 | game = get_object_or_404(Game, id=self.kwargs['id']) 104 | if not game.can_user_play(self.request.user): 105 | raise Http404 106 | context['game_id'] = int(self.kwargs['id']) 107 | return context 108 | 109 | 110 | def program_level(request, num): 111 | try: 112 | game = Game.objects.get(levelattempt__user=request.user, levelattempt__level_number=num) 113 | except Game.DoesNotExist: 114 | LOGGER.debug('Adding level') 115 | game = _add_and_return_level(num, request.user) 116 | LOGGER.debug('Programming game with id %s', game.id) 117 | return render(request, 'players/program.html', {'game_id': game.id}) 118 | 119 | 120 | def _render_game(request, game): 121 | context = { 122 | 'current_user_player_key': request.user.pk, 123 | 'active': game.is_active, 124 | 'static_data': game.static_data or '{}', 125 | } 126 | context['game_url_base'], context['game_url_path'] = app_settings.GAME_SERVER_LOCATION_FUNCTION(game.id) 127 | return render(request, 'players/watch.html', context) 128 | 129 | 130 | def watch_game(request, id): 131 | game = get_object_or_404(Game, id=id) 132 | if not game.can_user_play(request.user): 133 | raise Http404 134 | return _render_game(request, game) 135 | 136 | 137 | def watch_level(request, num): 138 | try: 139 | game = Game.objects.get(levelattempt__user=request.user, levelattempt__level_number=num) 140 | except Game.DoesNotExist: 141 | LOGGER.debug('Adding level') 142 | game = _add_and_return_level(num, request.user) 143 | LOGGER.debug('Displaying game with id %s', game.id) 144 | return _render_game(request, game) 145 | 146 | 147 | def _add_and_return_level(num, user): 148 | game = Game(generator='Level'+num, name='Level '+num, public=False, main_user=user) 149 | try: 150 | game.save() 151 | except ValidationError as e: 152 | LOGGER.warn(e) 153 | raise Http404 154 | game.can_play = [user] 155 | game.save() 156 | level_attempt = LevelAttempt(game=game, user=user, level_number=num) 157 | level_attempt.save() 158 | return game 159 | 160 | 161 | @login_required 162 | def add_game(request): 163 | if request.method == 'POST': 164 | form = forms.AddGameForm(request.POST) 165 | if form.is_valid(): 166 | game = form.save(commit=False) 167 | game.generator = 'Main' 168 | game.owner = request.user 169 | game.save() 170 | return redirect('aimmo/program', id=game.id) 171 | else: 172 | form = forms.AddGameForm() 173 | return render(request, 'players/add_game.html', {'form': form}) 174 | -------------------------------------------------------------------------------- /players/static/js/watch/world-viewer.js: -------------------------------------------------------------------------------- 1 | // All calls to paper.* should call invertY to get from simulation coordinate system into visualisation coordinate system, then scale up by appearance.cellSize 2 | 3 | const APPEARANCE = Object.create({ 4 | cellSize: 50, 5 | worldColours: { 6 | 0: "#efe", 7 | 1: "#777", 8 | 2: "#fbb", 9 | 3: "#ade" 10 | } 11 | }); 12 | 13 | const VIEWER = Object.create({ 14 | drawnElements: { 15 | players: [], 16 | pickups: [] 17 | }, 18 | 19 | init: function(canvasDomElement, world, appearance) { 20 | this.world = world; 21 | this.appearance = appearance; 22 | this.paper = Raphael(canvasDomElement); 23 | }, 24 | 25 | invertY: function(y) { 26 | return -y; 27 | }, 28 | 29 | reDrawWorldLayout: function() { 30 | this.paper.clear(); 31 | this.paper.setViewBox(this.world.minX * this.appearance.cellSize, this.world.minY * this.appearance.cellSize, //min possible coordinates 32 | this.world.width * this.appearance.cellSize, this.world.height * this.appearance.cellSize, //size of grid 33 | true); 34 | 35 | for (var x = this.world.minX; x <= this.world.maxX; x++) { 36 | for (var y = this.world.minY; y <= this.world.maxY; y++) { 37 | var currentCellValue = this.world.layout[x][y]; 38 | 39 | var square = this.paper.rect(x * this.appearance.cellSize, 40 | this.invertY(y) * this.appearance.cellSize, 41 | this.appearance.cellSize, 42 | this.appearance.cellSize); 43 | 44 | square.attr("fill", this.appearance.worldColours[currentCellValue]); 45 | square.attr("stroke", "#000"); 46 | 47 | this.paper.text((x + 0.5) * this.appearance.cellSize, (this.invertY(y) + 0.5) * this.appearance.cellSize, x + ', ' + y) 48 | } 49 | } 50 | }, 51 | 52 | constructNewPlayerElement: function(playerData, is_current_user) { 53 | const playerX = (0.5 + playerData["x"]) * this.appearance.cellSize; 54 | const playerY = (0.5 + this.invertY(playerData["y"])) * this.appearance.cellSize; 55 | const playerRadius = this.appearance.cellSize * 0.5 * 0.75; 56 | const currentUserIconSize = playerRadius * 0.4; 57 | 58 | var playerBody = this.paper.circle(playerX, playerY, playerRadius); 59 | 60 | playerBody.attr("fill", "#6495ED"); 61 | playerBody.attr("stroke", "#6495ED"); 62 | 63 | var currentUserIcon; 64 | if (is_current_user) { 65 | currentUserIcon = this.paper.rect( 66 | playerX - currentUserIconSize / 2, 67 | playerY - currentUserIconSize / 2, 68 | currentUserIconSize, 69 | currentUserIconSize 70 | ); 71 | currentUserIcon.attr("fill", "#FF0000"); 72 | currentUserIcon.attr("stroke", "#FF0000"); 73 | } 74 | 75 | var playerTextAbove = this.paper.text(playerX, playerY - 20, 'Score: ' + playerData["score"]); 76 | var playerTextBelow = this.paper.text(playerX, playerY + 20, playerData["health"] + 'hp, (' + playerData["x"] + ', ' + playerData["y"] + ')'); 77 | 78 | var player = this.paper.set(); 79 | player.push( 80 | playerBody, 81 | playerTextAbove, 82 | playerTextBelow, 83 | currentUserIcon 84 | ); 85 | return player; 86 | }, 87 | 88 | clearDrawnElements: function(elements) { 89 | while (elements.length > 0) { 90 | var elementToRemove = elements.pop(); 91 | elementToRemove.remove(); 92 | } 93 | }, 94 | 95 | reDrawPlayers: function() { 96 | this.clearDrawnElements(this.drawnElements.players); 97 | 98 | for (var i = 0; i < this.world.players.length; i++) { 99 | var is_current_user = this.world.players[i]["id"] === CURRENT_USER_PLAYER_KEY; 100 | var playerElement = this.constructNewPlayerElement(this.world.players[i], is_current_user); 101 | this.drawnElements.players.push(playerElement); 102 | } 103 | }, 104 | 105 | reDrawPickups: function() { 106 | this.clearDrawnElements(this.drawnElements.pickups); 107 | 108 | for (var i = 0; i < this.world.pickups.length; i++) { 109 | var pickupLocation = this.world.pickups[i].location; 110 | var x = (0.5 + pickupLocation[0]) * this.appearance.cellSize; 111 | var y = (0.5 + this.invertY(pickupLocation[1])) * this.appearance.cellSize; 112 | 113 | // Just for testing. 114 | pickup = this.drawHealth(x, y); 115 | } 116 | }, 117 | 118 | drawHealth: function(x, y) { 119 | var radius = this.appearance.cellSize * 0.5 * 0.75; 120 | var circle = this.paper.circle(x, y, radius); 121 | circle.attr("fill", '#FFFFFF'); 122 | var crossX = this.paper.rect(x - 10, y - 3, 20, 6).attr({fill: '#FF0000', stroke: '#FF0000'}); 123 | var crossY = this.paper.rect(x - 3, y - 10, 6, 20).attr({fill: '#FF0000', stroke: '#FF0000'}); 124 | var pickup = this.paper.set(); 125 | pickup.push(circle, crossX, crossY); 126 | return pickup; 127 | }, 128 | 129 | drawInvulnerability: function(x, y) { 130 | var radius = this.appearance.cellSize * 0.5 * 0.75; 131 | var circle = this.paper.circle(x, y, radius); 132 | circle.attr('fill', '#0066ff'); 133 | var pickup = this.paper.set(); 134 | pickup.push(circle); 135 | return pickup; 136 | }, 137 | 138 | drawDamage: function(x, y) { 139 | var radius = this.appearance.cellSize * 0.5 * 0.75; 140 | var circle = this.paper.circle(x, y, radius); 141 | circle.attr('fill', '#ff0000'); 142 | var pickup = this.paper.set(); 143 | pickup.push(circle); 144 | return pickup; 145 | }, 146 | 147 | reDrawState: function() { 148 | this.reDrawWorldLayout(); 149 | this.reDrawPickups(); 150 | this.reDrawPlayers(); 151 | } 152 | }); 153 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_map_generator.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import random 4 | import unittest 5 | 6 | from simulation import map_generator 7 | from simulation.location import Location 8 | from simulation.map_generator import get_random_edge_index 9 | from simulation.world_map import WorldMap 10 | from .dummy_avatar import DummyAvatarManager 11 | 12 | 13 | class ConstantRng(object): 14 | def __init__(self, value): 15 | self.value = value 16 | 17 | def randint(self, minimum, maximum): 18 | if not minimum <= self.value <= maximum: 19 | raise ValueError('Beyond range') 20 | return self.value 21 | 22 | 23 | class TestHelperFunctions(unittest.TestCase): 24 | def test_get_random_edge_index(self): 25 | map = WorldMap.generate_empty_map(3, 4, {}) 26 | self.assertEqual( 27 | (0, -1), get_random_edge_index(map, rng=ConstantRng(0))) 28 | self.assertEqual( 29 | (1, -1), get_random_edge_index(map, rng=ConstantRng(1))) 30 | self.assertEqual( 31 | (0, 1), get_random_edge_index(map, rng=ConstantRng(2))) 32 | self.assertEqual( 33 | (1, 1), get_random_edge_index(map, rng=ConstantRng(3))) 34 | self.assertEqual( 35 | (-1, 0), get_random_edge_index(map, rng=ConstantRng(4))) 36 | self.assertEqual( 37 | (2, 0), get_random_edge_index(map, rng=ConstantRng(5))) 38 | 39 | # Verify no out of bounds 40 | with self.assertRaisesRegexp(ValueError, 'Beyond range'): 41 | get_random_edge_index(map, rng=ConstantRng(-1)) 42 | 43 | with self.assertRaisesRegexp(ValueError, 'Beyond range'): 44 | get_random_edge_index(map, rng=ConstantRng(6)) 45 | 46 | def test_get_random_edge_index_can_give_all_possible(self): 47 | map = WorldMap.generate_empty_map(3, 4, {}) 48 | get_random_edge_index(map, rng=ConstantRng(1)) 49 | expected = frozenset(( 50 | (0, 1), (1, 1), 51 | (-1, 0), (2, 0), 52 | (0, -1), (1, -1), 53 | )) 54 | actual = frozenset(get_random_edge_index(map, rng=ConstantRng(i)) 55 | for i in xrange(6)) 56 | self.assertEqual(expected, actual) 57 | 58 | def test_out_of_bounds_random_edge(self): 59 | map = WorldMap.generate_empty_map(3, 4, {}) 60 | with self.assertRaisesRegexp(ValueError, 'Beyond range'): 61 | get_random_edge_index(map, rng=ConstantRng(-1)) 62 | 63 | with self.assertRaisesRegexp(ValueError, 'Beyond range'): 64 | get_random_edge_index(map, rng=ConstantRng(6)) 65 | 66 | 67 | class _BaseGeneratorTestCase(unittest.TestCase): 68 | def get_game_state(self, **kwargs): 69 | random.seed(0) 70 | settings = { 71 | 'START_WIDTH': 3, 72 | 'START_HEIGHT': 4, 73 | 'OBSTACLE_RATIO': 1.0 74 | } 75 | settings.update(kwargs) 76 | return self.GENERATOR_CLASS(settings).get_game_state(DummyAvatarManager()) 77 | 78 | def get_map(self, **kwargs): 79 | return self.get_game_state(**kwargs).world_map 80 | 81 | 82 | class TestMainGenerator(_BaseGeneratorTestCase): 83 | GENERATOR_CLASS = map_generator.Main 84 | 85 | def test_map_dimensions(self): 86 | m = self.get_map() 87 | grid = list(m.all_cells()) 88 | self.assertEqual(len(set(grid)), len(grid), "Repeats in list") 89 | for c in grid: 90 | self.assertLessEqual(c.location.x, 1) 91 | self.assertLessEqual(c.location.y, 2) 92 | self.assertGreaterEqual(c.location.x, -1) 93 | self.assertGreaterEqual(c.location.y, -1) 94 | 95 | def test_obstable_ratio(self): 96 | m = self.get_map(OBSTACLE_RATIO=0) 97 | obstacle_cells = [cell for cell in m.all_cells() if not cell.habitable] 98 | self.assertEqual(len(obstacle_cells), 0) 99 | 100 | def test_map_contains_some_non_habitable_cell(self): 101 | m = self.get_map() 102 | obstacle_cells = [cell for cell in m.all_cells() if not cell.habitable] 103 | self.assertGreaterEqual(len(obstacle_cells), 1) 104 | 105 | def test_map_contains_some_habitable_cell_on_border(self): 106 | m = self.get_map(START_WIDTH=4) 107 | edge_coordinates = [ 108 | (-1, 2), (0, 2), (1, 2), (2, 2), 109 | (-1, 1), (2, 1), 110 | (-1, 0), (2, 0), 111 | (-1, -1), (0, -1), (1, -1), (2, -1), 112 | ] 113 | edge_cells = (m.get_cell_by_coords(x, y) for (x, y) in edge_coordinates) 114 | habitable_edge_cells = [cell for cell in edge_cells if cell.habitable] 115 | 116 | self.assertGreaterEqual(len(habitable_edge_cells), 1) 117 | 118 | def test_not_complete(self): 119 | game_state = self.get_game_state() 120 | self.assertFalse(game_state.is_complete()) 121 | 122 | 123 | class TestLevel1Generator(_BaseGeneratorTestCase): 124 | GENERATOR_CLASS = map_generator.Level1 125 | 126 | def test_width_5(self): 127 | self.assertEqual(self.get_map().num_cols, 5) 128 | 129 | def test_height_1(self): 130 | self.assertEqual(self.get_map().num_rows, 1) 131 | 132 | def test_incomplete_without_avatars(self): 133 | game_state = self.get_game_state() 134 | self.assertFalse(game_state.is_complete()) 135 | 136 | def test_incomplete_at_score_0(self): 137 | game_state = self.get_game_state() 138 | game_state.avatar_manager.add_avatar(1, '', None) 139 | game_state.main_avatar_id = 1 140 | self.assertFalse(game_state.is_complete()) 141 | 142 | def test_completes_at_score_1(self): 143 | game_state = self.get_game_state() 144 | game_state.avatar_manager.add_avatar(1, '', None) 145 | game_state.avatar_manager.avatars_by_id[1].score = 1 146 | game_state.main_avatar_id = 1 147 | self.assertTrue(game_state.is_complete()) 148 | 149 | def test_static_spawn(self): 150 | game_state = self.get_game_state() 151 | for i in xrange(5): 152 | game_state.add_avatar(i, '') 153 | self.assertEqual(game_state.avatar_manager.avatars_by_id[i].location, Location(-2, 0)) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI:MMO 2 | A **M**assively **M**ulti-player **O**nline game, where players create **A**rtificially **I**ntelligent programs to play on their behalf. 3 | 4 | ## A [Code for Life](https://www.codeforlife.education/) project 5 | * Ocado Technology's [Code for Life initiative](https://www.codeforlife.education/) has been developed to inspire the next generation of computer scientists and to help teachers deliver the computing curriculum. 6 | * This repository hosts the source code of the **AI:MMO game**. AI:MMO is aimed as a follow-on from [Rapid Router](https://www.codeforlife.education/rapidrouter) to teach computing to secondary-school age children. 7 | * The other repos for Code For Life: 8 | * the main portal (as well as registration, dashboards, materials...), [Code For Life Portal](https://github.com/ocadotechnology/codeforlife-portal) 9 | * the first coding game of Code for Life for primary schools, [Rapid Router](https://github.com/ocadotechnology/rapid-router) 10 | * the [deployment code for Google App Engine](https://github.com/ocadotechnology/codeforlife-deploy-appengine) 11 | 12 | ## Objective 13 | People program Avatars. Avatars play the game. A player's aim is to create a better Avatar than other people's Avatars. A "better" Avatar is one that scores points faster than other people's Avatars. 14 | 15 | By getting people to compete to program better Avatars, we can teach them all sorts of algorithms and optimisation techniques. For example, a really good Avatar might incorporate AI techniques such as neural networks in order to make more optimal decisions. 16 | 17 | ## The Game 18 | The world is a 2D grid. Some cells are impassable. Some cells generate score. Some cells contain pick-ups. 19 | 20 | There are other Avatars in the world. The more Avatars there are, the bigger the world gets. 21 | 22 | Time passes in turns. An Avatar may perform a single action every turn. They only have visibility of a small amount of the world around them. 23 | 24 | Avatars can only wait, move or attack. 25 | 26 | Even with these basic mechanics, there is quite a lot of complexity in creating an Avatar that is good at gaining score. For example, you may need to be able to find optimal paths from A to B. You may need to program your Avatar to remember the parts of the world that it has already encountered, so that you can find faster paths between locations. You may need to program your Avatar to machine learn when it is optimal to: 27 | - attack another player 28 | - run away from another player 29 | - try to find a health pick up 30 | - run to the score location 31 | - ... 32 | 33 | ## Architecture 34 | ### Web Interface - `players` 35 | - Django 36 | - In-browser avatar code editor: http://ace.c9.io/#nav=about 37 | - Game view (so players can see their avatars play the game) 38 | - Statistics 39 | - Has a sample deployment in `example_project` 40 | 41 | ### Game creator - `aimmo-game-creator` 42 | - Maintains the set of games 43 | 44 | ### Core Game (Simulation) - `aimmo-game` 45 | - Maintains game state 46 | - Simulates environment events 47 | - Runs player actions 48 | 49 | ### Sandboxed User-Submitted AI Players (Avatars) - `aimmo-game-worker` 50 | - Each avatar will run in their own sandbox so that we can securely deal with user-submitted code 51 | - Each avatar will interact with the core game to get state and perform actions 52 | 53 | 54 | 55 | ## Running Locally 56 | * Clone the repo 57 | * Make and activate a virtualenv (We recommend [virtualenvwrapper](http://virtualenvwrapper.readthedocs.org/en/latest/index.html)) - if you have a Mac see the following section. 58 | * e.g. the first time, `mkvirtualenv -a path/to/aimmo aimmo` 59 | * and thereafter `workon aimmo` 60 | * `./run.py` in your aimmo dir - This will: 61 | * if necessary, create a superuser 'admin' with password 'admin' 62 | * install all of the dependencies using pip 63 | * sync the database 64 | * collect the static files 65 | * run the server 66 | * You can quickly create players as desired using the following command: 67 | 68 | `python example_project/manage.py generate_players 5 dumb_avatar.py` 69 | 70 | This creates 5 users with password `123`, and creates for each user an avatar that runs the code in `dumb_avatar.py` 71 | * To delete the generated players use the following command: 72 | 73 | `python example_project/manage.py delete_generated_players` 74 | 75 | ### Under Kubernetes 76 | * By default, the local environment runs each worker in a Python thread. However, for some testing, it is useful to run as a Kubernetes cluster. Note that this is not for most testing, the default is more convenient as the Kubernetes cluster is slow and runs into resource limits with ~10 avatars. 77 | * Linux, Windows (minikube is experimental though), and OSX (untested) are supported. 78 | * Prerequisites: 79 | * All platforms: VT-x/AMD-v virtualization. 80 | * Linux: [Virtualbox](https://www.virtualbox.org/wiki/Downloads). 81 | * OSX: either [Virtualbox](https://www.virtualbox.org/wiki/Downloads) or [VMWare Fusion](http://www.vmware.com/products/fusion.html). 82 | * Usage: `python run.py -k`. This will: 83 | * Download Docker, Minikube, and Kubectl into a `test-bin` folder in the project's root directory. 84 | * Run `minikube start` (if the cluster is not already running). 85 | * Build each image. 86 | * Start aimmo-game-creator. 87 | * Perform the same setup that run.py normally performs. 88 | * Start the Django project (which is not kubernetes controlled) on localhost:8000. 89 | * Run the same command to update all the images. 90 | 91 | #### Interacting with the cluster 92 | * `kubectl` and `minikube` (both in the `test-bin` folder, note that this is not on your PATH) can be used to interact with the cluster. 93 | * Running either command without any options will give the most useful commands. 94 | * `./test-bin/minikube dashboard` to open the kubernetes dashboard in your def 95 | 96 | ## Testing Locally 97 | *`./all_tests.py` will run all tests (note that this is several pages of output). 98 | * `--coverage` option generates coverage data using coverage.py 99 | 100 | ## Useful commands 101 | * To create an admin account: 102 | `python example_project/manage.py createsuperuser` 103 | 104 | ### Installing virtualenvwrapper on Mac 105 | * Run `pip install virtualenvwrapper` 106 | * Add the following to ~/.bashrc: 107 | ``` 108 | export WORKON_HOME=$HOME/.virtualenvs 109 | source /usr/local/bin/virtualenvwrapper.sh 110 | ``` 111 | * [This blog post](http://mkelsey.com/2013/04/30/how-i-setup-virtualenv-and-virtualenvwrapper-on-my-mac/) may also be 112 | useful. 113 | 114 | ## How to contribute! 115 | __Want to help?__ You can contact us using this [contact form][c4l-contact-form] and we'll get in touch as soon as possible! Thanks a lot. 116 | 117 | [c4l-contact-form]: https://www.codeforlife.education/contact/ 118 | -------------------------------------------------------------------------------- /aimmo-game/simulation/map_generator.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import heapq 3 | import logging 4 | import random 5 | from itertools import tee 6 | 7 | from six.moves import zip, range 8 | 9 | from simulation.direction import ALL_DIRECTIONS 10 | from simulation.game_state import GameState 11 | from simulation.location import Location 12 | from simulation.world_map import WorldMap 13 | from simulation.world_map import WorldMapStaticSpawnDecorator 14 | from simulation.world_map import DEFAULT_LEVEL_SETTINGS 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class _BaseGenerator(object): 20 | __metaclass__ = abc.ABCMeta 21 | 22 | def __init__(self, settings): 23 | self.settings = settings 24 | 25 | def get_game_state(self, avatar_manager): 26 | return GameState(self.get_map(), avatar_manager, self.check_complete) 27 | 28 | def check_complete(self, game_state): 29 | return False 30 | 31 | @abc.abstractmethod 32 | def get_map(self): 33 | pass 34 | 35 | 36 | class Main(_BaseGenerator): 37 | def get_map(self): 38 | height = self.settings['START_HEIGHT'] 39 | width = self.settings['START_WIDTH'] 40 | world_map = WorldMap.generate_empty_map(height, width, self.settings) 41 | 42 | # We designate one non-corner edge cell as empty, to ensure that the map can be expanded 43 | always_empty_edge_x, always_empty_edge_y = get_random_edge_index(world_map) 44 | always_empty_location = Location(always_empty_edge_x, always_empty_edge_y) 45 | 46 | for cell in shuffled(world_map.all_cells()): 47 | if cell.location != always_empty_location and random.random() < self.settings['OBSTACLE_RATIO']: 48 | cell.habitable = False 49 | # So long as all habitable neighbours can still reach each other, 50 | # then the map cannot get bisected 51 | if not _all_habitable_neighbours_can_reach_each_other(cell, world_map): 52 | cell.habitable = True 53 | 54 | return world_map 55 | 56 | 57 | def _get_edge_coordinates(height, width): 58 | for x in range(width): 59 | for y in range(height): 60 | yield x, y 61 | 62 | 63 | def shuffled(iterable): 64 | values = list(iterable) 65 | random.shuffle(values) 66 | return iter(values) 67 | 68 | 69 | def pairwise(iterable): 70 | """s -> (s0,s1), (s1,s2), (s2, s3), ...""" 71 | a, b = tee(iterable) 72 | next(b, None) 73 | return zip(a, b) 74 | 75 | 76 | def _all_habitable_neighbours_can_reach_each_other(cell, world_map): 77 | neighbours = get_adjacent_habitable_cells(cell, world_map) 78 | 79 | assert len(neighbours) >= 1 80 | neighbour_pairs = ((n1, n2) for n1, n2 in pairwise(neighbours)) 81 | shortest_path_exists = (get_shortest_path_between(n1, n2, world_map) is not None 82 | for n1, n2 in neighbour_pairs) 83 | return all(shortest_path_exists) 84 | 85 | 86 | def get_shortest_path_between(source_cell, destination_cell, world_map): 87 | 88 | def manhattan_distance_to_destination_cell(this_branch): 89 | branch_tip_location = this_branch[-1].location 90 | x_distance = abs(branch_tip_location.x - destination_cell.location.x) 91 | y_distance = abs(branch_tip_location.y - destination_cell.location.y) 92 | return x_distance + y_distance + len(this_branch) 93 | 94 | branches = PriorityQueue(key=manhattan_distance_to_destination_cell, init_items=[[source_cell]]) 95 | visited_cells = set() 96 | 97 | while branches: 98 | branch = branches.pop() 99 | 100 | for cell in get_adjacent_habitable_cells(branch[-1], world_map): 101 | if cell in visited_cells: 102 | continue 103 | 104 | visited_cells.add(cell) 105 | 106 | new_branch = branch + [cell] 107 | 108 | if cell == destination_cell: 109 | return new_branch 110 | 111 | branches.push(new_branch) 112 | 113 | return None 114 | 115 | 116 | def get_random_edge_index(world_map, rng=random): 117 | num_row_cells = world_map.num_rows - 2 118 | num_col_cells = world_map.num_cols - 2 119 | num_edge_cells = 2*num_row_cells + 2*num_col_cells 120 | random_cell = rng.randint(0, num_edge_cells-1) 121 | 122 | if 0 <= random_cell < num_col_cells: 123 | # random non-corner cell on the first row 124 | return random_cell + 1 + world_map.min_x(), world_map.min_y() 125 | random_cell -= num_col_cells 126 | 127 | if 0 <= random_cell < num_col_cells: 128 | # random non-corner cell on the last row 129 | return random_cell + 1 + world_map.min_x(), world_map.max_y() 130 | random_cell -= num_col_cells 131 | 132 | if 0 <= random_cell < num_row_cells: 133 | # random non-corner cell on the first column 134 | return world_map.min_x(), world_map.min_y() + random_cell + 1 135 | random_cell -= num_row_cells 136 | 137 | assert 0 <= random_cell < num_row_cells 138 | # random non-corner cell on the last column 139 | return world_map.max_x(), world_map.min_y() + random_cell + 1 140 | 141 | 142 | def get_adjacent_habitable_cells(cell, world_map): 143 | adjacent_locations = [cell.location + d for d in ALL_DIRECTIONS] 144 | adjacent_locations = [location for location in adjacent_locations 145 | if world_map.is_on_map(location)] 146 | 147 | adjacent_cells = [world_map.get_cell(location) for location in adjacent_locations] 148 | return [c for c in adjacent_cells if c.habitable] 149 | 150 | 151 | class PriorityQueue(object): 152 | def __init__(self, key, init_items=tuple()): 153 | self.key = key 154 | self.heap = [self._build_tuple(i) for i in init_items] 155 | heapq.heapify(self.heap) 156 | 157 | def _build_tuple(self, item): 158 | return self.key(item), item 159 | 160 | def push(self, item): 161 | to_push = self._build_tuple(item) 162 | heapq.heappush(self.heap, to_push) 163 | 164 | def pop(self): 165 | _, item = heapq.heappop(self.heap) 166 | return item 167 | 168 | def __len__(self): 169 | return len(self.heap) 170 | 171 | 172 | class _BaseLevelGenerator(_BaseGenerator): 173 | __metaclass__ = abc.ABCMeta 174 | 175 | def __init__(self, *args, **kwargs): 176 | super(_BaseLevelGenerator, self).__init__(*args, **kwargs) 177 | self.settings.update(DEFAULT_LEVEL_SETTINGS) 178 | 179 | 180 | class Level1(_BaseLevelGenerator): 181 | def get_map(self): 182 | world_map = WorldMap.generate_empty_map(1, 5, self.settings) 183 | world_map = WorldMapStaticSpawnDecorator(world_map, Location(-2, 0)) 184 | world_map.get_cell(Location(2, 0)).generates_score = True 185 | return world_map 186 | 187 | def check_complete(self, game_state): 188 | try: 189 | main_avatar = game_state.get_main_avatar() 190 | except KeyError: 191 | return False 192 | return main_avatar.score > 0 193 | -------------------------------------------------------------------------------- /aimmo-game/tests/test_simulation/test_turn_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import unittest 4 | 5 | from simulation.avatar.avatar_appearance import AvatarAppearance 6 | from simulation.game_state import GameState 7 | from simulation.location import Location 8 | from simulation.turn_manager import ConcurrentTurnManager 9 | from .dummy_avatar import DummyAvatarManager 10 | from .dummy_avatar import MoveEastDummy 11 | from .dummy_avatar import MoveNorthDummy 12 | from .dummy_avatar import MoveSouthDummy 13 | from .dummy_avatar import MoveWestDummy 14 | from .dummy_avatar import WaitDummy 15 | from .maps import InfiniteMap 16 | 17 | ORIGIN = Location(0, 0) 18 | 19 | RIGHT_OF_ORIGIN = Location(1, 0) 20 | FIVE_RIGHT_OF_ORIGIN = Location(5, 0) 21 | 22 | ABOVE_ORIGIN = Location(0, 1) 23 | FIVE_RIGHT_OF_ORIGIN_AND_ONE_ABOVE = Location(5, 1) 24 | 25 | 26 | class MockGameState(GameState): 27 | def get_state_for(self, avatar): 28 | return self 29 | 30 | 31 | class TestTurnManager(unittest.TestCase): 32 | def construct_default_avatar_appearance(self): 33 | return AvatarAppearance("#000", "#ddd", "#777", "#fff") 34 | 35 | def construct_turn_manager(self, avatars, locations): 36 | self.avatar_manager = DummyAvatarManager(avatars) 37 | self.game_state = MockGameState(InfiniteMap(), self.avatar_manager) 38 | self.turn_manager = ConcurrentTurnManager(game_state=self.game_state, 39 | end_turn_callback=lambda: None, 40 | completion_url='') 41 | for index, location in enumerate(locations): 42 | self.game_state.add_avatar(index, "", location) 43 | return self.turn_manager 44 | 45 | def assert_at(self, avatar, location): 46 | self.assertEqual(avatar.location, location) 47 | cell = self.game_state.world_map.get_cell(location) 48 | self.assertEqual(cell.avatar, avatar) 49 | 50 | def get_avatar(self, player_id): 51 | return self.avatar_manager.get_avatar(player_id) 52 | 53 | def run_turn(self): 54 | self.turn_manager.run_turn() 55 | 56 | def test_run_turn(self): 57 | ''' 58 | Given: > _ 59 | (1) 60 | Expect: _ o 61 | ''' 62 | self.construct_turn_manager([MoveEastDummy], [ORIGIN]) 63 | avatar = self.get_avatar(0) 64 | 65 | self.assert_at(avatar, ORIGIN) 66 | self.run_turn() 67 | self.assert_at(avatar, RIGHT_OF_ORIGIN) 68 | 69 | def test_run_several_turns(self): 70 | ''' 71 | Given: > _ _ _ _ _ 72 | (5) 73 | Expect: _ _ _ _ _ o 74 | ''' 75 | self.construct_turn_manager([MoveEastDummy], [ORIGIN]) 76 | avatar = self.get_avatar(0) 77 | 78 | self.assertEqual(avatar.location, ORIGIN) 79 | [self.run_turn() for _ in range(5)] 80 | self.assertEqual(avatar.location, FIVE_RIGHT_OF_ORIGIN) 81 | 82 | def test_run_several_turns_and_avatars(self): 83 | ''' 84 | Given: > _ _ _ _ _ 85 | > _ _ _ _ _ 86 | (5) 87 | Expect: _ _ _ _ _ o 88 | _ _ _ _ _ o 89 | ''' 90 | self.construct_turn_manager([MoveEastDummy, MoveEastDummy], 91 | [ORIGIN, ABOVE_ORIGIN]) 92 | avatar0 = self.get_avatar(0) 93 | avatar1 = self.get_avatar(1) 94 | 95 | self.assert_at(avatar0, ORIGIN) 96 | self.assert_at(avatar1, ABOVE_ORIGIN) 97 | [self.run_turn() for _ in range(5)] 98 | self.assert_at(avatar0, FIVE_RIGHT_OF_ORIGIN) 99 | self.assert_at(avatar1, FIVE_RIGHT_OF_ORIGIN_AND_ONE_ABOVE) 100 | 101 | def test_move_chain_succeeds(self): 102 | ''' 103 | Given: > > > > > _ 104 | 105 | Expect: _ o o o o o 106 | ''' 107 | self.construct_turn_manager([MoveEastDummy for _ in range(5)], 108 | [Location(x, 0) for x in range(5)]) 109 | avatars = [self.get_avatar(i) for i in range(5)] 110 | 111 | [self.assert_at(avatars[x], Location(x, 0)) for x in range(5)] 112 | self.run_turn() 113 | [self.assert_at(avatars[x], Location(x + 1, 0)) for x in range(5)] 114 | 115 | def test_move_chain_fails_occupied(self): 116 | ''' 117 | Given: > > x _ 118 | 119 | Expect: x x x _ 120 | ''' 121 | self.construct_turn_manager([MoveEastDummy, MoveEastDummy, WaitDummy], 122 | [Location(x, 0) for x in range(3)]) 123 | avatars = [self.get_avatar(i) for i in range(3)] 124 | 125 | [self.assert_at(avatars[x], Location(x, 0)) for x in range(3)] 126 | self.run_turn() 127 | [self.assert_at(avatars[x], Location(x, 0)) for x in range(3)] 128 | 129 | def test_move_chain_fails_collision(self): 130 | ''' 131 | Given: > > > _ < 132 | (1) 133 | Expect: x x x _ x 134 | ''' 135 | locations = [Location(0, 0), Location(1, 0), Location(2, 0), Location(4, 0)] 136 | self.construct_turn_manager( 137 | [MoveEastDummy, MoveEastDummy, MoveEastDummy, MoveWestDummy], 138 | locations) 139 | avatars = [self.get_avatar(i) for i in range(4)] 140 | 141 | [self.assert_at(avatars[i], locations[i]) for i in range(4)] 142 | self.run_turn() 143 | [self.assert_at(avatars[i], locations[i]) for i in range(4)] 144 | 145 | def test_move_chain_fails_cycle(self): 146 | ''' 147 | Given: > v 148 | ^ < 149 | (1) 150 | Expect: x x 151 | x x 152 | ''' 153 | locations = [Location(0, 1), Location(1, 1), Location(1, 0), Location(0, 0)] 154 | self.construct_turn_manager( 155 | [MoveEastDummy, MoveSouthDummy, MoveWestDummy, MoveNorthDummy], 156 | locations) 157 | avatars = [self.get_avatar(i) for i in range(4)] 158 | 159 | [self.assert_at(avatars[i], locations[i]) for i in range(4)] 160 | self.run_turn() 161 | [self.assert_at(avatars[i], locations[i]) for i in range(4)] 162 | 163 | def test_move_chain_fails_spiral(self): 164 | ''' 165 | Given: > > v 166 | ^ < 167 | (1) 168 | Expect: x x x 169 | x x 170 | ''' 171 | locations = [Location(0, 1), 172 | Location(1, 1), 173 | Location(2, 1), 174 | Location(2, 0), 175 | Location(1, 0)] 176 | self.construct_turn_manager( 177 | [MoveEastDummy, MoveEastDummy, MoveSouthDummy, MoveWestDummy, MoveNorthDummy], 178 | locations) 179 | avatars = [self.get_avatar(i) for i in range(5)] 180 | 181 | [self.assert_at(avatars[i], locations[i]) for i in range(5)] 182 | self.run_turn() 183 | [self.assert_at(avatars[i], locations[i]) for i in range(5)] 184 | 185 | if __name__ == '__main__': 186 | unittest.main() 187 | -------------------------------------------------------------------------------- /aimmo-game/simulation/worker_manager.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import itertools 3 | import json 4 | import logging 5 | import os 6 | import subprocess 7 | import tempfile 8 | import threading 9 | import time 10 | 11 | import requests 12 | from eventlet.greenpool import GreenPool 13 | from eventlet.semaphore import Semaphore 14 | from pykube import HTTPClient 15 | from pykube import KubeConfig 16 | from pykube import Pod 17 | 18 | LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class _WorkerManagerData(object): 22 | """ 23 | This class is thread safe 24 | """ 25 | 26 | def __init__(self, game_state, user_codes): 27 | self._game_state = game_state 28 | self._user_codes = user_codes 29 | self._lock = Semaphore() 30 | 31 | def _remove_avatar(self, user_id): 32 | assert self._lock.locked 33 | self._game_state.remove_avatar(user_id) 34 | del self._user_codes[user_id] 35 | 36 | def remove_user_if_code_is_different(self, user): 37 | with self._lock: 38 | existing_code = self._user_codes.get(user['id'], None) 39 | if existing_code != user['code']: 40 | # Remove avatar from the game, so it stops being called for turns 41 | if existing_code is not None: 42 | self._remove_avatar(user['id']) 43 | return True 44 | else: 45 | return False 46 | 47 | def add_avatar(self, user, worker_url): 48 | with self._lock: 49 | # Add avatar back into game 50 | self._game_state.add_avatar( 51 | user_id=user['id'], worker_url="%s/turn/" % worker_url) 52 | 53 | def set_code(self, user): 54 | with self._lock: 55 | self._user_codes[user['id']] = user['code'] 56 | 57 | def get_code(self, player_id): 58 | with self._lock: 59 | return self._user_codes[player_id] 60 | 61 | def remove_unknown_avatars(self, known_user_ids): 62 | with self._lock: 63 | unknown_user_ids = set(self._user_codes) - frozenset(known_user_ids) 64 | for u in unknown_user_ids: 65 | self._remove_avatar(u) 66 | return unknown_user_ids 67 | 68 | def set_main_avatar(self, avatar_id): 69 | with self._lock: 70 | self._game_state.main_avatar_id = avatar_id 71 | 72 | 73 | class WorkerManager(threading.Thread): 74 | """ 75 | Methods of this class must be thread safe unless explicitly stated. 76 | """ 77 | daemon = True 78 | 79 | def __init__(self, game_state, users_url, port=5000): 80 | """ 81 | 82 | :param thread_pool: 83 | """ 84 | self._data = _WorkerManagerData(game_state, {}) 85 | self.users_url = users_url 86 | self._pool = GreenPool(size=3) 87 | self.port = port 88 | super(WorkerManager, self).__init__() 89 | 90 | def get_code(self, player_id): 91 | return self._data.get_code(player_id) 92 | 93 | def get_persistent_state(self, player_id): 94 | """Get the persistent state for a worker.""" 95 | 96 | return None 97 | 98 | def create_worker(self, player_id): 99 | """Create a worker.""" 100 | 101 | raise NotImplemented 102 | 103 | def remove_worker(self, player_id): 104 | """Remove a worker for the given player.""" 105 | 106 | raise NotImplemented 107 | 108 | # TODO handle failure 109 | def spawn(self, user): 110 | # Get persistent state from worker 111 | persistent_state = self.get_persistent_state(user['id']) # noqa: F841 112 | 113 | # Kill worker 114 | LOGGER.info("Removing worker for user %s" % user['id']) 115 | self.remove_worker(user['id']) 116 | 117 | self._data.set_code(user) 118 | 119 | # Spawn worker 120 | LOGGER.info("Spawning worker for user %s" % user['id']) 121 | worker_url = self.create_worker(user['id']) 122 | 123 | # Add avatar back into game 124 | self._data.add_avatar(user, worker_url) 125 | LOGGER.info('Added user %s', user['id']) 126 | 127 | def _parallel_map(self, func, iterable_args): 128 | list(self._pool.imap(func, iterable_args)) 129 | 130 | def update(self): 131 | try: 132 | LOGGER.info("Waking up") 133 | game_data = requests.get(self.users_url).json() 134 | except (requests.RequestException, ValueError) as err: 135 | LOGGER.error("Failed to obtain game data : %s", err) 136 | else: 137 | game = game_data['main'] 138 | 139 | # Remove users with different code 140 | users_to_add = [] 141 | for user in game['users']: 142 | if self._data.remove_user_if_code_is_different(user): 143 | users_to_add.append(user) 144 | LOGGER.debug("Need to add users: %s" % [x['id'] for x in users_to_add]) 145 | 146 | # Add missing users 147 | self._parallel_map(self.spawn, users_to_add) 148 | 149 | # Delete extra users 150 | known_avatars = set(user['id'] for user in game['users']) 151 | removed_user_ids = self._data.remove_unknown_avatars(known_avatars) 152 | LOGGER.debug("Removing users: %s" % removed_user_ids) 153 | self._parallel_map(self.remove_worker, removed_user_ids) 154 | 155 | # Update main avatar 156 | self._data.set_main_avatar(game_data['main']['main_avatar']) 157 | 158 | def run(self): 159 | while True: 160 | self.update() 161 | LOGGER.info("Sleeping") 162 | time.sleep(10) 163 | 164 | 165 | class LocalWorkerManager(WorkerManager): 166 | """Relies on them already being created already.""" 167 | 168 | host = '127.0.0.1' 169 | worker_directory = os.path.join( 170 | os.path.dirname(__file__), 171 | '../../aimmo-game-worker/', 172 | ) 173 | 174 | def __init__(self, *args, **kwargs): 175 | super(LocalWorkerManager, self).__init__(*args, **kwargs) 176 | self.workers = {} 177 | self.port_counter = itertools.count(self.port + 10) 178 | 179 | def create_worker(self, player_id): 180 | assert(player_id not in self.workers) 181 | port = self.port_counter.next() 182 | env = os.environ.copy() 183 | data_dir = tempfile.mkdtemp() 184 | 185 | LOGGER.debug('Data dir is %s', data_dir) 186 | data = requests.get("http://127.0.0.1:{}/player/{}".format(self.port, player_id)).json() 187 | 188 | options = data['options'] 189 | with open('{}/options.json'.format(data_dir), 'w') as options_file: 190 | json.dump(options, options_file) 191 | 192 | code = data['code'] 193 | with open('{}/avatar.py'.format(data_dir), 'w') as avatar_file: 194 | avatar_file.write(code) 195 | 196 | env['PYTHONPATH'] = data_dir 197 | 198 | process = subprocess.Popen(['python', 'service.py', self.host, str(port), str(data_dir)], cwd=self.worker_directory, env=env) 199 | atexit.register(process.kill) 200 | self.workers[player_id] = process 201 | worker_url = 'http://%s:%d' % ( 202 | self.host, 203 | port, 204 | ) 205 | LOGGER.info("Worker started for %s, listening at %s", player_id, worker_url) 206 | return worker_url 207 | 208 | def remove_worker(self, player_id): 209 | if player_id in self.workers: 210 | self.workers[player_id].kill() 211 | del self.workers[player_id] 212 | 213 | 214 | class KubernetesWorkerManager(WorkerManager): 215 | """Kubernetes worker manager.""" 216 | 217 | def __init__(self, *args, **kwargs): 218 | self.api = HTTPClient(KubeConfig.from_service_account()) 219 | self.game_id = os.environ['GAME_ID'] 220 | self.game_url = os.environ['GAME_URL'] 221 | super(KubernetesWorkerManager, self).__init__(*args, **kwargs) 222 | 223 | def create_worker(self, player_id): 224 | pod = Pod( 225 | self.api, 226 | { 227 | 'kind': 'Pod', 228 | 'apiVersion': 'v1', 229 | 'metadata': { 230 | 'generateName': "aimmo-%s-worker-%s-" % (self.game_id, player_id), 231 | 'labels': { 232 | 'app': 'aimmo-game-worker', 233 | 'game': self.game_id, 234 | 'player': str(player_id), 235 | }, 236 | }, 237 | 'spec': { 238 | 'containers': [ 239 | { 240 | 'env': [ 241 | { 242 | 'name': 'DATA_URL', 243 | 'value': "%s/player/%d" % (self.game_url, player_id), 244 | }, 245 | ], 246 | 'name': 'aimmo-game-worker', 247 | 'image': 'ocadotechnology/aimmo-game-worker:%s' % os.environ.get('IMAGE_SUFFIX', 'latest'), 248 | 'ports': [ 249 | { 250 | 'containerPort': 5000, 251 | 'protocol': 'TCP' 252 | } 253 | ], 254 | 'resources': { 255 | 'limits': { 256 | 'cpu': '10m', 257 | 'memory': '64Mi', 258 | }, 259 | }, 260 | }, 261 | ], 262 | }, 263 | } 264 | ) 265 | pod.create() 266 | iterations = 0 267 | while pod.obj['status']['phase'] == 'Pending': 268 | if iterations > 30: 269 | raise EnvironmentError('Could not start worker %s, details %s' % (player_id, pod.obj)) 270 | LOGGER.debug('Waiting for worker %s', player_id) 271 | time.sleep(5) 272 | pod.reload() 273 | iterations += 1 274 | worker_url = "http://%s:5000" % pod.obj['status']['podIP'] 275 | LOGGER.info("Worker started for %s, listening at %s", player_id, worker_url) 276 | return worker_url 277 | 278 | def remove_worker(self, player_id): 279 | for pod in Pod.objects(self.api).filter(selector={ 280 | 'app': 'aimmo-game-worker', 281 | 'game': self.game_id, 282 | 'player': str(player_id), 283 | }): 284 | LOGGER.debug('Removing pod %s', pod.obj['spec']) 285 | pod.delete() 286 | 287 | WORKER_MANAGERS = { 288 | 'local': LocalWorkerManager, 289 | 'kubernetes': KubernetesWorkerManager, 290 | } 291 | --------------------------------------------------------------------------------