Merge: Nitrpg: add achievements
authorJean Privat <jean@pryen.org>
Thu, 26 Feb 2015 23:52:38 +0000 (06:52 +0700)
committerJean Privat <jean@pryen.org>
Thu, 26 Feb 2015 23:52:38 +0000 (06:52 +0700)
Not much achievements in this PR:

* Counting issues and rewarding at 1, 100, 1000
* Counting PR and rewarding at 1, 100, 1000
* Counting commits and rewarding at 1, 100, 1000, 10000
* Two achievements based on regexp, just for demo: Nitdoc and FFI

Partially fixes #1165: I fixed the typo in "complaint" and the first playre to unlock the achievement. But the related issue stills not linked for now.

Démo: http://nitlanguage.org/rpg/games/privat/nit (sorry, I had to reset the scores...)

Pull-Request: #1171
Reviewed-by: Jean Privat <jean@pryen.org>
Reviewed-by: Lucas Bajolet <r4pass@hotmail.com>

contrib/nitrpg/src/achievements.nit [new file with mode: 0644]
contrib/nitrpg/src/listener.nit
contrib/nitrpg/src/statistics.nit
contrib/nitrpg/src/templates/panels.nit
contrib/nitrpg/src/templates/templates.nit
contrib/nitrpg/src/templates/templates_base.nit
contrib/nitrpg/src/templates/templates_events.nit
contrib/nitrpg/src/web.nit

