solo mode for cardstories (part 2/2)

The solo mode that was drafted yesterday was implemented. A bug preventing asynchronous actions in plugins was fixed.

solo mode

The creation of the game using SQL statements to copy a game chosen at random was finalized with very little changes compared to the drafted. The preprocess function that intercepts the action=solo string in the query begins with the deletion of the action keyword so that it is ignored by the regular handler. After creating the solo game, some information is stored in the id2info table so that it can be used by the event listener. The game just created is loaded from the database using the same functions that are used to implement the CardstoriesGame::load method at bootstrap.

    @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]
            info = yield self.service.db.runInteraction(self.copy, player_id)
            self.id2info[info['game_id']] = info
            game = CardstoriesGame(self.service, info['game_id'])
            yield self.service.db.runInteraction(game.load)
            self.service.game_init(game)
            defer.returnValue({'game_id': info['game_id']})
        else:
            defer.returnValue(result)

The solo mode in the JavaScript side is a button that will send the action=solo ajax request and ask for the game to be redisplayed, therefore asking the player to pick a card:

        solo: function(player_id, root) {
            var $this = this;
            var success = function(data, status) {
                if('error' in data) {
                    $this.error(data.error);
                } else {
                    $this.setTimeout(function() { $this.reload(player_id, data.game_id, root); }, 30);
                }
            };
            $this.ajax({
                async: false,
                timeout: 30000,
                url: $this.url + '?action=solo&player_id=' + player_id,
                type: 'GET',
                dataType: 'json',
                global: false,
                success: success,
                error: $this.xhr_error
            });
        },

The game after this initial step is identical to the regular multiplayer workflow.
The event listener is a little different than what was initialy drafted. Because it would be confusing to recursively act on a game, the action is delayed for 1/10 seconds.

       def voting():
           return game.voting(info['owner_id'])
        reactor.callLater(0.01, voting)

When the listener is called, the CardstoriesService is in the process of notifying a number of functions about what just happened (i.e. picking a card in this case). If the function was to modify the game state using game.voting, the other functions would be presented a game state that does not match the notification. In addition, such a recursive game change would trigger additional notifications and add to the confusion. A safeguard is added to crash when such a recursion happens, assuming it is better to fail than to ignore a source of internal data corruption:

        modified = game.get_modified()
        yield self.notify({'type': 'change', 'game': game, 'details': args})
        for player_id in game.get_players():
            if self.players.has_key(player_id):
                yield self.players[player_id].touch(args)
        #
        # the functions being notified must not change the game state
        # because the behavior in this case is undefined
        #
        assert game.get_modified() == modified

asynchronous actions in plugins

There are two notifications system in the core of cardstories : poll.py which is used by the JavaScript client to react immediately when something happens and the listen function from service.py which is used by plugins to get a feed of all events occuring in the server. Both of them were implemented with a function that expected all notified functions to perform synchronously. They did not have the option to return a deferred that would allow them to complete at a later time.
Both notification systems and their associated tests were updated to take into account the fact that the notified function may return a deferred. It meant replacing

        for listener in listeners:
            listener.addErrback(error)
            listener.callback(result)
        del self.notify_running

with

       d = defer.DeferredList(listeners, consumeErrors = True)
        for listener in listeners:
            listener.addErrback(error)
            listener.callback(result)
        del self.notify_running
        return d

and changing each calling function in the call graph to handle the return value appropriately. The scope of the change was limited because most of the higher level functions already expected at least one of their statement to return a deferred.

plugin development

In order to isolate the development environment, the tests for the solo plugin were moved to plugins/solo instead of being added to the tests directory with all the other files. This only applies to server side code as there is no convenient way to separate the javascript and html code related to the plugin. The tests for the plugin will be run from the top level directory with

make -f maintain.mk check

or directly from the plugins/solo directory with

make check