solo mode for cardstories (part 1/2)

A solo mode was specified and an implementation based on the proposed plugin system has been drafted.

The algorithm is approximately:

  • the player sends an action=solo ajax request
  • a plugin intercepts the request and copies a completed game at random
  • the player takes the seat of a random player in the completed game
  • the game is set to invitation state
  • when the player picks a card, the plugin goes to vote state immediately
  • when the player vots for a card, the plugin goes to complete state and resolves the game immediately

Although it would make sense to log the game events and use them to replay the action, that would be invisible to the user and lead to a complex implementation. Instead, the proposed implementation relies on the fact that the game history is stored in full in the database, not as a sequence of events but as a complete set of data. The only information missing is how long it took to each player to figure pick or vote and how many times they changed their mind. However, these information are irrelevant in solo mode.

class Plugin:

    def __init__(self, service):
        self.service = service
        self.id2info = {}
        self.handle(None)

The handle function listens to all incoming events. The reference to service is stored because the plugin will use SQL statements to create the games and needs the database access objects from service. The id2info is a data structure that contains information about the original game that will allow the plugin to replace the card picked by the older player with the one picked by the solo player.

    def name(self):
        return 'solo'

    @defer.inlineCallbacks
    def handle(self, event):
        if event == None:
            pass
        elif event['type'] == 'change':
            if self.id2info.has_key(event['type']['game'].id):
                game = event['type']['game']
                info = self.id2info[game.id]:
                details = event['type']['details']
                if details['type'] == 'pick' and details['player_id'] == info['player_id']:
                    yield game.voting(info['owner_id'])

game.voting shuffles the board and discard invited players, which is not necessary in the context of the solo plugin. It is not harmful either since the game logic does not rely on the actual position of a card in the board.

                elif details['type'] == 'vote' and details['player_id'] == info['player_id']:
                    yield game.complete(info['owner_id'])
                    del self.info2id[game.id]

        self.service.listen().addCallback(self.handle)
        defer.returnValue(True)

The handle function is registered again so that it is called when the next event occurs. Since it is registered after calling voting or complete it will not be recursively called as a side effect of the touch function called from voting or complete.

    def copy(self, transaction, player_to):
        #
        # get the id of a completed game at random
        #
        transaction.execute("SELECT id, owner_id, board FROM games WHERE state = 'complete' ORDER BY RANDOM() LIMIT 1")
        ( game_from, owner_id, board ) = transaction.fetchall()[0]
        #
        # copy the game
        #
        transaction.execute("INSERT INTO games (owner_id, players, sentence, cards, board, created) SELECT owner_id, players, sentence, cards, board, DATETIME('NOW')  FROM games WHERE id = ?", [ game_from ])
        game_to = transaction.lastrowid
        #
        # copy the players
        #
        transaction.execute("INSERT INTO player2game (player_id, game_id, cards, picked, vote) SELECT (player_id, ?, cards, picked, vote) FROM player2game WHERE game_id = ?", [ game_to, game_from ])
        #
        # select a player at random
        #
        transaction.execute("SELECT player_id, picked FROM player2game WHERE game_id = ? AND player_id != ? ORDER BY RANDOM() LIMIT 1", [ game_to, owner_id ])
        ( player_from, picked ) = transaction.fetchall()[0]
        #
        # replace the selected player with the player willing to play solo
        #
        transaction.execute("UPDATE player2game SET player_id = ?, picked = NULL, vote = NULL) WHERE game_id = ? AND player_id = ? ", [ player_to, game_to, player_from ])
        return { 'game_id': game_to,
                 'owner_id': owner_id }

Creating a solo game is done by duplicating the database records for a completed game and replace one of the players at random.

    @defer.inlineCallbacks
    def preprocess(self, result, request):
        if request.args.has_key(action) and request.args['action'][0] == 'solo':
            del request.args['action']
            player_id = request.args['player_id'][0]
            game_id = yield self.db.runInteraction(self.copy, player_id)
            self.id2info[game_id] = { 'player_id': player_id }
            game = CardstoriesGame(self.service, game_id)
            yield self.db.runInteraction(game.load)
            self.service.game_init(game)
            defer.returnValue({'game_id': game_id})
        else:
            defer.returnValue(result)

Once the game is created in the database (with the copy method), the load function is called to create its in-core equivalent and register it to the CardstoriesService instance with the game_init method. This is the same logic that is used when launching the cardstories daemon : if it discovers that some games in the database are not in complete state, it will call the service.load method.
By deleting the action entry of the incoming request

            del request.args['action']

the plugin relies on the fact that the handler is a noop if the action is missing. It replaces it completly and return the game identity in the same way action=create would:

defer.returnValue({'game_id': game_id})