diff --git a/contrib/nitrpg/src/achievements.nit b/contrib/nitrpg/src/achievements.nit
new file mode 100644 (file)
index 0000000..625bfc2
--- /dev/null
@@ -0,0 +1,402 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Copyright 2014-2015 Alexandre Terrasa <alexandre@moz-code.org>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# `nitrpg` achievements.
+#
+# Players can unlock achievements by performing remarkable actions on the repo.
+# Achievements are rewarded by nitcoins.
+module achievements
+
+import events
+import statistics
+
+redef class GameEntity
+
+       # Register a new achievement for this game entity.
+       #
+       # Saves the achievement in game data.
+       # Do nothing is the achievement is already registered.
+       #
+       # TODO should update the achievement?
+       fun add_achievement(achievement: Achievement) do
+               var key = self.key / achievement.key
+               if game.store.has_key(key) then return
+               stats.inc("achievements")
+               achievement.save_in(self)
+               save
+       end
+
+       # Load the event from its `id`.
+       #
+       # Looks for the event save file in game data.
+       # Returns `null` if the event cannot be found.
+       fun load_achievement(id: String): nullable Achievement do
+               var key = self.key / "achievements" / id
+               if not game.store.has_key(key) then return null
+               var json = game.store.load_object(key)
+               return new Achievement.from_json(game, json)
+       end
+
+       # List all events registered in this entity.
+       #
+       # This list is reloaded from game data each time its called.
+       #
+       # To add events see `add_event`.
+       fun load_achievements: MapRead[String, Achievement] do
+               var res = new HashMap[String, Achievement]
+               var key = self.key / "achievements"
+               if not game.store.has_collection(key) then return res
+               var coll = game.store.list_collection(key)
+               for id in coll do
+                       res[id.to_s] = load_achievement(id.to_s).as(not null)
+               end
+               return res
+       end
+end
+
+# Achievements are rewarded by `nitcoins`.
+#
+# An achievement represents a notable action performed by a `Player`.
+# Player that `unlock` achievements are rewarded by nitcoins.
+class Achievement
+       super GameEntity
+
+       redef var key is lazy do return "achievements" / id
+
+       redef var game
+
+       # Uniq ID for this achievement.
+       var id: String
+
+       # Name of this achievement.
+       var name: String
+
+       # Description of the achievement.
+       var desc: String
+
+       # Reward that this achievement give in nitcoins.
+       var reward: Int
+
+       # Is this achievement unlocked by somebody?
+       var is_unlocked: Bool is lazy do return not load_events.is_empty
+
+       # Init `self` from a `json` object.
+       #
+       # Used to load achievements from storage.
+       init from_json(game: Game, json: JsonObject) do
+               self.game = game
+               id = json["id"].to_s
+               name = json["name"].to_s
+               desc = json["desc"].to_s
+               reward = json["reward"].as(Int)
+       end
+
+       redef fun to_json do
+               var json = super
+               json["id"] = id
+               json["name"] = name
+               json["desc"] = desc
+               json["reward"] = reward
+               return json
+       end
+end
+
+redef class Player
+
+       # Is `a` unlocked for this `Player`?
+       fun has_achievement(a: Achievement): Bool do
+               return load_achievement(a.id) != null
+       end
+
+       # Unlocks an achievement for this Player based on a GithubEvent.
+       #
+       # Register the achievement and adds the achievement reward to the player
+       # nitcoins.
+       #
+       # Do nothing is this player has already unlocked the achievement.
+       #
+       # TODO: add abstraction so achievements do not depend on GithubEvent.
+       fun unlock_achievement(a: Achievement, event: GithubEvent) do
+               if has_achievement(a) then return
+               nitcoins += a.reward
+               add_achievement(a)
+               trigger_unlock_event(a, event)
+       end
+
+       # Create a new event that marks the achievement unlocking.
+       fun trigger_unlock_event(achievement: Achievement, event: GithubEvent) do
+               var obj = new JsonObject
+               obj["player"] = name
+               obj["reward"] = achievement.reward
+               obj["achievement"] = achievement.id
+               obj["github_event"] = event.json
+               var ge = new GameEvent(game, "achievement_unlocked", obj)
+               add_event(ge)
+               game.add_event(ge)
+               achievement.add_event(ge)
+       end
+end
+
+# `GameReactor` dedicated to achievements unlocking.
+interface AchievementReactor
+       super GameReactor
+
+       # Unic ID of the achievement this reactor unlocks.
+       fun id: String is abstract
+
+       # Name of the achievement this reactor unlocks.
+       fun name: String is abstract
+
+       # Description of the achievement this reactor unlocks.
+       fun desc: String is abstract
+
+       # Amount of nitcoins rewarded for unlocking the achievement.
+       fun reward: Int is abstract
+
+       # Return a new instance of the achievement to unlock.
+       fun new_achievement(game: Game): Achievement do
+               var achievement = new Achievement(game, id, name, desc, reward)
+               game.add_achievement(achievement)
+               return achievement
+       end
+end
+
+#####################
+### Issues
+#####################
+
+# Unlock achievement after X issues.
+#
+# Used to factorize behavior.
+abstract class PlayerXIssues
+       super AchievementReactor
+
+       # Number of PR required to unlock the achievement.
+       var threshold: Int is noinit
+
+       redef fun react_event(game, event) do
+               if not event isa IssuesEvent then return
+               if not event.action == "opened" then return
+               var player = event.issue.user.player(game)
+               if player.stats["issues"] == threshold then
+                       var a = new_achievement(game)
+                       player.unlock_achievement(a, event)
+               end
+       end
+end
+
+# Player open his first issue.
+class Player1Issue
+       super PlayerXIssues
+
+       redef var id = "player_1_issue"
+       redef var name = "First complaint"
+       redef var desc = "Open your first issue."
+       redef var reward = 10
+       redef var threshold = 1
+end
+
+# Player open 100 issues.
+class Player100Issues
+       super PlayerXIssues
+
+       redef var id = "player_100_issues"
+       redef var name = "Mature whiner"
+       redef var desc = "Open 100 issues in the game."
+       redef var reward = 100
+       redef var threshold = 100
+end
+
+# Player open 1 000 issues.
+class Player1KIssues
+       super PlayerXIssues
+
+       redef var id = "player_1000_issues"
+       redef var name = "You, sir, complain a lot"
+       redef var desc = "Open 1000 issues in the game."
+       redef var reward = 1000
+       redef var threshold = 1000
+end
+
+# Player open an issue about nitdoc.
+class IssueAboutNitdoc
+       super AchievementReactor
+
+       redef var id = "issue_about_nitdoc"
+       redef var name = "Say nitdoc again, I double dare you!"
+       redef var desc = "Open an issue with \"nitdoc\" in the title."
+       redef var reward = 10
+
+       redef fun react_event(game, event) do
+               if not event isa IssuesEvent then return
+               if not event.action == "opened" then return
+               var player = event.issue.user.player(game)
+               var re = "nitdoc".to_re
+               re.ignore_case = true
+               if event.issue.title.has(re) then
+                       var a = new_achievement(game)
+                       player.unlock_achievement(a, event)
+               end
+       end
+end
+
+# Player open an issue about FFI.
+class IssueAboutFFI
+       super PlayerXIssues
+
+       redef var id = "issue_about_ffi"
+       redef var name = "Polyglot what?"
+       redef var desc = "Open an issue with `ffi` in the title."
+       redef var reward = 10
+
+       redef fun react_event(game, event) do
+               if not event isa IssuesEvent then return
+               if not event.action == "opened" then return
+               var player = event.issue.user.player(game)
+               var re = "\\bffi\\b".to_re
+               re.ignore_case = true
+               if event.issue.title.has(re) then
+                       var a = new_achievement(game)
+                       player.unlock_achievement(a, event)
+               end
+       end
+end
+
+#####################
+### Pull requests
+#####################
+
+# Unlock achievement after X pull requests.
+#
+# Used to factorize behavior.
+abstract class PlayerXPulls
+       super AchievementReactor
+
+       # Number of PR required to unlock the achievement.
+       var threshold: Int is noinit
+
+       redef fun react_event(game, event) do
+               if not event isa PullRequestEvent then return
+               if not event.action == "opened" then return
+               var player = event.pull.user.player(game)
+               if player.stats["pulls"] == threshold then
+                       var a = new_achievement(game)
+                       player.unlock_achievement(a, event)
+               end
+       end
+end
+
+# Open your first pull request.
+class Player1Pull
+       super PlayerXPulls
+
+       redef var id = "player_1_pull"
+       redef var name = "First PR"
+       redef var desc = "Open your first pull request."
+       redef var reward = 10
+       redef var threshold = 1
+end
+
+# Author 100 pull requests.
+class Player100Pulls
+       super PlayerXPulls
+
+       redef var id = "player_100_pulls"
+       redef var name = "100 pull requests!!!"
+       redef var desc = "Open 100 pull requests in the game."
+       redef var reward = 100
+       redef var threshold = 100
+end
+
+# Author 1000 pull requests.
+class Player1KPulls
+       super PlayerXPulls
+
+       redef var id = "player_1000_pulls"
+       redef var name = "1000 PULL REQUESTS!!!"
+       redef var desc = "Open 1000 pull requests in the game."
+       redef var reward = 1000
+       redef var threshold = 1000
+end
+
+#####################
+### Commits
+#####################
+
+# Unlock achievement after X merged commits.
+#
+# Used to factorize behavior.
+abstract class PlayerXCommits
+       super AchievementReactor
+
+       # Number of PR required to unlock the achievement.
+       var threshold: Int is noinit
+
+       redef fun react_event(game, event) do
+               if not event isa PullRequestEvent then return
+               if not event.action == "closed" then return
+               if not event.pull.merged then return
+               var player = event.pull.user.player(game)
+               if player.stats["commits"] == threshold then
+                       var a = new_achievement(game)
+                       player.unlock_achievement(a, event)
+               end
+       end
+end
+
+# Author your first commit in the game.
+class Player1Commit
+       super PlayerXCommits
+
+       redef var id = "player_1_commit"
+       redef var name = "First blood"
+       redef var desc = "Author your first commit in the game."
+       redef var reward = 10
+       redef var threshold = 1
+end
+
+# Author 100 commits.
+class Player100Commits
+       super PlayerXCommits
+
+       redef var id = "player_100_commits"
+       redef var name = "100 commits"
+       redef var desc = "Author 100 commits in the game."
+       redef var reward = 100
+       redef var threshold = 100
+end
+
+# Author 1 000 commits.
+class Player1KCommits
+       super PlayerXCommits
+
+       redef var id = "player_1000_commits"
+       redef var name = "1000 commits!!!"
+       redef var desc = "Author 1000 commits in the game."
+       redef var reward = 1000
+       redef var threshold = 1000
+end
+
+# Author 10 000 commits.
+class Player10KCommits
+       super PlayerXCommits
+
+       redef var id = "player_10000_commits"
+       redef var name = "10000 COMMITS!!!"
+       redef var desc = "Author 10000 commits in the game."
+       redef var reward = 10000
+       redef var threshold = 10000
+end
index d35b169..0e4ba00 100644 (file)
@@ -17,8 +17,8 @@
 # This tool is runned to listen to `Github::Event` and update the game.
 module listener
 
