Build the Multi-player Game Model Service

Prerequisites

You will need to have these installed:

Have the Games API service running in Docker and available on http://localhost:8100/.

Installation

In a separate terminal, create a new Python 3.6 virtual environment called div-model3.6:

Here is an example of using venv to create a virtual environment on Mac OS and activating it:

$ /Library/Frameworks/Python.framework/Versions/3.6/bin/python3 -m venv ~/venv/div-model3.6
$ source ~/venv/div-model3.6/bin/activate

Install Django:

$ pip install Django~=2.2.0

Create a Django project folder and rename it to serve as a git repository:

$ django-admin startproject div_model
$ mv div_model div-model

Change to the project folder:

$ cd div-model

Create a requirements.txt file that installs the simpl-modelservice and unit testing apps:

simpl-modelservice==0.9.1

# tests
pytest==4.6.3
pytest-cov==2.7.1
pytest-django==3.5.0
django-test-plus==1.3.1

Install these requirements along with their dependencies:

$ pip install -r requirements.txt

Note, if you receive any errors when installing the above requirements, make sure you have python3.6-dev installed.

Please note, if DJANGO_SETTINGS_MODULE is leftover from a previous session, you may need to unset it:

$ unset DJANGO_SETTINGS_MODULE

Create a django app that will contain your game logic:

$ ./manage.py startapp game

Add the following to your INSTALLED_APPS in div_model/settings.py:

INSTALLED_APPS += [
    'modelservice',
    'game',
]

CALLBACK_URL = os.environ.get('CALLBACK_URL', 'http://{hostname}:{port}/callback')

SIMPL_GAMES_URL = os.environ.get('SIMPL_GAMES_URL', 'http://localhost:8100/apis')

SIMPL_GAMES_AUTH = ('[email protected]', 'simpl')

ROOT_TOPIC = 'world.simpl.sims.div'

It’s highly recommended that you set a 'users' cache. Since the modelservice will run single-threaded, you can take advantage of the locmem backend:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
    },
    'users': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'users',
    }
}

Implementation

For simplicity, we’re going to create a multi-player Game in which each world must have exactly two players – one playing the Dividend role, the other playing the Divisor role. The game’s model will automatically advance as soon as both players in a world have submitted valid decisions for their roles.

In your game app module, define the simulation model by creating a model.py file with the following content:

class Model(object):
    """
    The model calculates a result given a dividend and a divisor and returns the result.
    """

    def step(self, dividend, divisor):
        """
        Parameters:
            dividend - current period's dividend decision
            divisor - current period's divisor decision
        Returns result of dividing dividend by divisor.
        """
        return dividend / divisor

In your game app module, add a unit test directory tests and a model unit test tests/test_model.py:

import pytest
from test_plus.test import TestCase

from game.model import Model


class ModelTestCase(TestCase):
    def setUp(self):
        self.m = Model()

    def test_create(self):
        m = Model()
        self.assertNotEqual(m, None)

    def test_result_5(self):
        result = self.m.step(1.25, 0.25)
        self.assertEqual(result, 5)

    def test_result_fraction(self):
        result = self.m.step(1, 2)
        self.assertEqual(result, 0.5)

Run your unit test:

$ export DJANGO_SETTINGS_MODULE=div_model.settings
$ py.test

Create a management command that will create your game and initialize it with one run, a leader, 2 worlds, and 2 players per world.

Create a management folder in the game folder and add an empty __init__.py file.

Create a commands folder in the game/management folder and add an empty __init__.py file.

Create a create_default_env.py script in the game/management/commands folder containing this code:

import djclick as click

from modelservice.simpl.syn import games_client


def echo(text, value):
    click.echo(
        click.style(text, fg='green') + '{0}'.format(value)
    )


def delete_default_run(games_client):
    """ Delete default Run """
    echo('Resetting the Div game default run...', ' done')
    runs = games_client.runs.filter(game_slug='div')
    for run in runs:
        if run.name == 'default':
            games_client.runs.delete(run.id)


@click.command()
@click.option('--reset', default=False, is_flag=True,
              help="Delete default game run and recreate it from scratch")
