Card Stories - Webservice Documentation

From Farsides Wiki

Jump to: navigation, search

Contents

[edit] Game logic

Workflow of communication between the client (JS) and the webservice (Python). This is not an exact description of the protocol, but it should help you to get an idea of how the JS client and the webservice communicate.

  1. A user is identified by a number (in the 20 to 30 range below).
  2. The creator of the game choses one card out of 36 and sends the action=create&owner_id=25&card=1&sentence=wtf message which returns the newly created game identifier {'game_id': 101}
  3. The newly created game is in the 'invitation' state and up to five players can join by sending the action=participate&player_id=26&game_id=101 message which returns {} on success or {'error': 'error message'} if it fails.
  4. The player then asks for the state of the game and is returned his own cards in "self" (see long polling)
  5. The player then picks the card that is closer to the sentence (wtf) by sending a action=pick&player_id=26&game_id=101&card=10 message.
  6. The player can send the message more than once to change the value of the picked card.
  7. The game owner decides to move to the voting phase by sending the action=voting&owner_id=25&game_id=101 message.
  8. The game is now in the 'vote' state and each player who chose to participate can vote for one of the cards picked by the owner or the other players by sending the message action=vote&player_id=26&game_id=101&card=2 where vote is the id of the card [0-6].
  9. The player can send the message more than once to change the vote.
  10. The game owner decides to move to the voting phase by sending the action=complete&owner_id=25&game_id=101 message. The game is now in the 'complete' state and the winners are calculated as follows:
    • The owner wins if at least one of the players guesses right, but not all of them do. Then the winners are the owner and the players who guessed right.
    • If the owner loses, all the other players win.

[edit] Player authentication

[edit] Event notifications

To notify plugins or listen()ers of an event, the notify() and touch() methods are used.

listen() returns a deferred that will be called when a) the service is started (type start), b) the service is stopped (type stop), c) a game is deleted (type delete), d) a game has changed (type change). The result argument of the callback is a map with a “type” member set as explained above. When the type is either “delete” or “change”, it the “game” member is the CardstoriesGame object to which the even relates. The “detail” member further describes the event when the “type” is “change” or it is set to None when the “type” is “delete”.

def notify(self, result):
        if hasattr(self, 'notify_running'):
            raise UserWarning, 'recursive call to notify'
        self.notify_running = True
        listeners = self.listeners
        self.listeners = []
        def error(reason):
            reason.printDetailedTraceback()
            return True
        for listener in listeners:
            listener.addErrback(error)
            listener.callback(result)
        del self.notify_running

This would not be enough for a listener to figure out what event triggered the notification. Enough information need to be included in the notification argument so that the listener knows what happened.

self.touch(type = 'participate', player_id = player_id)
self.touch(type = 'voting')
self.touch(type = 'pick', player_id = player_id, card = card)
self.touch(type = 'vote', player_id = player_id, vote = vote)
self.touch(type = 'complete')
self.touch(type = 'invite', invited = invited)

[edit] Long polling

A specific implementation of long polling was chosen over a more generic one. The full state of a given game can be retrieved at any time by the client, as well as the lobby / list of games for a given player. Instead of using long polling to simulate a generic bidirectional communication channel, it is applied to an event notification mechanism telling the client that it needs to request information from the server.

The general idea is that the client sends a request asking the server if anything happened after a given timestamp, in milliseconds. For instance:

$ curl 'http://localhost:4923/resource?action=poll&player_id=loic&modified=1302779947401'
{"game_id": [16], "player_id": [1], "modified": [1302780247404]}

asks if anything changed for player loic after 1302779947401. The call will block because there is nothing new. At 1302780247404 something happens and the call returns with the new timestamp. It also hints that the event is related to game_id number 16. When receiving this answer, the client is expected to immediately issue a new request like:

$ curl 'http://localhost:4923/resource?action=poll&player_id=loic&modified=1302780247404'

It will block again and timeout if nothing happens for more than –poll-timeout seconds (as defined in tap.py). When it timesout, the map returned to the client contains the keyword timeout as in the following example:

{"player_id": [1], "modified": [1302780247404], "timeout": [1302780278453]}

The logic of this notification queue was implemented and tested in the pollable class. It contains two methods: poll that returns a deferred that will fire when the touch method is called triggers all the deferred created by previous calls to the poll method.

[edit] CardstoriesGame and CardstoriesPlayer objects