-import statistics
 import reactors
+import achievements
 import github::hooks
 
 # `HookListener` that redirects events to a `Game` instance.
@@ -34,8 +34,14 @@ class RpgHookListener
                # TODO handle verbosity with opts
                game.verbose_lvl = 1
                game.message(1, "Received event {event} for {game.repo.full_name}")
-               for reactor in reactors do reactor.react_event(game, event)
+               for reactor in reactors do
+                       game.message(2, "Apply reactor {reactor} on {event}")
+                       reactor.react_event(game, event)
+               end
        end
+
+       # Register a reactor for this listener.
+       fun add_reactor(reactors: GameReactor...) do self.reactors.add_all reactors
 end
 
 if args.length != 2 then
@@ -51,9 +57,12 @@ var port = args[1].to_i
 
 var api = new GithubAPI(get_github_oauth)
 
-var listener = new RpgHookListener(api, host, port)
-listener.reactors.add new StatisticsReactor
-listener.reactors.add new PlayerReactor
+var l = new RpgHookListener(api, host, port)
+l.add_reactor(new StatisticsReactor, new PlayerReactor)
+l.add_reactor(new Player1Issue, new Player100Issues, new Player1KIssues)
+l.add_reactor(new Player1Pull, new Player100Pulls, new Player1KPulls)
+l.add_reactor(new Player1Commit, new Player100Commits, new Player1KCommits)
+l.add_reactor(new IssueAboutNitdoc, new IssueAboutFFI)
 
 print "Listening events on {host}:{port}"