def command(reset):
    """
    Create and initialize Div game.
    Create a "default" Div run.
    Set the run phase to "Play".
    Add 2 worlds to the run.
    Add a scenario and period 1 for each world.
    Add 1 leader ("leader") to the run
    Add 4 players ("s1", "s2", "s3", "s4") to the run
    splitting the players between the 2 worlds and assigning all roles.
    """

    # Create a Game
    game = games_client.games.get_or_create(
        name='Div',
        slug='div'
    )
    echo('getting or creating game: ', game.name)

    # Handle resetting the game
    if reset:
        if click.confirm(
                'Are you sure you want to delete the default game run and recreate from scratch?'):
            delete_default_run(games_client)

    # Create required Roles ("Dividend" and "Divisor")
    dividend_role = games_client.roles.get_or_create(
        game=game.id,
        name='Dividend',
    )
    echo('getting or creating role: ', dividend_role.name)

    divisor_role = games_client.roles.get_or_create(
        game=game.id,
        name='Divisor',
    )
    echo('getting or creating role: ', divisor_role.name)

    # Create game Phases ("Play" and "Debrief")
    play_phase = games_client.phases.get_or_create(
        game=game.id,
        name='Play',
        order=1,
    )
    echo('getting or creating phase: ', play_phase.name)

    debrief_phase = games_client.phases.get_or_create(
        game=game.id,
        name='Debrief',
        order=2,
    )
    echo('getting or creating phase: ', debrief_phase.name)

    # Add run with 2 fully populated worlds ready to play
    run = add_run(game, 'default', 2, 1,
                  dividend_role, divisor_role,
                  play_phase, games_client)

    # echo('Completed setting up run: id=', run.id)


def add_run(game, run_name, world_count, first_user_number,
            dividend_role, divisor_role,
            phase, games_client):
    # Create or get a Run
    run = games_client.runs.get_or_create(
        game=game.id,
        name=run_name,
    )
    echo('getting or creating run: ', run.name)

    # Set run to phase
    run.phase = phase.id
    run.save()
    echo('setting run to phase: ', phase.name)

    user_name_root = "s"
    if run_name is not 'default':
        user_name_root = run_name
    for n in range(0, world_count):
        world_num = n + 1
        world = add_world(run, world_num, games_client)

        # Add users to run
        add_world_users(run, world, user_name_root,
                        first_user_number + n * 2,
                        dividend_role, divisor_role, games_client)

    return run


def add_world(run, number, games_client):
    """
        Add a world to the run with a scenario and period 1.
        The world's name is based on number.
    """
    name = 'World {0}'.format(number)
    world = games_client.worlds.get_or_create(
        run=run.id,
        name=name,
    )
    echo('getting or creating world: ', world.name)

    scenario = games_client.scenarios.get_or_create(
        world=world.id,
        name='World Scenario 1'
    )
    period1 = games_client.periods.get_or_create(
        scenario=scenario.id,
        order=1
    )

    return world


def add_world_users(run, world, user_name_root,
                    first_number,
                    dividend_role, divisor_role, games_client):
    """
        Add 1 leader ("leader") to the run with a test scenario
        Add players to the run with names based on user_name_root and first_number
        Add players to world assigning all required roles
    """
    fac_user = games_client.users.get_or_create(
        password='leader',
        first_name='Div',
        last_name='Leader',
        email='[email protected]',
    )
    echo('getting or creating user: ', fac_user.email)

    games_client.runusers.get_or_create(
        user=fac_user.id,
        run=run.id,
        leader=True,
    )
    echo('getting or creating leader runuser for user: ', fac_user.email)

    roles = [dividend_role, divisor_role]
    for n in range(len(roles)):
        user_number = n + first_number
        add_player(user_name_root, user_number, run, world, roles[n],
                   games_client)


def add_player(user_name_root, user_number, run, world, role,
               games_client):
    """Add player with name based on user_name_root and user_number to world in role"""

    username = '{}{}'.format(user_name_root, user_number)
    first_name = 'Student{0}'.format(user_number)
    if user_name_root == 's':  # assume original default namings
        last_name = 'User'
    else:
        last_name = user_name_root[:1].upper() + user_name_root[1:]
    email = '{0}@div.edu'.format(username)

    user = games_client.users.get_or_create(
        password=username,
        first_name=first_name,
        last_name=last_name,
        email=email,
    )
    echo('getting or creating user: ', user.email)

    games_client.runusers.get_or_create(
        user=user.id,
        run=run.id,
        world=world.id,
        role=role.id,
    )
    echo('getting or creating runuser for user: ', user.email)

A World’s players will each submit a Decision saved on the World Scenario’s current Period. After both players have submitted a valid decision, the model will produce a Result for the current Period, and the World’s Scenario will step to the next Period.

In your game app module, create a file called runmodel.py. Next, add save_decision and divide functions to perform these steps:

from modelservice.simpl import games_client
from .model import Model


async def save_decision(period_id, role_id, operand):
    # add/update role's decision for period
    async with games_client as api_session:
        decision = await api_session.decisions.get_or_create(
            period=period_id,
            name='decision',
            role=role_id
        )
        decision.data["operand"] = float(operand)
        await decision.save()
        return decision


