Build the Single-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 calc-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/calc-model3.6
$ source ~/venv/calc-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 calc_model
$ mv calc_model calc-model

Change to the project folder:

$ cd calc-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

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 calc_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.calc'

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 single-player Game in which each player has a Scenario that can advance multiple periods.

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

class Model(object):
    """
    The model adds an operand to the previous total and returns the result.
    """

    def step(self, operand, prev_total=0.0):
        """
        Parameters:
            operand - current period's decision
            prev_total - the calculated total from the previous period
        Returns new total
        """
        return operand + prev_total

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_first_step(self):
        m = Model()
        total = m.step(5)
        self.assertEqual(total, 5)

    def test_increase_step(self):
        m = Model()
        total = m.step(5, 3)
        self.assertEqual(total, 8)

    def test_decrease_step(self):
        m = Model()
        total = m.step(5, -2.5)
        self.assertEqual(total, 2.5)

Run your unit test:

$ export DJANGO_SETTINGS_MODULE=calc_model.settings
$ py.test

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

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.

Finally, 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 Calc game default run...', ' done')
    game = games_client.games.get_or_create(slug='calc')
    runs = games_client.runs.filter(game=game.id)
    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 Calc game.
    Create a "default" Calc run.
    Set the run phase to "Play".
    Add 1 leader ("leader") to the run
    Add 2 players ("s1", "s2") to the run.
    Add a scenario and period 1 for each player.
    """

    # 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 a Game
    game = games_client.games.get_or_create(
        name='Calc',
        slug='calc'
    )
    echo('getting or creating game: ', game.name)

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

    # Add run with 2 players ready to play
    run = add_run(game, 'default', 2, play_phase, games_client)

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


def add_run(game, run_name, user_count, phase, games_client):
    # Create or get the 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)

    fac_user = games_client.users.get_or_create(
        password='leader',
        first_name='CALC',
        last_name='Leader',
        email='[email protected]',
    )
    echo('getting or creating user: ', fac_user.email)

    fac_runuser = 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)

    for n in range(0, user_count):
        user_number = n + 1
        # Add player to run
        add_player(user_number, run, games_client)

    return run


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

    username = 's{0}'.format(user_number)
    first_name = 'Student{0}'.format(user_number)
    email = '{0}@calc.edu'.format(username)

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

    runuser = games_client.runusers.get_or_create(
        user=user.id,
        run=run.id,
        defaults={"role": None}
    )
    echo('getting or creating runuser for user: ', user.email)

    add_runuser_scenario(runuser, games_client)


def add_runuser_scenario(runuser, games_client):
    """Add a scenario named 'Scenario 1' to the runuser"""

    scenario = games_client.scenarios.get_or_create(
        runuser=runuser.id,
        name='Scenario 1',
    )
    click.echo('getting or creating runuser {} scenario: {}'.format(
        runuser.id,
        scenario.id))

    period = games_client.periods.get_or_create(
        scenario=scenario.id,
        order=1,
    )
    click.echo('getting or creating runuser {} period 1 for scenario: {}'.format(
        runuser.id,
        scenario.id))

Every player’s submission will be a Decision saved on the current Period. The model will then produce a Result for the current Period, and the player’s Scenario will step to the next Period.

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

from modelservice.simpl import games_client
from .model import Model


async def save_decision(period_id, decision):
    # add decision to period
    async with games_client as api_session:
        decision = await api_session.decisions.get_or_create(
            period=period_id,
            name='decision',
            data={"operand": decision},
            defaults={"role": None}
        )
        return decision


async def step_scenario(scenario_id):
    """
    Step the scenario's current period
    """
    async with games_client as api_session:
        periods = await api_session.periods.filter(scenario=scenario_id,
                                                   ordering='order')
        period_count = len(periods)
        period = periods[period_count - 1]

        operand = 0.0
        period_decisions = await api_session.decisions.filter(period=period.id)
        if len(period_decisions) > 0:
            operand = float(period_decisions[0].data["operand"])

        prev_total = 0.0
        if period_count > 1:
            prev_period = periods[period_count - 2]
            prev_period_results = \
                await api_session.results.filter(period=prev_period.id)
            if len(prev_period_results) > 0:
                prev_total = float(prev_period_results[0].data["total"])

        # step model
        model = Model()
        total = model.step(operand, prev_total)
        data = {"total": total}

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

        # prepare for next step by adding a new period
        next_period_order = period.order + 1
        next_period = await api_session.periods.get_or_create(
            scenario=scenario_id,
            order=next_period_order,
        )
        await next_period.save()

        return next_period.id

In your game app module, create a file called games.py with the following content:

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

from .runmodel import step_scenario, save_decision


class CalcPeriod(Period):
    @register
    async def submit_decision(self, operand, **kwargs):
        """
        Receives the operand played and stores as a ``Decision`` then
        steps the model saving the ``Result``. A new ``Period`` is added to
        scenario in preparation for the next decision.
        """
        # Call will prefix the ROOT_TOPIC
        # "world.simpl.sims.calc.model.period.1.submit_decision"

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

        await save_decision(self.pk, operand)
        self.session.log.info("submit_decision: saved decision")

        await step_scenario(self.scenario.pk)
        self.session.log.info("submit_decision: stepped scenario")
        
        return 'ok'


Game.register('calc', [
    CalcPeriod,
])

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 calc-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 calc-model" Vendor="Simpl" Version="0.1.0"

In the calc-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=calc_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 calc-model and run it:

$ docker-compose up

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

Once the service has come up, 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=calc_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 single-player game model service. A completed example implementation is available at https://github.com/simplworld/simpl-calc-model that uses the game slug simpl-calc.

You can now head over to the Single-player Game Frontend Tutorial.