-listener.listen
+l.listen
index 39c05af..dfdb3ba 100644 (file)
@@ -26,6 +26,9 @@ import counter
 
 redef class GameEntity
 
+       # Statistics for this entity.
+       fun stats: GameStats is abstract
+
        # Load statistics for this `MEntity` if any.
        fun load_statistics: nullable GameStats do
                var key = self.key / "statistics"
@@ -33,23 +36,17 @@ redef class GameEntity
                var json = game.store.load_object(key)
                return new GameStats.from_json(game, json)
        end
-
-       # Save statistics under this `MEntity`.
-       fun save_statistics(stats: GameStats) do
-               game.store.store_object(key / stats.key, stats.to_json)
-       end
 end
 
 redef class Game
 
-       # Statistics for this game instance.
-       var stats: GameStats is lazy do
+       redef var stats is lazy do
                return load_statistics or else new GameStats(game)
        end
 
        redef fun save do
                super
-               save_statistics(stats)
+               stats.save_in(self)
        end
 
        redef fun pretty do
@@ -63,14 +60,13 @@ end
 
 redef class Player
 
-       # Statistics for this player.
-       var stats: GameStats is lazy do
+       redef var stats is lazy do
                return load_statistics or else new GameStats(game)
        end
 
        redef fun save do
                super
-               save_statistics(stats)
+               stats.save_in(self)
        end
 
        redef fun pretty do
@@ -125,9 +121,7 @@ end
 class StatisticsReactor
        super GameReactor
 
-       redef fun react_event(game, e) do
-               e.react_stats_event(game)
-       end
+       redef fun react_event(game, e) do e.react_stats_event(game)
 end
 
 redef class GithubEvent
index 89d6431..14538c5 100644 (file)
@@ -112,7 +112,9 @@ class GameStatusPanel
 
        redef fun render_body do
                add "<strong class=\"text-success\">{game.load_players.length}</strong>"
-               add " <a href=\"{game.url}/players\">players</a><br><br>"
+               add " <a href=\"{game.url}/players\">players</a><br>"
+               add "<strong class=\"text-success\">{game.stats["achievements"]}</strong>"
+               add " <a href=\"{game.url}/achievements\">achievements</a><br><br>"
                add "<strong class=\"text-success\">{game.stats["pulls"]}</strong> pull requests"
                add " (<strong>{game.stats["pulls_open"]}</strong> open)<br>"
                add "<strong class=\"text-success\">{game.stats["issues"]}</strong> issues"