Representation of the player and the game. The CardstoriesPlayer class is simple minded and derived from the pollable class. The CardstoriesGame object does a little more: it keeps an in core image of the game players and the pending invitations, if applicable. Each method of the CardstoriesGame class that has a side effect (such as inviting new players or picking a card) will call the touch method of the pollable base class. It allows to implement the following use case:

  1. An incoming poll request is received for game number 20
  2. Nothing happened yet: the poll method is called on game 20 to acquire a deferred that will fire when something happens
  3. A callback is attached to the deferred : it will return the incoming poll request when the deferred is fired
  4. Later on, a player on game 20 picks a card
  5. As a consequence, the touch method is called on game 20 and triggers the deferred previously acquired via the poll method
  6. The previously attached callback is called and completes the request that returns to the client, informing it that something needs attention

[edit] CardstoriesService object

It is a container of all games, delegates the game logic to the CardstoriesGame class, dispatches the requests after checking for the required arguments to the relevant game and implements the lobby / list of games.

The poll action is handled with the following semantic:

  • If player_id is set, return when a game in which the designated player is involved has been modified. It is best suited when displaying the list of game in which a player is involved.
  • If game_id is set, return when the designated game changes state. It is best suited when displaying a single game.

[edit] Objects life cycle

For each game that is not in the complete state, an in-core representation of the game (CardstoriesGame) is created and persists until the game moves to the complete state. For consistency, a short lived in-core representation is created when a client requests information about a completed game. It is destroyed as soon as the request is answered.

The CardstoriesPlayer objects are only created when a poll request is made with a player_id argument. They do not exist if no poll is waiting on them, even if a player is involved in a number of games. There would be no reason to keep it up to date. A CardstoriesPlayer object will be destroyed after –poll-timeout * 2 seconds unless it receives a new poll request.

When the CardstoriesService is started, the list of games in progress and their associated players are read from the database and the corresponding in-core objects created. For each CardstoriesGame object, the CardstoriesService object asks to be notified (registering the poll_notify_player callback with the poll function) whenever its state is changed. When it knows that a CardstoriesGame changed, it gets the list of players involved (method get_players) and calls the touch method for each corresponding CardstoriesPlayer instance it will find in the self.players map.

def poll_notify_players(self, args):
    if args == None:
        return False
    game_id = int(args['game_id'][0])
    if not self.games.has_key(game_id):
        return False
    game = self.games[game_id]
    for player_id in game.get_players():
        if self.players.has_key(player_id):
            self.players[player_id].touch(args)
    d = game.poll(args)
    d.addCallback(self.poll_notify_players)
    return True

[edit] handle()

The handle function dispatches the incoming requests to the appropriate function implementing it:

def handle(self, args):
    try:
        action = args['action'][0]
        if action in self.ACTIONS:
            d = getattr(self, action)(args)
            def error(reason):
                if reason.type is UserWarning:
                    return {'error': reason.getErrorMessage()}
                else:
                    return reason
            d.addErrback(error)
            return d
        else:
            raise UserWarning('action ' + action + ' is not among the allowed actions ' + ','.join(self.ACTIONS))
    except UserWarning, e:
        return defer.succeed({'error': e.args[0]})

It is surprisingly complex. The idea is that it catches all UserWarning exceptions and send them back to the caller as a JSON object of the form

{'error': 'the error message'}

while the other exceptions are not caught. However, UserWarning exceptions reach the handle function in two different ways:

  • within a deferred object if it happens within a deferred callback, either explicitly or implicitly because a function is decorated with a defer.inlineCallbacks
  • as a regular exception that must be caught

Each function exposed by the webservice checks if the required arguments are provided with:

@staticmethod
def required(args, method, *keys):

primarily because it helps writing the tests for the handle function.

[edit] game()

The game function returns all the relevant game information at once. The information returned depends on the player_id argument. It will reveal the information known to the player and hide the informations from the other players.

[edit] Lobby

The lobby function is accessed with action=lobby in the game URL. For instance:

action=lobby&player_id=myself&my=true&in_progress=false

will return all the games in which myself is involved and that are complete. The lobby function is designed to be used for all the list of games. It returns a map such as

{
    'games': [(101, 'SENTENCE2', 'invitation', 1, '2011-05-01'),
              (100, 'SENTENCE1', 'invitation', 0, '2011-02-01')],
    'win': {101: 'n'}
}

Where games is an array of games in which the first element is the game identifier, the second is the sentence written by the game author, the third is 1 if the player who sent the command (as listed in the player_id= argument) is the author of the game or 0 if (s)he is not, the fourth is the date at which the game was created. The list of rows is sorted by creation date, most recent first.

The wins field is a map showing 'y' if the player who sent the command won the game or 'n' if he did not. This is also indicates which games the user participates in.