async def divide(period_id):
    """
    (Re)calculates the result of the period's Dividend and Divisor decisions.
    """
    async with games_client as api_session:
        period = await api_session.periods.get(scenario=period_id)
        period_decisions = await api_session.decisions.filter(period=period.id)

        dividend, divisor = None, None
        for decision in period_decisions:
            role = await api_session.roles.get(id=decision.role)
            if role.name == 'Dividend':
                dividend = decision.data["operand"]
            else:
                divisor = decision.data["operand"]

        if dividend is None or divisor is None:
            return None

        # run model
        model = Model()
        quotient = model.step(dividend, divisor)

        result = await api_session.results.get_or_create(
            period=period.id,
            name="result",
            defaults={"role": None}
        )
        result.data["quotient"] = quotient
        await result.save()

        return quotient

In your game app module, create a file called games.py that defines a DivPeriod Scope subclass with a submit_decision RPC method. The method validates the operand argument and returns and error message if a Divisor player submits a zero. Otherwise, the model is run if both players in a world have submitted a decision for this period:

import asyncio

from modelservice.games import Period, Game
from modelservice.games import subscribe, register

from .runmodel import divide, save_decision


class DivPeriod(Period):
    @register
    async def submit_decision(self, operand, **kwargs):
        """
        Receives the operand played and saves as a ``Decision``.
        If decisions for both roles have been saved,
        runs the model saving the ``Result``.
        """
        # Call will prefix the ROOT_TOPIC
        # "world.simpl.sims.div.model.period.1.submit_decision"

        for k in kwargs:
            self.session.log.info("submit_decision: Key: {}".format(k))

        user = kwargs['user']
        runuser = self.game.get_scope('runuser', user.runuser.pk)
        role = runuser.role

        role_name = role.json["name"]
        if role_name == "Divisor" and float(operand) == 0:
            return "Cannot divide by zero"

        await save_decision(self.pk, role.pk, operand)
        self.session.log.info(
            "submit_decision: saved decision for role {}".format(role_name))

        #pause while the scopes update
        await asyncio.sleep(0.01)

        if len(self.decisions) == 2:
            await divide(self.scenario.pk, )
            self.session.log.info("submit_decision: saved result")

        return 'ok'


Game.register('div', [
    DivPeriod,
])

NOTE: If you want to use a filename other than games.py you must ensure the file is imported somewhere, usually in a __init__.py somewhere for the @game decorator to find and register your game into the system.

In the div-model directory create a Dockerfile file with the following contents:

FROM gladiatr72/just-tini:latest as tini

FROM revolutionsystems/python:3.6.9-wee-optimized-lto

ENV PYTHONDONTWRITEBYTECODE=true
ENV PYTHONUNBUFFERED 1
ENV PYTHONOPTIMIZE TRUE

RUN apt-get update &&\
    apt-get install -y gcc g++ libsnappy-dev\
    && pip install --upgrade pip ipython ipdb\
    && apt-get -y autoremove \
    && rm -rf /var/lib/apt/lists/* /usr/share/man /usr/local/share/man /tmp/*

RUN mkdir -p /code
COPY --from=tini /tini /tini

WORKDIR /code
ADD ./requirements.txt /code/

RUN pip install -r requirements.txt

ADD . /code/

ENV PYTHONPATH /code:$PYTHONPATH

EXPOSE 8080

ENTRYPOINT ["/tini", "--"]

CMD /code/manage.py run_modelservice --loglevel=debug

LABEL Description="Image for div-model" Vendor="Simpl" Version="0.1.0"

In the div-model directory, create a docker-compose.yml file with the following contents:

version: '3'

services:
  model.backend:
    build:
      context: .
    networks:
      - simpl
    volumes:
      - .:/code
    ports:
      - "8080:8080"
    command: /code/manage.py run_modelservice --loglevel=info 
    environment:
      - DJANGO_SETTINGS_MODULE=div_model.settings
      - SIMPL_GAMES_URL=http://api:8000/apis/
      - CALLBACK_URL=http://model.backend:8080/callback
    stop_signal: SIGTERM

networks:
  simpl:
    external:
      name: simpl-games-api_simpl

Create a Docker image of div-model and run it:

$ docker-compose up

The service will come up, but fail to complete initializing because the div game does not yet exist.

Now open a separate terminal and create a shell into the div-model container by running:

$ docker-compose run --rm model.backend bash

Once you are in the container shell, run your command:

$ export DJANGO_SETTINGS_MODULE=div_model.settings
$ ./manage.py create_default_env
$ exit
$ docker-compose down

Return the to terminal where you ran docker-compose up and run:

$ docker-compose up

By default the service will bind to 0.0.0.0:8080.

This concludes the tutorial on building a multi-player game model service. A completed example implementation is available at https://github.com/simplworld/simpl-div-model that uses the game slug simpl-div.

You can now head over to the Multi-player Game Frontend tutorial.