@@ -144,6 +146,7 @@ class PlayerStatusPanel
                add "<p class=\"lead\">ranked "
                add " <span class=\"text-success\"># {ranking[player.name]}</span></p>"
                add "<strong class=\"text-success\">{player.nitcoins}</strong> nitcoins<br><br>"
+               add "<strong class=\"text-success\">{player.stats["achievements"]}</strong> achievements<br><br>"
                add "<strong>{player.stats["pulls"]}</strong> pull requests<br>"
                add "<strong>{player.stats["issues"]}</strong> issues<br>"
                add "<strong>{player.stats["commits"]}</strong> commits"
@@ -360,3 +363,75 @@ class EventListPanel
                add "</div>"
        end
 end
+
+# Achievement unlocked list panel.
+class AchievementsListPanel
+       super Panel
+
+       # Entity to load the events from.
+       var entity: GameEntity
+
+       redef fun render_title do
+               add "<span class=\"glyphicon glyphicon-list\"></span>&nbsp;&nbsp;"
+               add "Achievements unlocked"
+       end
+
+       redef fun render_body do
+               var achs = entity.load_achievements.values.to_a
+               if achs.is_empty then
+                       add "<em>No achievement yet...</em>"
+                       return
+               end
+               for ach in achs do add ach.list_item
+       end
+end
+
+# Achievement detail panel.
+class AchievementPanel
+       super Panel
+
+       # Achievement to display.
+       var achievement: Achievement
+
+       redef fun render_title do
+               add "<span class=\"glyphicon glyphicon-check\"></span>&nbsp;&nbsp;"
+               add "Achievement details"
+       end
+
+       redef fun render_body do
+               add """<p class=\"lead\">
+                               <span class="badge progress-bar-success"
+                                style="vertical-align: middle">+{{{achievement.reward}}}</span>
+                       {{{achievement.name}}}
+                      </p>
+                      <p><strong>{{{achievement.desc}}}</strong></p>"""
+
+               var events = achievement.load_events
+
+               if events.is_empty then
+                       add "<em>Never unlocked...</em>"
+                       return
+               end
+
+               var event = events.last
+               var tpl = event.tpl_event
+               var player = tpl.player
+               add "<hr>"
+               add """<div class="media">
+                       <a class="media-left" href="{{{player.url}}}">
+                                <span class="badge progress-bar-warning" style="position: absolute">#1</span>
+                        <img class=\"img-circle\" style="width:50px"
+                         src="{{{player.user.avatar_url}}}" alt="{{{player.name}}}">
+                       </a>
+                               <div class="media-body">
+                                <h4 class="media-heading">Unlocked first by {{{player.link}}}</h4>
+                                <span class="text-muted">at {{{event.time}}} </span>
+                               </div>
+                          </div>"""
+
+               if events.length > 1 then
+                       add """<p><br>Also unlocked by <strong class="text-success">
+                       {{{events.length}}} players</strong>.</p>"""
+               end
+       end
+end
index f1affb9..92a4915 100644 (file)
@@ -54,7 +54,7 @@ class NitRpgPage
        </head>
        <body>
                <nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
-                       <a class="navbar-brand" href="/">Github RPG</a>"""
+                       <a class="navbar-brand" href="{{{root_url}}}/">Github RPG</a>"""
                if not breadcrumbs == null then
                        add breadcrumbs.as(not null)
                end
index 9b28d57..8b85a4d 100644 (file)
@@ -17,7 +17,7 @@
 # Base HTML rendering templates for `nitpg`.
 module templates_base
 
-import statistics
+import achievements
 
 redef class GameEntity
 
@@ -50,3 +50,24 @@ redef class Issue
        # Return a HTML link to this Issue.
        fun link: String do return "<a href=\"{html_url}\">#{number}</a>"
 end
+
+redef class Achievement
+       # Return a HTML link to this Issue.
+       fun link: String do return "<a href=\"{url}\">{name}</a>"
+
+       fun list_item: String do
+               return """<div class="media">
+                              <div class="media-left" style="width: 50px">
+                               <span class="glyphicon glyphicon-check"></span>
+                               <span class="badge progress-bar-success"
+                                style="position: absolute; margin-top: 10px;
+                                        margin-left: -5px;">+{{{reward}}}</span>
+                              </div>
+                              <div class="media-body">
+                               <h4 class="media-heading">{{{link}}}</h4>
+                               <span class="text-muted">{{{desc}}}</span>
+                              </div>
+                             </div>"""
+
+       end
+end
index 20f7b58..234c9e5 100644 (file)
@@ -17,7 +17,7 @@
 # Templates to display `GameEvent` kinds.
 module templates_events
 