The following describes the semantic of the all the parameters combinations. In each paragraph, player_id refers to the mandatory value of the player_id parameter.

my=true&in_progress=true

Shows the games that are not in the complete state and where player_id is either participating (because (s)he is listed in the player2game table) or invited (because (s)he is listed in the invitations table). For each game game_id in which player_id is participating, the wins maps wins[game_id] to ‘n’. For each game in which player_id is invited, there is no entry in the wins map.

my=true&in_progress=false

Shows the games that are in the complete state and where player_id participated. For each game game_id the wins maps wins[game_id] to ‘n’ if the player lost or ‘y’ if (s)he won.

my=false&in_progress=true

Shows the games that are not in the complete state. For each game game_id in which player_id is participating (because (s)he is listed in the player2game table), the wins maps wins[game_id] to ‘n’. There is no entry in wins for the games in which player_id is invited or is not participating.

my=false&in_progress=false

Shows the games that are in the complete state. For each game game_id in which player_id participated ( (because (s)he is listed in the player2game table) the wins maps wins[game_id] to ‘n’ if the player lost or ‘y’ if (s)he won.

[edit] Invitation state

Invitations can be sent immediately after the author of the game defined it. The invite() function is called with action=invite followed by multiple player_id. For instance:

action=invite&owner_id=myself&game_id=5&player_id=firstfriend&player_id=secondfriend

A table stores the pending invitations for a given game:

c.execute(
    "CREATE TABLE invitations ( "
    "  player_id INTEGER, "
    "  game_id INTEGER"
    "); ")

The authentication module can translate multiple player ids (i.e. multiple player_id parameters). A pending invitation is canceled when a user chose to participate in the game. All pending invitations are canceled when the game goes to the vote state.

[edit] Author view

The card picked by each player is included in the game state information returned by the web service, as shown by the following lines from service.py

if player[2] != None and ( player[0] == player_id or owner_id == player_id ):
    picked = ord(player[2])
else:
    picked = None
    ...
    players.append([ player[0], vote, player[4], picked, player_cards ])

This information is used to reveal the board to the game author and let her/him decide when to go to the voting phase.

[edit] Invitation emails

During the invitation phase the author of the game can type the names of the players (s)he would like to invite. The actual sending of emails is implemented in the email plugin.

[edit] Cancel and autoplay

The –game-timeout option specifies that a game left unattended during more than 24h should either be canceled or proceed to the next state. At present the author of the game is required to act to go from the invitation state to the vote state and then to the complete state. The same functions used for the timeout could be used to automate these steps.

[edit] Game cancelation

The cancel function sets the game state to cancel

yield self.service.db.runOperation("UPDATE games SET state = 'canceled' WHERE id = ?", [ self.get_id() ])

and removes all the players:

yield self.cancelInvitations()
yield self.service.db.runOperation("DELETE FROM player2game WHERE game_id = ?", [ self.get_id() ])

from the database. It then notifies the clients interested in the game, before removing the list of players from the in core representation:

self.destroy() # notify before altering the in core representation
self.invited = []
self.players = []

The service.py holding all games is notified of the destruction of the game and removes it from its registry.

def game_notify(self, args, game_id):
    if args == None:
        del self.games[game_id]
        return False

[edit] Players leaving the game

When the game is canceled, all players are forced to leave the game. The implementation allow a later use by the user, should he decide to leave a game:

@defer.inlineCallbacks
def leave_api(self, args):
    self.service.required(args, 'leave', 'player_id')
    player_ids = args['player_id']
    deleted = yield self.leave(player_ids)
    self.touch()
    defer.returnValue({'deleted': deleted})

It is not exposed in the webservice at the moment. When removing the users as a side effect of canceling a game, the touch function is only called once to reduce the number of notifications.

[edit] Coding hints

  • files must be valid python identifiers (ie test_XXX and not test-XXX)
  • the trial command line is not used, python-coverage is used instead because it gives a more readable coverage report
  • there should be no + between multiline strings
  • generators must be used instead of map / lambda

[edit] Running behind nginx

When running the webservice for debug purposes:

root@cardstories:/var/www/cardstories# PYTHONPATH=.:etc/cardstories twistd --nodaemon cardstories --port 4923 --db /tmp/cardstories.sqlite

it was made available from the outside by adding the following proxypass to the nginx running on the development machine:

location /resource {
        proxy_pass   http://127.0.0.1:4923;
}

For long poll to work with nginx, you need to add the following (with 600s being --poll-timeout * 2):

proxy_read_timeout 600s;
proxy_send_timeout 600s;
Personal tools