-import events
+import achievements
 import templates_base
 
 redef class GameEvent
@@ -29,6 +29,8 @@ redef class GameEvent
                        return new TplPullMerged(self)
                else if kind == "pull_review" then
                        return new TplPullReview(self)
+               else if kind == "achievement_unlocked" then
+                       return new TplAchievementUnlocked(self)
                end
                abort
        end
@@ -63,6 +65,11 @@ class TplEvent
                return new IssueCommentEvent.from_json(event.game.api, obj)
        end
 
+       # Load `achievement` data key as an Achievement.
+       var achievement: Achievement is lazy do
+               return player.load_achievement(event.data["achievement"].to_s).as(not null)
+       end
+
        # Display a media item for a reward event.
        fun media_item: String do
                return """<div class="media">
@@ -109,3 +116,12 @@ class TplPullReview
                return "{player.link} reviewed {issue.link}"
        end
 end
+
+# Event: achievement_unlocked
+class TplAchievementUnlocked
+       super TplEvent
+
+       redef var title is lazy do
+               return "{player.link} unlocked {achievement.link}"
+       end
+end
index ac7f91d..b27c457 100644 (file)
@@ -114,6 +114,7 @@ class RepoHome
                page.side_panels.add new ShortListPlayersPanel(game)
                page.flow_panels.add new PodiumPanel(game)
                page.flow_panels.add new EventListPanel(game, list_limit, list_from)
+               page.flow_panels.add new AchievementsListPanel(game)
                rsp.body = page.write_to_string
                return rsp
        end
@@ -152,12 +153,50 @@ class PlayerHome
                page.side_panels.clear
                page.side_panels.add new PlayerStatusPanel(game, player)
                page.flow_panels.add new PlayerReviewsPanel(game, player)
+               page.flow_panels.add new AchievementsListPanel(player)
                page.flow_panels.add new EventListPanel(player, list_limit, list_from)
                rsp.body = page.write_to_string
                return rsp
        end
 end
 
+# Display the list of achievements unlocked for this game.
+class ListAchievements
+       super GameAction
+
+       redef fun answer(request, url) do
+               var rsp = prepare_response(request, url)
+               page.breadcrumbs.add_link(game.url / "achievements", "achievements")
+               page.flow_panels.add new AchievementsListPanel(game)
+               rsp.body = page.write_to_string
+               return rsp
+       end
+end
+
+# Player details page.
+class AchievementHome
+       super GameAction
+
+       redef fun answer(request, url) do
+               var rsp = prepare_response(request, url)
+               var name = request.param("achievement")
+               if name == null then
+                       var msg = "Bad request: should look like /:owner/:repo/achievements/:achievement."
+                       return bad_request(msg)
+               end
+               var achievement = game.load_achievement(name)
+               if achievement == null then
+                       return bad_request("Request Error: unknown achievement {name}.")
+               end
+               page.breadcrumbs.add_link(game.url / "achievements", "achievements")
+               page.breadcrumbs.add_link(achievement.url, achievement.name)
+               page.flow_panels.add new AchievementPanel(achievement)
+               page.flow_panels.add new EventListPanel(achievement, list_limit, list_from)
+               rsp.body = page.write_to_string
+               return rsp
+       end
+end
+
 if args.length != 3 then
        print "Error: missing argument"
        print ""
@@ -175,6 +214,8 @@ var vh = new VirtualHost(iface)
 vh.routes.add new Route("/styles/", new FileServer("www/styles"))
 vh.routes.add new Route("/games/:owner/:repo/players/:player", new PlayerHome(root))
 vh.routes.add new Route("/games/:owner/:repo/players", new ListPlayers(root))
+vh.routes.add new Route("/games/:owner/:repo/achievements/:achievement", new AchievementHome(root))
+vh.routes.add new Route("/games/:owner/:repo/achievements", new ListAchievements(root))
 vh.routes.add new Route("/games/:owner/:repo", new RepoHome(root))
 
 var fac = new HttpFactory.and_libevent