diff --git a/Dockerfile b/Dockerfile index ad664af..cbefa6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM nitlang/nit # Needed for nitcorn and to build mongo-c-driver -RUN apt-get update && apt-get install -y libevent-dev libssl-dev libsasl2-dev libcurl4-openssl-dev file +RUN apt-get update && apt-get install -y libevent-dev libssl-dev libsasl2-dev libcurl4-openssl-dev file libsqlite3-dev sqlite3 # Install mongo-c-driver manually since it is not available in Debian/jessie RUN curl -L https://github.com/mongodb/mongo-c-driver/releases/download/1.4.0/mongo-c-driver-1.4.0.tar.gz -o mongo-c-driver-1.4.0.tar.gz \ diff --git a/Makefile b/Makefile index 1ecf840..58f5207 100644 --- a/Makefile +++ b/Makefile @@ -28,10 +28,13 @@ bin/db_loader: nitserial src/db_loader.nit -o src/db_loader_serial.nit nitc src/db_loader.nit -m src/db_loader_serial.nit -o bin/db_loader -populate: bin/db_loader +populate: bin/db_loader init_db # There are levels to this... try: `make populate level=2` bin/db_loader $(level) +init_db: + sqlite3 Missions < init.sql + run: bin/app --auth shib diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..220ef97 --- /dev/null +++ b/init.sql @@ -0,0 +1,211 @@ +DROP TABLE IF EXISTS players; +DROP TABLE IF EXISTS friends; +DROP TABLE IF EXISTS category; +DROP TABLE IF EXISTS tracks; +DROP TABLE IF EXISTS missions; +DROP TABLE IF EXISTS mission_dependencies; +DROP TABLE IF EXISTS testcases; +DROP TABLE IF EXISTS stars; +DROP TABLE IF EXISTS track_status; +DROP TABLE IF EXISTS mission_status; +DROP TABLE IF EXISTS star_status; +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS submissions; +DROP TABLE IF EXISTS friend_events; +DROP TABLE IF EXISTS achievements; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS achievement_unlocks; +DROP TABLE IF EXISTS languages; +DROP TABLE IF EXISTS track_languages; +DROP TABLE IF EXISTS mission_languages; +DROP TABLE IF EXISTS track_statuses; +DROP TABLE IF EXISTS star_results; + + +CREATE TABLE players( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + name TEXT DEFAULT "", + email TEXT DEFAULT "", + avatar_url TEXT DEFAULT "", + date_joined INTEGER NOT NULL +); + +CREATE TABLE friends( + player_id1 INTEGER, + player_id2 INTEGER, + + PRIMARY KEY(player_id1, player_id2), + FOREIGN KEY(player_id1) REFERENCES players(id), + FOREIGN KEY(player_id2) REFERENCES players(id) +); + +CREATE TABLE languages( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT +); + +CREATE TABLE tracks( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT DEFAULT "", + path TEXT +); + +CREATE TABLE track_languages( + track_id INTEGER, + language_id INTEGER, + + PRIMARY KEY(track_id, language_id), + FOREIGN KEY(track_id) REFERENCES tracks(id), + FOREIGN KEY(language_id) REFERENCES languages(id) +); + +CREATE TABLE track_statuses( + track_id INTEGER, + player_id INTEGER, + status INTEGER, + + PRIMARY KEY(track_id, player_id), + FOREIGN KEY(track_id) REFERENCES tracks(id), + FOREIGN KEY(player_id) REFERENCES players(id) +); + +CREATE TABLE missions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + track_id INTEGER NOT NULL, + description TEXT NOT NULL, + reward INTEGER DEFAULT 0, + path TEXT, + + FOREIGN KEY(track_id) REFERENCES tracks(id) +); + +CREATE TABLE mission_languages( + mission_id INTEGER, + language_id INTEGER, + + PRIMARY KEY(mission_id, language_id), + FOREIGN KEY (mission_id) REFERENCES missions(id), + FOREIGN KEY (language_id) REFERENCES languages(id) +); + +CREATE TABLE mission_dependencies( + mission_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, + PRIMARY KEY (mission_id, parent_id), + + FOREIGN KEY(mission_id) REFERENCES missions(id), + FOREIGN KEY(parent_id) REFERENCES missions(id) +); + +CREATE TABLE testcases( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mission_id INTEGER NOT NULL, + root_uri TEXT NOT NULL, + + FOREIGN KEY(mission_id) REFERENCES missions(id) +); + +CREATE TABLE stars( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + mission_id INTEGER NOT NULL, + score INTEGER DEFAULT 0, + reward INTEGER DEFAULT 0, + type_id INTEGER NOT NULL, + + FOREIGN KEY(mission_id) REFERENCES missions(id) +); + +CREATE TABLE mission_status( + mission_id INTEGER, + player_id INTEGER, + status INTEGER NOT NULL, + PRIMARY KEY(mission_id, player_id), + + FOREIGN KEY(mission_id) REFERENCES missions(id), + FOREIGN KEY(player_id) REFERENCES players(id) +); + +CREATE TABLE star_status( + star_id INTEGER, + player_id INTEGER, + status BOOLEAN DEFAULT FALSE, + PRIMARY KEY(star_id, player_id), + + FOREIGN KEY(star_id) REFERENCES stars(id), + FOREIGN KEY(player_id) REFERENCES players(id) +); + +CREATE TABLE events( + id INTEGER PRIMARY KEY AUTOINCREMENT, + datetime INTEGER NOT NULL +); + +CREATE TABLE submissions( + event_id INTEGER PRIMARY KEY, + player_id INTEGER NOT NULL, + mission_id TEXT NOT NULL, + workspace_path TEXT, + status INTEGER DEFAULT 1, + + FOREIGN KEY(event_id) REFERENCES events(id), + FOREIGN KEY(player_id) REFERENCES players(id), + FOREIGN KEY(mission_id) REFERENCES missions(id) +); + +CREATE TABLE friend_events( + event_id INTEGER PRIMARY KEY, + player_id1 INTEGER NOT NULL, + player_id2 INTEGER NOT NULL, + status INTEGER DEFAULT 0, + + FOREIGN KEY(event_id) REFERENCES events(id), + FOREIGN KEY(player_id1) REFERENCES players(id), + FOREIGN KEY(player_id2) REFERENCES players(id) +); + +CREATE TABLE achievements( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT, + reward INTEGER DEFAULT 0 +); + +CREATE TABLE achievement_unlocks( + event_id INTEGER PRIMARY KEY, + achievement_id INTEGER, + player_id INTEGER, + + FOREIGN KEY(event_id) REFERENCES events(id), + FOREIGN KEY(player_id) REFERENCES players(id), + FOREIGN KEY(achievement_id) REFERENCES achievements(id) +); + +CREATE TABLE notifications( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + object TEXT NOT NULL, + body TEXT DEFAULT "", + read BOOLEAN DEFAULT FALSE, + timestamp INTEGER NOT NULL, + + FOREIGN KEY(event_id) REFERENCES events(id), + FOREIGN KEY(player_id) REFERENCES players(id) +); + +CREATE TABLE star_results( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER NOT NULL, + star_id INTEGER NOT NULL, + score INTEGER NOT NULL, + + FOREIGN KEY(submission_id) REFERENCES submissions(event_id), + FOREIGN KEY(star_id) REFERENCES stars(id) +); diff --git a/misc/setup_pep.nit b/misc/setup_pep.nit index 60ebe36..92b0fa0 100644 --- a/misc/setup_pep.nit +++ b/misc/setup_pep.nit @@ -10,8 +10,8 @@ import model::loader import submissions import api -var opts = new AppOptions.from_args(args) -var config = new AppConfig.from_options(opts) +var config = new AppConfig +config.parse_options(args) # clean bd config.db.drop @@ -31,7 +31,7 @@ for mission in config.missions.find_all do print " no path. skip" continue end - + # Get a potential solution var f = (path / "solution.pep").to_path var source = f.read_all @@ -55,8 +55,8 @@ for mission in config.missions.find_all do # If success, update the goals in the original .ini file if sub.status == "success" then var ini = new ConfigTree((path / "config.ini").to_s) - ini["star.time_goal"] = sub.time_score.to_s - ini["star.size_goal"] = sub.size_score.to_s + ini["star.time_goal"] = (sub.time_score or else "").to_s + ini["star.size_goal"] = (sub.size_score or else "").to_s ini.save end end diff --git a/src/api/api.nit b/src/api/api.nit index 7e3af8f..d94d6cc 100644 --- a/src/api/api.nit +++ b/src/api/api.nit @@ -21,6 +21,10 @@ import api::api_tracks import api::api_missions import api::api_achievements +redef class AppConfig + redef init do super # FIXME avoid linearization conflit +end + redef class AuthRouter redef init do super # FIXME avoid linearization conflit end diff --git a/src/api/api_achievements.nit b/src/api/api_achievements.nit index 392174a..f8da89a 100644 --- a/src/api/api_achievements.nit +++ b/src/api/api_achievements.nit @@ -30,7 +30,7 @@ class APIAchievements super APIHandler redef fun get(req, res) do - res.json new JsonArray.from(config.achievements.group_achievements) + res.json new JsonArray.from(req.ctx.all_achievements) end end @@ -43,7 +43,7 @@ class APIAchievement res.api_error("Missing URI param `aid`", 400) return null end - var achievement = config.achievements.find_by_key(aid) + var achievement = req.ctx.achievement_by_slug(aid) if achievement == null then res.api_error("Achievement `{aid}` not found", 404) return null @@ -64,6 +64,6 @@ class APIAchievementPlayers redef fun get(req, res) do var achievement = get_achievement(req, res) if achievement == null then return - res.json new JsonArray.from(achievement.players(config)) + res.json new JsonArray.from(achievement.players) end end diff --git a/src/api/api_auth.nit b/src/api/api_auth.nit index 584b195..7ac9c92 100644 --- a/src/api/api_auth.nit +++ b/src/api/api_auth.nit @@ -21,33 +21,30 @@ redef class AppConfig # Authentification method used # # At this point can be either `github` or `shib`, see the clients modules. - var auth_methods: Array[String] is lazy do return value_or_default("auth", default_auth_method).split(",") + fun auth_methods: Array[String] do return (ini["auth"] or else default_auth_method).split(",") # Default authentification method used # # Will be refined based on what auth method we implement. fun default_auth_method: String is abstract - redef init from_options(opts) do - super - var auth_methods = opts.opt_auth_method.value - if not auth_methods.is_empty then - self["auth"] = auth_methods.join(",") - end - end -end - -redef class AppOptions - # Authentification to use # # Can be either `github` or `shib`. var opt_auth_method = new OptionArray("Authentification service to use. Can be `github` (default) and/or `shib`", "--auth") - init do + redef init do super add_option(opt_auth_method) end + + redef fun parse_options(opts) do + super + var auth_methods = opt_auth_method.value + if not auth_methods.is_empty then + ini["auth"] = auth_methods.join(",") + end + end end # The common auth router @@ -81,7 +78,7 @@ class SessionRefresh if session == null then return var player = session.player if player == null then return - session.player = config.players.find_by_id(player.id) + session.player = req.ctx.player_by_id(player.id) end end @@ -117,8 +114,14 @@ abstract class AuthLogin # Helper method to use when a new account is created. fun register_new_player(player: Player) do - player.add_achievement(config, new FirstLoginAchievement(player)) - config.players.save player + var ctx = player.context + var first_login = ctx.achievement_by_slug("hello_world") + if first_login == null then + first_login = new FirstLoginAchievement(ctx) + first_login.commit + end + player.add_achievement(first_login) + player.commit end # Redirect to the `next` page. @@ -149,6 +152,7 @@ class AuthHandler res.api_error("Unauthorized", 403) return null end + player.context = req.ctx return player end end @@ -175,11 +179,9 @@ end class FirstLoginAchievement super Achievement serialize - autoinit player + autoinit(context) - redef var key = "first_login" redef var title = "Hello World!" redef var desc = "Login into the mission board for the first time." redef var reward = 10 - redef var icon = "log-in" end diff --git a/src/api/api_auth_github.nit b/src/api/api_auth_github.nit index 3bc3be5..515415d 100644 --- a/src/api/api_auth_github.nit +++ b/src/api/api_auth_github.nit @@ -66,11 +66,10 @@ class MissionsGithubOAuthCallBack var user = session.user if user == null then return var id = user.login - var player = config.players.find_by_id(id) + var player = req.ctx.player_by_slug(id) if player == null then - player = new Player(id) - player.name = user.login - player.avatar_url = user.avatar_url + var avatar = user.avatar_url or else "" + player = new Player(req.ctx, id, user.login, "", avatar) register_new_player(player) end session.player = player diff --git a/src/api/api_auth_rand.nit b/src/api/api_auth_rand.nit index e94ac7a..72e24b2 100644 --- a/src/api/api_auth_rand.nit +++ b/src/api/api_auth_rand.nit @@ -38,7 +38,7 @@ class RandLogin if session == null then return var id = req.get_args.get_or_null("id") if id == null then return - var player = config.players.find_by_id(id) + var player = req.ctx.player_by_slug(id) if player == null then return session.player = player res.redirect "/player" diff --git a/src/api/api_auth_shibuqam.nit b/src/api/api_auth_shibuqam.nit index bba93d8..835dd66 100644 --- a/src/api/api_auth_shibuqam.nit +++ b/src/api/api_auth_shibuqam.nit @@ -86,12 +86,11 @@ class ShibCallback end # Get the player (a new or an old one) - var id = user.id - var player = config.players.find_by_id(id) + var slug = user.id + var ctx = req.ctx + var player = ctx.player_by_slug(slug) if player == null then - player = new Player(id) - player.name = user.display_name - player.avatar_url = user.avatar + player = new Player(ctx, slug, user.display_name, user.email, user.avatar) register_new_player(player) end session.player = player diff --git a/src/api/api_base.nit b/src/api/api_base.nit index 18d13a9..f43789e 100644 --- a/src/api/api_base.nit +++ b/src/api/api_base.nit @@ -30,6 +30,11 @@ class APIRouter var config: AppConfig end +redef class HttpRequest + # The datbase context to which `self` is attached + var ctx: DBContext is lazy do return new DBContext +end + abstract class PlayerHandler super APIHandler @@ -39,11 +44,9 @@ abstract class PlayerHandler res.api_error("Missing URI param `login`", 400) return null end - var player = config.players.find_by_id(pid) - if player == null then - res.api_error("Player `{pid}` not found", 404) - return null - end + var player = null + player = req.ctx.player_by_slug(pid) + if player == null then res.api_error("Player `{pid}` not found", 404) return player end end @@ -57,11 +60,9 @@ abstract class TrackHandler res.api_error("Missing URI param `tid`", 400) return null end - var track = config.tracks.find_by_id(tid) - if track == null then - res.api_error("Track `{tid}` not found", 404) - return null - end + var track = null + track = req.ctx.track_by_slug(tid) + if track == null then res.api_error("Track `{tid}` not found", 404) return track end end @@ -75,11 +76,9 @@ abstract class MissionHandler res.api_error("Missing URI param `mid`", 400) return null end - var mission = config.missions.find_by_id(mid) - if mission == null then - res.api_error("Mission `{mid}` not found", 404) - return null - end + var mission = null + mission = req.ctx.mission_by_slug(mid) + if mission == null then res.api_error("Mission `{mid}` not found", 404) return mission end end diff --git a/src/api/api_missions.nit b/src/api/api_missions.nit index 99729d0..81b29bb 100644 --- a/src/api/api_missions.nit +++ b/src/api/api_missions.nit @@ -23,6 +23,7 @@ redef class APIRouter super use("/missions", new APIMissions(config)) use("/missions/:mid", new APIMission(config)) + use("/missions/:mid/status", new APIMissionStatus(config)) end end @@ -30,7 +31,7 @@ class APIMissions super APIHandler redef fun get(req, res) do - res.json new JsonArray.from(config.missions.find_all) + res.json new JsonArray.from(req.ctx.all_missions) end end @@ -44,6 +45,9 @@ class APIMission var mission = get_mission(req, res) if mission == null then return + var mstat = mission.status_for(player.id) + if mstat == null or mstat.status == req.ctx.mission_locked then return + var post = req.body var deserializer = new JsonDeserializer(post) @@ -54,8 +58,9 @@ class APIMission return end var runner = config.engine_map[submission_form.engine] - var submission = new Submission(player, mission, submission_form.source.decode_base64.to_s) - runner.run(submission, config) + + var submission = new Submission(req.ctx, player.id, mission.id, submission_form.source.decode_base64.to_s) + runner.run(submission) res.json submission end @@ -66,3 +71,15 @@ class APIMission res.json mission end end + +class APIMissionStatus + super MissionHandler + super AuthHandler + + redef fun get(req, res) do + var mission = get_mission(req, res) + var player = get_player(req, res) + if mission == null or player == null then return + res.json mission.status_for(player.id) + end +end diff --git a/src/api/api_players.nit b/src/api/api_players.nit index 32d5704..d9666f1 100644 --- a/src/api/api_players.nit +++ b/src/api/api_players.nit @@ -37,6 +37,9 @@ redef class APIRouter use("/player/ask_friend/:fid", new APIPlayerAskFriend(config)) use("/player/friend_requests/", new APIPlayerFriendRequests(config)) use("/player/friend_requests/:fid", new APIPlayerFriendRequest(config)) + + use("/players/:login/track/:tid/missions_status", new APITrackMissionStatus(config)) + use("/players/:login/track/:tid/stars_status", new APITrackStarsStatus(config)) end end @@ -44,7 +47,10 @@ class APIPlayers super APIHandler redef fun get(req, res) do - res.json new JsonArray.from(config.players_ranking) + var top = req.ctx.all_players + var res_arr = new JsonArray + for i in top do res_arr.add(i.stats) + res.json res_arr end end @@ -74,7 +80,7 @@ class APIPlayerTracksStatus redef fun get(req, res) do var player = get_player(req, res) if player == null then return - res.json new JsonArray.from(player.tracks_status(config)) + res.json new JsonArray.from(player.track_statuses) end end @@ -87,7 +93,7 @@ class APIPlayerTrackStatus if player == null then return var track = get_track(req, res) if track == null then return - res.json player.track_status(config, track) + res.json track.status_for(player.id) end end @@ -100,7 +106,7 @@ class APIPlayerMissionStatus if player == null then return var mission = get_mission(req, res) if mission == null then return - res.json player.mission_status(config, mission) + res.json mission.status_for(player.id) end end @@ -113,7 +119,7 @@ class APIPlayerStats unlock_morriar_achievement(req, player) - res.json player.stats(config) + res.json player.stats end fun unlock_morriar_achievement(req: HttpRequest, player: Player) do @@ -122,7 +128,12 @@ class APIPlayerStats if session == null then return var logged = session.player if logged == null then return - logged.add_achievement(config, new LookInTheEyesOfTheGodAchievement(player)) + var ach = req.ctx.achievement_by_slug("look_in_the_eyes_of_god") + if ach == null then + ach = new LookInTheEyesOfGodAchievement + ach.commit + end + logged.add_achievement(ach) end end @@ -132,14 +143,14 @@ class APIPlayerNotifications redef fun get(req, res) do var player = get_player(req, res) if player == null then return - res.json new JsonArray.from(player.notifications(config)) + res.json new JsonArray.from(player.open_notifications) end redef fun delete(req, res) do var player = get_player(req, res) if player == null then return - player.clear_notifications(config) - res.json new JsonArray.from(player.notifications(config)) + player.clear_notifications + res.json new JsonArray.from(player.notifications) end end @@ -148,11 +159,11 @@ class APIPlayerNotification fun get_notification(req: HttpRequest, res: HttpResponse): nullable PlayerNotification do var nid = req.param("nid") - if nid == null then + if nid == null or not nid.is_int then res.api_error("Missing URI param `nid`", 400) return null end - var notif = config.notifications.find_by_id(nid) + var notif = req.ctx.notification_by_id(nid.to_i) if notif == null then res.api_error("Notification `{nid}` not found", 404) return null @@ -165,7 +176,7 @@ class APIPlayerNotification if player == null then return var notif = get_notification(req, res) if notif == null then return - if player.id != notif.player.id then + if player.id != notif.player_id then res.api_error("Unauthorized", 403) return end @@ -177,12 +188,11 @@ class APIPlayerNotification if player == null then return var notif = get_notification(req, res) if notif == null then return - if player.id != notif.player.id then + if player.id != notif.player_id then res.api_error("Unauthorized", 403) return end - player.clear_notification(config, notif) - res.json new JsonObject + res.json notif.clear end end @@ -192,7 +202,7 @@ class APIPlayerFriends redef fun get(req, res) do var player = get_player(req, res) if player == null then return - res.json new JsonArray.from(player.load_friends(config)) + res.json new JsonArray.from(player.friends) end end @@ -205,7 +215,7 @@ class APIPlayerFriend res.api_error("Missing URI param `fid`", 400) return null end - var friend = config.players.find_by_id(fid) + var friend = req.ctx.player_by_slug(fid) if friend == null then res.api_error("Friend `{fid}` not found", 404) return null @@ -218,11 +228,11 @@ class APIPlayerFriend if player == null then return var friend = get_friend(req, res) if friend == null then return - if not player.has_friend(friend) then + if not player.has_friend(friend.id) then res.api_error("Friend `{friend.id}` not found", 404) return end - res.json new JsonArray.from(player.load_friends(config)) + res.json new JsonArray.from(player.friends) end redef fun delete(req, res) do @@ -230,12 +240,12 @@ class APIPlayerFriend if player == null then return var friend = get_friend(req, res) if friend == null then return - if not player.has_friend(friend) then + if not player.has_friend(friend.id) then res.api_error("Friend `{friend.id}` not found", 404) return end - player.remove_friend(config, friend) - friend.remove_friend(config, player) + player.remove_friend(friend) + friend.remove_friend(player) res.json new JsonObject end end @@ -253,7 +263,7 @@ class APIPlayerAskFriend res.error 400 return end - var friend_request = player.ask_friend(config, friend) + var friend_request = player.ask_friend(friend) if friend_request == null then res.error 400 return @@ -269,8 +279,8 @@ class APIPlayerFriendRequests var player = get_player(req, res) if player == null then return var obj = new JsonObject - obj["sent"] = new JsonArray.from(player.sent_friend_requests(config)) - obj["received"] = new JsonArray.from(player.received_friend_requests(config)) + obj["sent"] = new JsonArray.from(player.sent_friend_requests) + obj["received"] = new JsonArray.from(player.received_friend_requests) res.json obj end end @@ -280,11 +290,11 @@ class APIPlayerFriendRequest fun get_friend_request(req: HttpRequest, res: HttpResponse): nullable FriendRequest do var fid = req.param("fid") - if fid == null then + if fid == null or not fid.is_int then res.error 400 return null end - var friend_request = config.friend_requests.find_by_id(fid) + var friend_request = req.ctx.friend_request_by_id(fid.to_i) if friend_request == null then res.error 404 return null @@ -298,7 +308,7 @@ class APIPlayerFriendRequest if player == null then return var friend_request = get_friend_request(req, res) if friend_request == null then return - if not friend_request.from.id == player.id or friend_request.to.id == player.id then + if not friend_request.from == player.id or friend_request.to == player.id then res.error 404 return end @@ -311,11 +321,11 @@ class APIPlayerFriendRequest if player == null then return var friend_request = get_friend_request(req, res) if friend_request == null then return - if not friend_request.to.id == player.id then + if not friend_request.to == player.id then res.error 404 return end - if not player.accept_friend_request(config, friend_request) then + if not friend_request.accept then res.error 400 return end @@ -328,11 +338,11 @@ class APIPlayerFriendRequest if player == null then return var friend_request = get_friend_request(req, res) if friend_request == null then return - if not friend_request.to.id == player.id then + if not friend_request.to == player.id then res.error 404 return end - if not player.decline_friend_request(config, friend_request) then + if not friend_request.decline then res.error 400 return end @@ -348,21 +358,54 @@ class APIPlayerAchivements redef fun get(req, res) do var player = get_player(req, res) if player == null then return - res.json new JsonArray.from(player.achievements(config)) + res.json new JsonArray.from(player.achievements) + end +end + +class APITrackMissionStatus + super TrackHandler + super PlayerHandler + + redef fun get(req, res) do + var track = get_track(req, res) + var player = get_player(req, res) + if track == null then + print "No track found" + return + end + if player == null then + print "No player found" + return + end + var mission_statuses = track.mission_statuses_for(player.id) + res.json new JsonArray.from(mission_statuses) + end +end + +class APITrackStarsStatus + super TrackHandler + super PlayerHandler + + redef fun get(req, res) do + var track = get_track(req, res) + var player = get_player(req, res) + if track == null or player == null then + return + end + var star_statuses = track.star_statuses_for(player.id) + res.json new JsonArray.from(star_statuses) end end # Look in the eyes of the God achievement # # Unlocked when the player looks at Morriar stats for the first time. -class LookInTheEyesOfTheGodAchievement +class LookInTheEyesOfGodAchievement super Achievement serialize - autoinit player + noautoinit - redef var key = "look_in_the_eyes_of_the_god" - redef var title = "Look in the eyes of the God" + redef var title = "Look in the eyes of God" redef var desc = "Look at \"Morriar\" page." redef var reward = 10 - redef var icon = "eye-open" end diff --git a/src/api/api_tracks.nit b/src/api/api_tracks.nit index cae6617..e165153 100644 --- a/src/api/api_tracks.nit +++ b/src/api/api_tracks.nit @@ -22,6 +22,7 @@ redef class APIRouter super use("/tracks", new APITracks(config)) use("/tracks/:tid", new APITrack(config)) + use("/tracks/:tid/status", new APITrackStatus(config)) use("/tracks/:tid/missions", new APITrackMissions(config)) end end @@ -30,7 +31,7 @@ class APITracks super APIHandler redef fun get(req, res) do - res.json new JsonArray.from(config.tracks.find_all) + res.json new JsonArray.from(req.ctx.all_tracks) end end @@ -44,12 +45,26 @@ class APITrack end end +class APITrackStatus + super TrackHandler + super AuthHandler + + redef fun get(req, res) do + var track = get_track(req, res) + if track == null then return + var player = get_player(req, res) + if player == null then return + var status = track.status_for(player.id) + res.json status + end +end + class APITrackMissions super TrackHandler redef fun get(req, res) do var track = get_track(req, res) if track == null then return - res.json new JsonArray.from(config.missions.find_by_track(track)) + res.json new JsonArray.from(track.missions) end end diff --git a/src/api/engine_configuration.nit b/src/api/engine_configuration.nit index f36bd7a..b41c974 100644 --- a/src/api/engine_configuration.nit +++ b/src/api/engine_configuration.nit @@ -19,7 +19,8 @@ redef class AppConfig # Map of all supported engines for problem solving var engine_map = new HashMap[String, Engine] - init do + redef init do + super engine_map["pep8term"] = new Pep8Engine end end diff --git a/src/app.nit b/src/app.nit index 142a0ba..ff28a9b 100644 --- a/src/app.nit +++ b/src/app.nit @@ -14,15 +14,15 @@ import api -var opts = new AppOptions.from_args(args) +var config = new AppConfig +config.parse_options(args) -if opts.opt_help.value then +if config.opt_help.value then print("Usage: app [Options]\nOptions:") - opts.usage + config.usage return end -var config = new AppConfig.from_options(opts) var app = new App app.use_before("/*", new SessionInit) diff --git a/src/config.nit b/src/config.nit index eef0942..70b9e56 100644 --- a/src/config.nit +++ b/src/config.nit @@ -20,10 +20,10 @@ import popcorn::pop_repos redef class AppConfig # Github client id used for Github OAuth login. - var gh_client_id: String is lazy do return value_or_default("github.client.id", "") + fun gh_client_id: String do return ini["github.client.id"] or else "" # Github client secret used for Github OAuth login. - var gh_client_secret: String is lazy do return value_or_default("github.client.secret", "") + fun gh_client_secret: String do return ini["github.client.secret"] or else "" # Site root url to use for some redirect # Useful if behind some reverse proxy @@ -32,6 +32,6 @@ redef class AppConfig var port = app_port var url = "http://{host}" if port != 80 then url += ":{port}" - return value_or_default("app.root_url", url) + return ini["app.root_url"] or else url end end diff --git a/src/db_loader.nit b/src/db_loader.nit index f30a35c..2dc82b9 100644 --- a/src/db_loader.nit +++ b/src/db_loader.nit @@ -16,68 +16,90 @@ import model import model::loader import api -var opts = new AppOptions.from_args(args) -var config = new AppConfig.from_options(opts) +redef class DBContext + fun player_count: Int do + var db = connection + var res = db.select("COUNT(*) FROM players;") + if res == null then + log_sql_error + return 0 + end + return res.get_count + end + + fun track_count: Int do + var db = connection + var res = db.select("COUNT(*) FROM tracks;") + if res == null then + log_sql_error + return 0 + end + return res.get_count + end +end +#var opts = new AppOptions.from_args(args) +#var config = new AppConfig.from_options(opts) -# Use level 0 to disable debug things var level = 1 if args.length >= 1 then level = args[0].to_i -# clean bd -config.players.clear -config.notifications.clear -config.achievements.clear -config.friend_requests.clear -config.tracks.clear -config.missions.clear -config.missions_status.clear - -config.load_tracks "tracks" - -if level >= 1 then - config.load_tracks "tracks-wip" - +with ctx = new DBContext do # load some tracks and missions var track_count = 5 * level for i in [1..track_count] do - var track = new Track("track{i}", "Track {i}", "desc {i}") - config.tracks.save track + var track = new Track(ctx, "Track {i}", "desc {i}", "track{i}") + track.commit var last_missions = new Array[Mission] - var mission_count = (10 * level).rand + var mission_count = (10 * level).rand + 1 for j in [1..mission_count] do - var mission = new Mission("track{i}:mission{j}", track, "Mission {i}-{j}", "desc {j}") + var mission = new Mission(ctx, "track{i}:mission{j}", "Mission {i}-{j}", track.id, "desc {j}") + print "Added mission {mission}" if last_missions.not_empty then + var parents = new Array[Mission] if 100.rand > 75 then - mission.parents.add last_missions.last.id + parents.add last_missions.last else - mission.parents.add last_missions.rand.id + parents.add last_missions.rand end if 100.rand > 50 then - var rand = last_missions.rand.id - if not mission.parents.has(rand) then mission.parents.add rand + var rand = last_missions.rand + if not parents.has(rand) then parents.add rand end + mission.parents = parents end + mission.commit var star_count = (4 * level).rand for s in [1..star_count] do - mission.add_star(new MissionStar("star{s} explanation", 100.rand)) + var star = new MissionStar(ctx, "star{s} explanation", 100.rand, mission.id, 1) + star.commit end last_missions.add mission - config.missions.save mission end end + ctx.load_tracks "tracks" + # load some players - var morriar = new Player("Morriar", "Morriar", avatar_url= "https://avatars.githubusercontent.com/u/583144?v=3") - config.players.save morriar - var privat = new Player("privat", "privat", avatar_url= "https://avatars2.githubusercontent.com/u/135828?v=3") - config.players.save privat + var morriar = new Player(ctx, "Morriar", "Morriar", "morriar@dummy.cx", "https://avatars.githubusercontent.com/u/583144?v=3") + morriar.commit + var privat = new Player(ctx, "privat", "privat", "privat@dummy.cx", "https://avatars2.githubusercontent.com/u/135828?v=3") + privat.commit # privat.ask_friend(config, morriar) - privat.add_friend(config, morriar) - privat.add_achievement(config, new FirstLoginAchievement(privat)) - morriar.add_friend(config, privat) - morriar.add_achievement(config, new FirstLoginAchievement(morriar)) + var first_login = new FirstLoginAchievement(ctx) + first_login.commit + + privat.add_achievement(first_login) + print "privat got achievement" + morriar.add_achievement(first_login) + print "morriar got achievement" + + var request = new FriendRequest(ctx, privat.id, morriar.id) + request.commit + print "Friend request created" + request.accept + print "Friend request accepted" var aurl = "https://avatars.githubusercontent.com/u/2577044?v=3" var players = new Array[Player] @@ -86,39 +108,30 @@ if level >= 1 then var player_count = 30 * level for i in [0..player_count] do - var p = new Player("P{i}", "Player{i}", avatar_url=aurl) + var p = new Player(ctx, "P{i}", "Player{i}", "dummy@dummy.cx", aurl) players.push p + p.commit end for player in players do - config.players.save player - - # load some statuses - for mission in config.missions.find_all do - var status = new MissionStatus(mission, player, mission.track) - if mission.is_unlocked_for_player(config, player) or 100.rand > 25 then - status.status = "open" - for star in mission.stars do - status.stars_status.add new StarStatus(star, 100.rand > 50) - end - end - if status.unlocked_stars.not_empty then status.status = "success" - config.missions_status.save status - end - # Spread some love (or friendships =( ) for other_player in players do - if not player.has_friend(other_player) then + if other_player != player and not player.has_friend(other_player.id) then var love = 10.rand - if love == 1 then player.add_friend(config, other_player) + if love == 1 then + print "Making {player.id} friend with {other_player.id}" + var rq = new FriendRequest(ctx, player.id, other_player.id) + rq.commit + print "Request commited" + rq.accept + print "Request accepted" + end end end end - config.players.save new Player("John", "Doe") + print "Loaded {ctx.track_count} tracks" + print "Loaded {ctx.mission_count} missions" + print "Loaded {ctx.player_count} players" + #print "Loaded {} missions status" end - -print "Loaded {config.tracks.find_all.length} tracks" -print "Loaded {config.missions.find_all.length} missions" -print "Loaded {config.players.find_all.length} players" -print "Loaded {config.missions_status.find_all.length} missions status" diff --git a/src/debug.nit b/src/debug.nit index 7a42283..3475d34 100644 --- a/src/debug.nit +++ b/src/debug.nit @@ -2,9 +2,10 @@ import api redef class AppConfig # The default player, if any, that will be logged on a new session - var debug_player: String is lazy do return value_or_default("app.debug.player", "John") + var debug_player: String is lazy do return ini["app.debug.player"] or else "John" init do + super engine_map["nitc"] = new NitcEngine end end @@ -25,7 +26,7 @@ redef class SessionRefresh if session.auto_logged then return var player = session.player if player == null then - session.player = config.players.find_by_id(config.debug_player) + session.player = req.ctx.player_by_slug(config.debug_player) session.auto_logged = true end end diff --git a/src/model/achievements.nit b/src/model/achievements.nit index cdc3702..aa9c995 100644 --- a/src/model/achievements.nit +++ b/src/model/achievements.nit @@ -16,114 +16,132 @@ module achievements import model::notifications -redef class AppConfig - var achievements = new AchievementRepo(db.collection("achievements")) is lazy +redef class DBContext + fun achievement_worker: AchievementWorker do return once new AchievementWorker + + # Gets an `Achievement` by its `id` + fun achievement_by_id(id: Int): nullable Achievement do return achievement_worker.fetch_one(self, "* FROM achievements WHERE id = {id};") + + fun achievement_by_slug(slug: String): nullable Achievement do return achievement_worker.fetch_one(self, "* FROM achievements WHERE slug = {slug.to_sql_string};") + + fun all_achievements: Array[Achievement] do return achievement_worker.fetch_multiple(self, "* FROM achievements;") end -# Achievement representation -# -# Achievement are notorious acts performed by players. +redef class Statement + # Gets all the Achievement items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of an Achievement + fun to_achievements(ctx: DBContext): Array[Achievement] do + return ctx.achievement_worker. + fetch_multiple_from_statement(ctx, self) + end +end + +class AchievementWorker + super EntityWorker + + redef type ENTITY: Achievement + + redef fun entity_type do return "Achievement" + + redef fun expected_data do return once ["id", "slug", "title", "description", "reward"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + var slug = m["slug"].as(String) + var title = m["title"].as(String) + var desc = m["description"].as(String) + var reward = m["reward"].as(Int) + var ach = new Achievement(ctx, title, desc, reward) + ach.slug = slug + ach.id = id + return ach + end +end + +# Notable acts performed by players. class Achievement - super Event + super UniqueEntity serialize - # Achievement key used to identify all instances of an achievement - var key: String - - # Player who unlocked this achievement - var player: Player + # Key name for `self` + var slug: String is lazy do return title.strip_id # Achievement title (should be short and punchy) - var title: String + var title: String is writable(set_title) # Achievement description (explains how to get this achievement) var desc: String - # Points rewarded when this achievement is unlocked + # Reward for unlocking the achievement var reward: Int - # Icon associated to this achievement - var icon: String + fun title=(title: String) do + set_title title + slug = title.strip_id + end # List players who unlocked `self` - fun players(config: AppConfig): Array[Player] do - var achs = config.achievements.collection.aggregate( - (new MongoPipeline). - match((new MongoMatch).eq("key", key)). - group(new MongoGroup("$player._id"))) - var res = new HashSet[Player] - for a in achs do - var player = config.players.find_by_id(a["_id"].as(String)) - if player == null then continue - res.add player - end - return res.to_a + fun players: Array[Player] do + if id == -1 then return new Array[Player] + return context.player_worker.fetch_multiple(context, "players.* FROM achievement_unlocks AS unlocks, players WHERE unlocks.achievement = {id} AND unlocks.player_id = players.id;") end + + redef fun insert do return basic_insert("INSERT INTO achievements(slug, title, description, reward) VALUES ({slug.to_sql_string}, {title.to_sql_string}, {desc.to_sql_string}, {reward});") + + redef fun update do return basic_update("UPDATE achievements SET title = {title.to_sql_string}, slug = {slug.to_sql_string}, description = {desc.to_sql_string}, WHERE id = {id};") end redef class Player serialize # Does `self` already unlocked `achievement`? - fun has_achievement(config: AppConfig, achievement: Achievement): Bool do - return config.achievements.player_has_achievement(self, achievement) + fun has_achievement(achievement: Achievement): Bool do + var res = context.try_select("COUNT(*) FROM achievement_unlocks WHERE player_id = {id} AND achievement_id = {achievement.id};") + return res != null and res.get_count == 1 end # Lists all achievements unlocked by `self` - fun achievements(config: AppConfig): Array[Achievement] do - return config.achievements.find_by_player(self) - end + fun achievements: Array[Achievement] do return context.achievement_worker.fetch_multiple(context, "a.* FROM achievements AS a, achievement_unlocks AS unlocks WHERE unlocks.player_id = {id} AND a.id = unlocks.achievement_id;") # Unlocks `achievement` for `self` # # Return false if `self` already unlocked `achievement` - fun add_achievement(config: AppConfig, achievement: Achievement): Bool do - if has_achievement(config, achievement) then return false - config.achievements.save achievement - add_notification(config, new AchievementUnlockedNotification(achievement)) - return true + fun add_achievement(achievement: Achievement): Bool do + if has_achievement(achievement) then return false + var unlock = new AchievementUnlock(context, achievement.id, id) + if not unlock.commit then return false + var notif = new AchievementUnlocked(context, unlock.id, id, achievement) + return notif.commit end -end -# Achievements repository -class AchievementRepo - super MongoRepository[Achievement] - - fun group_achievements: Array[Achievement] do - var ach_ids = collection.aggregate((new MongoPipeline).group(new MongoGroup("$key"))) - var res = new Array[Achievement] - for id in ach_ids do - var a = find_by_key(id["_id"].as(String)) - if a != null then res.add a - end - return res + # How many achievements have been unlocked? + fun achievement_count: Int do + var res = context.try_select("COUNT(*) FROM achievement_unlocks WHERE player_id = {id};") + if res == null then return 0 + return res.get_count end +end - fun find_by_player(player: Player): Array[Achievement] do - return find_all((new MongoMatch).eq("player._id", player.id)) - end +class AchievementUnlock + super Event - fun player_has_achievement(player: Player, achievement: Achievement): Bool do - return find((new MongoMatch). - eq("player._id", player.id). - eq("key", achievement.key)) != null - end + var achievement_id: Int + var player_id: Int - fun find_by_key(key: String): nullable Achievement do - return find((new MongoMatch).eq("key", key)) - end + redef fun insert do return super and basic_insert("INSERT INTO achievement_unlocks(event_id, achievement_id, player_id) VALUES ({id}, {achievement_id}, {player_id})") end -class AchievementUnlockedNotification +class AchievementUnlocked super PlayerNotification serialize - autoinit(achievement) - - redef var player is lazy do return achievement.player - - var achievement: Achievement redef var object = "Achievement unlocked" - redef var body = "You unlocked a new achievement." + redef var body is lazy do return "You unlocked a new achievement: {achievement.title}" redef var icon = "check" + + # The achievement unlocked + var achievement: Achievement end diff --git a/src/model/engines/engine_base.nit b/src/model/engines/engine_base.nit index 8a466c8..60abf31 100644 --- a/src/model/engines/engine_base.nit +++ b/src/model/engines/engine_base.nit @@ -31,26 +31,27 @@ class Engine # * prepare the compilation and execution environment # * run the compilation&execution in a sanboxed environment # * retrieve the results - fun run(submission: Submission, config: AppConfig) do + fun run(submission: Submission) do submission.status = "pending" + var mission = submission.mission # Not yet completed mission. - if submission.mission.testsuite.is_empty then + if mission == null or mission.testsuite.is_empty then submission.compilation.title = "Work in progress" submission.compilation.message = "There is no test for this mission yet.\nPlease try again later." - set_compilation_error(config, submission) + set_compilation_error(submission) return end var ok = prepare_workspace(submission) if not ok then - set_compilation_error(config, submission) + set_compilation_error(submission) return end ok = execute(submission) if not ok then - set_compilation_error(config, submission) + set_compilation_error(submission) return end @@ -68,15 +69,16 @@ class Engine end submission.time_score = time submission.test_errors = errors - submission.update_status(config) + submission.commit + for i in mission.stars do i.check(submission) end # Mark the `submission` as an error, and save it in the `config` - fun set_compilation_error(config: AppConfig, submission: Submission) do + fun set_compilation_error(submission: Submission) do submission.status = "error" submission.compilation.is_error = true submission.results.clear - submission.update_status(config) + submission.commit end # Execute the compilation and test in a sandboxed environment @@ -162,7 +164,9 @@ class Engine source.write_to_file(sourcefile) # Copy each test input - var tests = submission.mission.testsuite + var mission = submission.mission + if mission == null then return false + var tests = mission.testsuite for test in tests do # Prepare a new test result for the test case var res = new TestResult(test) diff --git a/src/model/friends.nit b/src/model/friends.nit index 42a8ca6..d151a78 100644 --- a/src/model/friends.nit +++ b/src/model/friends.nit @@ -17,159 +17,225 @@ module friends import model::notifications import model::achievements -redef class AppConfig - var friend_requests = new FriendRequestRepo(db.collection("friend_requests")) is lazy +redef class DBContext + + fun friend_request_worker: FriendRequestWorker do return once new FriendRequestWorker + fun friend_request_by_id(id: Int): nullable FriendRequest do return friend_request_worker.fetch_one(self, "ev.id AS id, ev.datetime AS datetime, frd.player_id1 AS from, frd.player_id2 AS to, frd.status AS status FROM events AS ev, friend_events AS frd WHERE ev.id = {id} AND frd.event_id = ev.id;") + + # 0 - Unanswered + fun friend_request_unanswered: Int do return 0 + # 1 - Accepted + fun friend_request_accepted: Int do return 1 + # 2 - Rejected + fun friend_request_rejected: Int do return 2 end -redef class Player - serialize +redef class Statement + fun to_friend_requests(ctx: DBContext): Array[FriendRequest] do + return ctx.friend_request_worker. + fetch_multiple_from_statement(ctx, self) + end +end - # Ids of `self` friend. - var friends = new Array[String] +class FriendRequestWorker + super EntityWorker - # Add a new friend to player - fun add_friend(config: AppConfig, player: Player) do - if player.id == id then return - if friends.is_empty then - add_achievement(config, new FirstFriendAchievement(self)) - end - friends.add player.id - config.players.save self + redef type ENTITY: FriendRequest + + redef fun entity_type do return "FriendRequest" + + redef fun expected_data do return once ["id", "datetime", "from_id", "to_id", "status"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + var date = m["datetime"].as(Int) + var from = m["from_id"].as(Int) + var to = m["to_id"].as(Int) + var status = m["status"].as(Int) + var ret = new FriendRequest(ctx, from, to) + ret.status = status + ret.id = id + ret.timestamp = date + return ret end +end - fun remove_friend(config: AppConfig, player: Player) do - friends.remove player.id - config.players.save self +redef class Player + serialize + + fun unlock_first_friend_achievement do + if friend_count == 1 then + var achievement = context.achievement_by_slug("alone_no_more") + if achievement == null then + achievement = new FirstFriendAchievement(context) + achievement.commit + end + add_achievement(achievement) + end end - # Is `player` an accepted friend of `self`? - fun has_friend(player: Player): Bool do - return friends.has(player.id) + fun friend_count: Int do + var res = context.try_select("COUNT(*) FROM friends WHERE player_id1 = {id};") + if res == null then return 0 + return res.get_count end - fun load_friends(config: AppConfig): Array[Player] do - var res = new Array[Player] - for fid in friends do - var friend = config.players.find_by_id(fid) - if friend == null then continue - res.add friend + fun remove_friend(player: Player): Bool do + var db = context.connection + var query = "DELETE FROM friends WHERE (player_id1 = {id} AND player_id2 = {player.id}) OR (player_id1 = {player.id} AND player_id2 = {id});" + if not db.execute(query) then + print "Unable to remove friend '{player.name}' from database due to error '{db.error or else "Unknown error"}'" + return false end - return res + return true end - # Friend requests received by `self` - fun received_friend_requests(config: AppConfig): Array[FriendRequest] do - return config.friend_requests.find_to_player(self) + # Is `player_id` an accepted friend of `self`? + fun has_friend(player_id: Int): Bool do + var res = context.try_select("COUNT(*) FROM friends WHERE player_id2 = {player_id};") + return res != null and res.get_count != 0 end - # Friend requests sent by `self` - fun sent_friend_requests(config: AppConfig): Array[FriendRequest] do - return config.friend_requests.find_from_player(self) - end + fun friends: Array[Player] do return context.player_worker.fetch_multiple(context, "players.* FROM players, friends WHERE friends.player_id1 = {id} AND players.id = friends.player_id2;") # Does `self` already have a friend request from `player`? - fun has_friend_request_from(config: AppConfig, player: Player): Bool do - return config.friend_requests.find_between(player, self) != null + fun has_friend_request_from(player_id: Int): Bool do + var res = context.try_select("COUNT(*) FROM events AS ev, friend_events AS frd WHERE frd.player_id1 = {player_id} AND frd.status = {context.friend_request_unanswered} AND frd.event_id = ev.id;") + return res != null and res.get_count != 0 end # Create a friend request from `self` to `player` # # Returns the friend request if the request was created. # `null` means the player is already a friend or already has a friend request. - fun ask_friend(config: AppConfig, player: Player): nullable FriendRequest do + fun ask_friend(player: Player): nullable FriendRequest do if self == player then return null - if player.has_friend(self) then return null - if player.has_friend_request_from(config, self) then return null - var fr = new FriendRequest(self, player) - config.friend_requests.save fr - player.add_notification(config, fr.new_notification) + if player.has_friend(id) then return null + if player.has_friend_request_from(id) then return null + var fr = new FriendRequest(context, id, player.id) + fr.commit return fr end - # Accept a friend request - # - # Return `true` is the request has been accepted. - fun accept_friend_request(config: AppConfig, friend_request: FriendRequest): Bool do - if friend_request.to.id != id then return false - if friend_request.from.id == id then return false - add_friend(config, friend_request.from) - friend_request.from.add_friend(config, self) - config.friend_requests.remove_by_id(friend_request.id) - friend_request.from.add_notification(config, friend_request.accept_notification) - return true - end + # Get all open friend requests + fun open_friend_requests: Array[FriendRequest] do return context.friend_request_worker.fetch_multiple(context, "ev.id AS id, ev.datetime AS datetime, frd.player_id1 AS from_id, frd.player_id2 AS to_id, frd.status AS status FROM events AS ev, friend_events AS frd WHERE (frd.player_id2 = {id} OR frd.player_id1 = {id}) AND frd.status = {context.friend_request_unanswered} AND frd.event_id = ev.id;") - # Decline a friend request - # - # Return `true` is the request has been declined. - fun decline_friend_request(config: AppConfig, friend_request: FriendRequest): Bool do - if friend_request.to.id != id then return false - config.friend_requests.remove_by_id(friend_request.id) - return true - end + # All the requests received by `self` + fun received_friend_requests: Array[FriendRequest] do return context.friend_request_worker.fetch_multiple(context, "ev.id AS id, ev.datetime AS datetime, frd.player_id1 AS from_id, frd.player_id2 AS to_id, frd.status AS status FROM events AS ev, friend_events AS frd WHERE frd.player_id2 = {id} AND frd.event_id = ev.id;") + + # All the requests sent by `self` + fun sent_friend_requests: Array[FriendRequest] do return context.friend_request_worker.fetch_multiple(context, "ev.id AS id, ev.datetime AS datetime, frd.player_id1 AS from_id, frd.player_id2 AS to_id, frd.status AS status FROM events AS ev, friend_events AS frd WHERE frd.player_id1 = {id} AND frd.event_id = ev.id;") end class FriendRequest super Event serialize - var from: Player - var to: Player + # Who asked to be friend + var from_id: Int + + # Player object for from + var from: nullable Player is lazy do return context.player_by_id(from_id) - # Build a new notification based on `self` - fun new_notification: FriendRequestNotification do - return new FriendRequestNotification(self) + # To whom is the request targeted? + var to_id: Int + + # Player object for to + var to: nullable Player is lazy do return context.player_by_id(to_id) + + # Status of the request + # + # 0 - Unanswered + # 1 - Accepted + # 2 - Rejected + var status = 0 + + fun accept: Bool do + status = context.friend_request_accepted + var from = from + var to = to + if from == null or to == null then + print "Error: FriendRequest {id} concerns at least one non existing player, {from or else from_id} or {to or else to_id}" + return false + end + var ret = commit + var notif_from = new FriendRequestAcceptNotification(context, id, from_id, to_id) + var notif_to = new FriendRequestAcceptNotification(context, id, to_id, from_id) + notif_from.commit + notif_to.commit + from.unlock_first_friend_achievement + to.unlock_first_friend_achievement + return ret end - # Build a new notification based on `self` - fun accept_notification: FriendRequestAcceptNotification do - return new FriendRequestAcceptNotification(self) + fun decline: Bool do + status = context.friend_request_rejected + var ret = commit + return ret end redef fun ==(o) do return o isa SELF and o.id == id -end -class FriendRequestRepo - super MongoRepository[FriendRequest] - - # Friend request between `player1` and `player2` - fun find_between(player1, player2: Player): nullable FriendRequest do - return find((new MongoMatch).eq("from._id", player1.id).eq("to._id", player2.id)) + redef fun insert do + var from = from + var to = to + if from == null or to == null then + print "Cannot insert friend request to database due to either player not existing" + return false + end + if not (super and basic_insert("INSERT INTO friend_events(event_id, player_id1, player_id2, status) VALUES({id}, {from}, {to}, {status});")) then return false + if status == context.friend_request_accepted then return make_friend + var notif = new FriendRequestNotification(context, id, to_id, from_id) + notif.commit + return true end - # Friend requests from `player` - fun find_from_player(player: Player): Array[FriendRequest] do - return find_all((new MongoMatch).eq("from._id", player.id)) + redef fun update do + if not (super and basic_update("UPDATE friend_events SET status = {status} WHERE event_id = {id};")) then return false + if status == context.friend_request_accepted then return make_friend + return true end - # Friend requests to `player` - fun find_to_player(player: Player): Array[FriendRequest] do - return find_all((new MongoMatch).eq("to._id", player.id)) + fun make_friend: Bool do + var db = context.connection + var query = "INSERT INTO friends(player_id1, player_id2) VALUES ({from_id}, {to_id}), ({to_id}, {from_id});" + if not db.execute(query) then + context.log_sql_error(self, query) + return false + end + return true end end class FriendRequestNotification super PlayerNotification serialize - autoinit(friend_request) - - redef var player is lazy do return friend_request.to - var friend_request: FriendRequest + # Who sent the friend request + var from_id: Int redef var object = "New friend request" - redef var body = "Someone want to be your friend." - redef var icon = "user" + redef var body is lazy do + var p = context.player_by_id(from_id) + if p != null then return "{p.name} wants to be your friend." + return "Error: No player could be found" + end end class FriendRequestAcceptNotification - super FriendRequestNotification + super PlayerNotification serialize - redef var player is lazy do return friend_request.from + # Player receiving the request + var to_id: Int redef var object = "Accepted friend request" - redef var body = "Someone accepted your friend request." + redef var body is lazy do + var p = context.player_by_id(to_id) + if p != null then return "You and {p.name} are now friends." + return "Error: No player could be found" + end end # First friend achievement @@ -178,11 +244,9 @@ end class FirstFriendAchievement super Achievement serialize - autoinit player + autoinit(context) - redef var key = "first_friend" - redef var title = "No more alone" + redef var title = "Alone no more" redef var desc = "Get your first friend." redef var reward = 30 - redef var icon = "user" end diff --git a/src/model/loader.nit b/src/model/loader.nit index 86aaebc..7a9ce47 100644 --- a/src/model/loader.nit +++ b/src/model/loader.nit @@ -13,7 +13,7 @@ module loader -import missions +import stars import markdown private import md5 @@ -28,7 +28,7 @@ redef class ConfigTree end end -redef class AppConfig +redef class DBContext # Load all tracks that are subdirectories of `path`. fun load_tracks(path: String) do # Process files @@ -66,7 +66,8 @@ redef class AppConfig proc.emitter.decorator = new DescDecorator(path, "data") var html = proc.process(content).write_to_string - var track = new Track(title_id, title, html) + var track = new Track(self, title, html, title_id) + track.path = path var ls = ini["languages"] if ls != null then @@ -89,24 +90,29 @@ redef class AppConfig var ss = ini.get_i("star.size.reward") if ss != null then track.default_size_score = ss - var tmpl = (path / "template").to_path.read_all - if not tmpl.is_empty then track.default_template = tmpl - - self.tracks.save track - track.load_missions(self, path) + track.commit + track.load_missions return track end end redef class Track + serialize + # Load the missions from the directory `path`. - fun load_missions(config: AppConfig, path: String) do + # + # NOTE: This creates the missions in the database, use wisely + fun load_missions do + var path = path + if path == null then return var files = path.files.to_a default_comparator.sort(files) var missions = new POSet[Mission] var mission_by_name = new HashMap[String, Mission] + var dependency_map = new HashMap[Mission, Array[String]] + # Process files for f in files do var ff = path / f @@ -127,17 +133,19 @@ redef class Track proc.emitter.decorator = new DescDecorator(ff, "data") var html = proc.process(content).write_to_string - var title_id = self.id + ":" + title.strip_id + var mission_slug = "{slug}:{title.strip_id}" - var m = new Mission(title_id, self, title, html) + var m = new Mission(context, mission_slug, title, id, html) mission_by_name[name] = m + var mission_parents = new Array[String] m.path = ff + m.commit var reqs = ini["req"] if reqs != null then for r in reqs.split(",") do r = r.trim - m.parents.add r + mission_parents.add r end m.solve_reward = ini.get_i("reward") or else default_reward @@ -146,15 +154,17 @@ redef class Track if tg != null then var td = ini["star.time.desc"] or else default_time_desc var ts = ini.get_i("star.time.reward") or else default_time_score - var star = new TimeStar(td, ts, tg) - m.add_star star + var star = new TimeStar(context, td, ts, m.id) + star.goal = tg + star.commit end var sg = ini.get_i("star.size.goal") if sg != null then var sd = ini["star.size.desc"] or else default_size_desc var ss = ini.get_i("star.size.reward") or else default_size_score - var star = new SizeStar(sd, ss, sg) - m.add_star star + var star = new SizeStar(context, sd, ss, m.id) + star.goal = sg + star.commit end var ls = ini["languages"] if ls != null then @@ -168,89 +178,119 @@ redef class Track m.languages.add_all self.default_languages end - var tmpl - tmpl = (ff / "template").to_path.read_all - if tmpl.is_empty then tmpl = self.default_template - m.template = tmpl - - # Load tests, if any. - # This assume the Oto test file format: - # * Testcases start with the line `===` - # * input and output are separated with the line `---` - var tf = ff / "tests.txt" - if tf.file_exists then - var i = "" - var o = "" - var in_input = true - var lines = tf.to_path.read_lines - if lines.first == "===" then lines.shift - lines.add "===" - var n = 0 - for l in lines do - if l == "===" then - n += 1 - var t = new TestCase(n, i, o) - m.testsuite.add t - i = "" - o = "" - in_input = true - else if l == "---" then - in_input = false - else if in_input then - i += l + "\n" - else - o += l + "\n" - end - end - end + dependency_map[m] = mission_parents - print "{ff}: got «{m}»; {m.testsuite.length} tests. languages={m.languages.join(",")}" + #print "{ff}: got «{m}»; {m.testsuite.length} tests. languages={m.languages.join(",")}" missions.add_node m end for m in missions do - # The mangoid of the parents - var reals = new Array[String] - for r in m.parents do + var mpar = dependency_map[m] + var marr = new Array[Mission] + for r in mpar do var rm = mission_by_name.get_or_null(r) if rm == null then print_error "{m}: unknown requirement {r}" else if missions.has_edge(rm, m) then print_error "{m}: circular requirement with {rm}" else + marr.add rm missions.add_edge(m, rm) - reals.add rm.id end end - - # replace parents' id and save - m.parents.clear - m.parents.add_all reals - config.missions.save(m) + m.parents = marr + m.commit end end # List of default allowed languages - var default_languages = new Array[String] + var default_languages = new Array[String] is noserialize # Default reward for a solved mission - var default_reward = 10 + var default_reward = 10 is noserialize # Default description of a time star - var default_time_desc = "Instruction CPU" + var default_time_desc = "Instruction CPU" is noserialize # Default reward for a time star - var default_time_score = 10 + var default_time_score = 10 is noserialize # Default description of a size star - var default_size_desc = "Taille du code machine" + var default_size_desc = "Taille du code machine" is noserialize # Default reward for a size star - var default_size_score = 10 + var default_size_score = 10 is noserialize # Default template for the source code - var default_template: nullable String = null + var default_template: nullable String is lazy do + var p = path + if p == null then return null + var tmpl_path = (p / "template").to_path + if tmpl_path.exists then return tmpl_path.read_all + return null + end +end + +redef class Mission + serialize + + # The set of unit tests used to validate the mission + # + # This is done in `Mission` instead of a subclass to limit the number of classes + # and maybe simplify the serialization/API. + # If a mission has no test-case, an empty array should be enough for now. + var testsuite: Array[TestCase] is lazy do + var ff = path + var tests = new Array[TestCase] + if ff == null or ff.is_empty then return tests + # Load tests, if any. + # This assume the Oto test file format: + # * Testcases start with the line `===` + # * input and output are separated with the line `---` + var tf = ff / "tests.txt" + #print "Test path is {tf}, exists? {tf.file_exists}" + if tf.file_exists then + var i = "" + var o = "" + var in_input = true + var lines = tf.to_path.read_lines + if lines.first == "===" then lines.shift + lines.add "===" + var n = 0 + for l in lines do + if l == "===" then + n += 1 + var t = new TestCase(i, o, n) + tests.add t + i = "" + o = "" + in_input = true + else if l == "---" then + in_input = false + else if in_input then + i += l + "\n" + else + o += l + "\n" + end + end + end + return tests + end + + # Template for the source code + var template: nullable String is lazy do + var path = path + if path == null then return null + var tmpl + tmpl = (path / "template").to_path.read_all + if tmpl.is_empty then + var t = track + if t == null then return null + return t.default_template + end + return tmpl + end end class DescDecorator @@ -321,30 +361,3 @@ class DescDecorator end end end - -redef class String - # Replace sequences of non-alphanumerical characters by underscore. - # - # ~~~ - # assert "abcXYZ123_".strip_id == "abcXYZ123_" - # assert ", 'A[]\nB#$_".strip_id == "_A_B_" - # ~~~ - fun strip_id: String - do - var res = new Buffer - var sp = false - for c in chars do - if not c.is_alphanumeric then - sp = true - continue - end - if sp then - res.add '_' - sp = false - end - res.add c - end - if sp then res.add '_' - return res.to_s - end -end diff --git a/src/model/missions.nit b/src/model/missions.nit index ef84942..e9524b3 100644 --- a/src/model/missions.nit +++ b/src/model/missions.nit @@ -11,99 +11,252 @@ # 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. - module missions -import model::tracks -import mongodb::queries +import model::players + +redef class DBContext + + # Cache for missions by their ID + # + # Used for fast fetching and shared instances + var mission_cache = new HashMap[Int, Mission] + + fun mission_worker: MissionWorker do return once new MissionWorker + + fun mission_status_worker: MissionStatusWorker do return once new MissionStatusWorker + + fun mission_by_id(id: Int): nullable Mission do + if mission_cache.has_key(id) then return mission_cache[id] + var m = mission_worker.fetch_one(self, "* FROM missions WHERE id = {id};") + if m != null then mission_cache[id] = m + return m + end + + fun mission_by_slug(slug: String): nullable Mission do return mission_worker.fetch_one(self, "* FROM missions WHERE slug = {slug.to_sql_string};") + + fun all_missions: Array[Mission] do return mission_worker.fetch_multiple(self, "* FROM missions;") + + ## Mission status codes + + fun mission_locked: Int do return 1 + fun mission_open: Int do return 2 + fun mission_success: Int do return 3 + + var mission_status: Array[String] = ["locked", "open", "success"] + + ## +end + +class MissionWorker + super EntityWorker + + redef type ENTITY: Mission + + redef fun entity_type do return "Mission" + + redef fun expected_data do return once ["id", "slug", "title", "track_id", "description", "reward", "path"] -redef class AppConfig - var missions = new MissionRepo(db.collection("missions")) is lazy + redef fun make_entity_from_row(ctx, row) do + var map = row.map + var id = map["id"].as(Int) + if ctx.mission_cache.has_key(id) then return ctx.mission_cache[id] + var slug = map["slug"].as(String) + var title = map["title"].as(String) + var tid = map["track_id"].as(Int) + var desc = map["description"].as(String) + var rew = map["reward"].as(Int) + var ret = new Mission(ctx, slug, title, tid, desc) + if map["path"] != null then ret.path = map["path"].as(String) + ret.id = id + ret.solve_reward = rew + ret.load_languages + ctx.mission_cache[id] = ret + return ret + end end -redef class Track - fun missions(config: AppConfig): Array[Mission] do - return config.missions.find_by_track(self) +class MissionStatusWorker + super EntityWorker + + redef type ENTITY: MissionStatus + + redef fun entity_type do return "MissionStatus" + + redef fun expected_data do return ["mission_id", "player_id", "status"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var mid = m["mission_id"].as(Int) + var pid = m["player_id"].as(Int) + var status = m["status"].as(Int) + var res = new MissionStatus(ctx, mid, pid, status) + res.persisted = true + return res + end +end + +redef class Statement + # Gets all the Mission items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of a Mission + fun to_missions(ctx: DBContext): Array[Mission] do + return ctx.mission_worker. + fetch_multiple_from_statement(ctx, self) + end + + fun to_mission_statuses(ctx: DBContext): Array[MissionStatus] do + return ctx.mission_status_worker. + fetch_multiple_from_statement(ctx, self) end end class Mission - super Entity + super UniqueEntity serialize - redef var id - var track: nullable Track + var slug: String var title: String + var track_id: Int var desc: String + # Reward for solving the mission (excluding stars) + var solve_reward: Int = 0 is writable, serialize_as("reward") + var path: nullable String is writable - # List of allowed languages var languages = new Array[String] - var parents = new Array[String] - var stars = new Array[MissionStar] - var path: nullable String = null is writable + fun load_languages do + var db = context.connection + var query = "languages.name FROM mission_languages, languages WHERE mission_languages.mission_id = {id} AND languages.id = mission_languages.language_id;" + var res = db.select(query) + if res == null then + context.log_sql_error(context, query) + return + end + for i in res do languages.add(i[0].to_s) + end - # Number of points to solve the mission (excluding stars) - var solve_reward: Int = 1 is writable + redef fun to_s do return title - # Template for the source code - var template: nullable String = null is writable + var parents: Array[Mission] is lazy, writable do return context.mission_worker.fetch_multiple(context, "missions.* FROM missions, mission_dependencies WHERE mission_dependencies.mission_id = {id} AND missions.id = mission_dependencies.parent_id;") - fun add_star(star: MissionStar) do stars.add star + var children: Array[Mission] is lazy, noserialize do return context.mission_worker.fetch_multiple(context, "missions.* FROM missions, mission_dependencies WHERE mission_dependencies.parent_id = {id} AND mission_dependencies.mission_id = missions.id;") - # Total number of points for the mission (including stars) - var reward: Int is lazy do - var r = solve_reward - for star in stars do r += star.reward - return r + # Sets the dependencies for a Mission within database + # + # REQUIRE: `self.id` != -1 + # NOTE: No cycle detection is performed here, careful when setting + # dependencies + fun set_dependencies: Bool do + var db = context.connection + var clean_query = "DELETE FROM mission_dependencies WHERE mission_id = {id};" + var insert_query = "INSERT INTO mission_dependencies(mission_id, parent_id) VALUES " + var values = new Array[String] + for i in parents do values.add("({id}, {i.id})") + insert_query += values.join(", ") + if not db.execute(clean_query) then + print "Error when setting dependencies: {db.error or else "Unknown Error"}" + return false + end + if parents.length == 0 then return true + if not db.execute(insert_query) then + print "Error when setting dependencies: {db.error or else "Unknown Error"}" + return false + end + return true end - redef fun to_s do return title + # How much can `self` reward for full completion? (Including stars) + fun total_reward: Int do + var db = context.connection + var rows = db.select("score FROM stars WHERE mission_id = {id};") + var score = solve_reward + if rows != null then for i in rows do score += i.map["score"].as(Int) + db.close + return score + end - # The set of unit tests used to validate the mission - # - # This is done in `Mission` instead of a subclass to limit the number of classes - # and maybe simplify the serialization/API. - # If a mission has no test-case, an empty array should be enough for now. - var testsuite = new Array[TestCase] - - # Load mission parents from DB - fun load_parents(config: AppConfig): Array[Mission] do - var parents = new Array[Mission] - for parent_id in self.parents do - var parent = config.missions.find_by_id(parent_id) - if parent == null then continue - parents.add parent - end - return parents + # Which missions do `self` depend on? + fun dependencies: Array[Mission] do return context.mission_worker.fetch_multiple(context, "missions.* FROM missions, mission_dependencies WHERE mission_id = {id};") + + # Is `self` unlocked for `player` ? + fun is_unlocked_for_player(player_id: Int): Bool do + var ret = context.try_select("COUNT(*) FROM mission_status WHERE player_id = {player_id} AND mission_id = {id} AND (status = {context.mission_open} OR status = {context.mission_success});") + return ret != null and ret.get_count != 0 end - # Load mission parents from DB - fun load_children(config: AppConfig): Array[Mission] do - var children = new Array[Mission] + redef fun insert do + var p = path + if p != null then p = p.to_sql_string + var ret = basic_insert("INSERT INTO missions(slug, title, track_id, description, reward, path) VALUES({slug.to_sql_string}, {title.to_sql_string}, {track_id}, {desc.to_sql_string}, {solve_reward}, {p or else "NULL"});") and set_dependencies + if ret then context.mission_cache[id] = self + return ret + end - var track = self.track - if track == null then return children + redef fun update do + var p = path + if p != null then p = p.to_sql_string + return basic_update("UPDATE missions SET slug = {slug.to_sql_string}, title = {title.to_sql_string}, track_id = {track_id}, description = {desc.to_sql_string}, reward = {solve_reward}, path = {p or else "NULL"} WHERE id = {id};") and set_dependencies + end - for mission in track.missions(config) do - for parent_id in mission.parents do - if parent_id != self.id then continue - var child = config.missions.find_by_id(mission.id) - if child == null then continue - children.add child + fun status_for(player_id: Int): nullable MissionStatus do + var ret = context.mission_status_worker.fetch_one(context, "* FROM mission_status WHERE mission_id = {id} AND player_id = {player_id};") + if ret != null then return ret + var deps = parents + var mstat = context.mission_locked + var unlocked = true + for i in deps do + var istat = i.status_for(player_id) + # Should never happen, if it does, we have a serious problem which needs a quick fix. + if istat == null then return null + if not istat.status == context.mission_success then + unlocked = false + break end end - return children + if unlocked then mstat = context.mission_open + var status = new MissionStatus(context, id, player_id, mstat) + status.commit + return status end end -class MissionRepo - super MongoRepository[Mission] +class MissionStatus + super BridgeEntity + serialize - fun find_by_track(track: nullable Track): Array[Mission] do - if track == null then return find_all - return find_all((new MongoMatch).eq("track._id", track.id)) + var mission_id: Int + var player_id: Int + var status_code: Int is writable + var status: String is lazy do + if status_code < 1 or status_code > 3 then return "locked" + return context.mission_status[status_code - 1] end + + redef fun insert do return basic_insert("INSERT INTO mission_status(mission_id, player_id, status) VALUES ({mission_id}, {player_id}, {status_code})") + + redef fun update do return basic_update("UPDATE mission_status SET status = {status_code} WHERE player_id = {player_id} AND mission_id = {mission_id}") +end + +redef class Player + + fun open_missions_count: Int do + var res = context.try_select("COUNT(*) FROM mission_status WHERE mission_status.player_id = {id} AND mission_status.status = {context.mission_open} OR mission_status.status = {context.mission_success};") + if res == null then return 0 + return res.get_count + end + + fun open_missions: Array[Mission] do return context.mission_worker.fetch_multiple(context, "missions.* FROM missions, mission_status WHERE mission_status.player_id = {id} AND mission_status.status = {context.mission_open} AND mission_status.mission_id = missions.id;") + + fun successful_missions_count: Int do + var res = context.try_select("COUNT(*) FROM mission_status WHERE mission_status.player_id = {id} AND mission_status.status = {context.mission_success}") + if res == null then return 0 + return res.get_count + end + + fun successful_missions: Array[Mission] do return context.mission_worker.fetch_multiple(context, "missions.* FROM missions, mission_status WHERE mission_status.player_id = {id} AND mission_status.status = {context.mission_success} AND mission_status.mission_id = missions.id") + end # A single unit test on a mission @@ -112,43 +265,12 @@ end class TestCase serialize - # The number of the test in the test-suite (starting with 1) - var number: Int - # The input that is feed to the tested program. var provided_input: String # The expected response from the program for `provided_input`. var expected_output: String -end - -# Mission requirements -class MissionStar - super Entity - serialize - - # The star explanation - var title: String - - # The reward (in points) accorded when this star is unlocked - var reward: Int -end - -# For stars that asks the player to minimize a quantity -class ScoreStar - super MissionStar - serialize - - # The value to earn the star - var goal: Int -end -class SizeStar - super ScoreStar - serialize -end - -class TimeStar - super ScoreStar - serialize + # The number of the test in the test-suite (starting with 1) + var number: Int end diff --git a/src/model/model_base.nit b/src/model/model_base.nit index bf3c762..d8dacb0 100644 --- a/src/model/model_base.nit +++ b/src/model/model_base.nit @@ -16,6 +16,52 @@ module model_base import config +import sqlite3 + +# Context for database-related queries +class DBContext + super FinalizableOnce + + # Connection to the database + var connection = new Sqlite3DB.open(sqlite_address) + + # Log a database error + # + # TODO: Use logger when available + fun log_sql_error(thrower: Object, query: String) do + print "Database error: '{connection.error or else "Unknown error"}' in class {thrower.class_name}" + print "Query was `{query}`" + end + + # Databse address + fun sqlite_address: String do return "Missions" + + # What to do when starting a `with` block + fun start do end + # What to do when finishing a `with` block + fun finish do connection.close + + redef fun finalize do connection.close + + # Try selecting data and log errors if there are some + fun try_select(query: String): nullable Statement do + var res = connection.select(query) + if res == null then + log_sql_error(self, query) + return null + end + return res + end +end + +redef class Statement + # Use this function with `COUNT` statements for easy retrieval + fun get_count: Int do + var cnt = 0 + for i in self do cnt += i[0].to_i + return cnt + end +end # Base model entity # @@ -24,22 +70,184 @@ abstract class Entity super Jsonable serialize - # `self` unique id. - var id: String = (new MongoObjectId).id is serialize_as "_id" + # Context to which the database is linked + var context: DBContext is noserialize, writable - redef fun to_s do return id - redef fun ==(o) do return o isa SELF and id == o.id - redef fun hash do return id.hash redef fun to_json do return serialize_to_json + + # Commit `self` to database + fun commit: Bool is abstract + + # Insert a new `self` to database + fun insert: Bool is abstract + + # Basic insertion method for factorization purposes + protected fun basic_insert(query: String): Bool do + var db = context.connection + if not db.execute(query) then + context.log_sql_error(self, query) + return false + end + return true + end + + # Update `self` to database + fun update: Bool is abstract + + # Basic update method for factorization purposes + protected fun basic_update(query: String): Bool do + var db = context.connection + if not db.execute(query) then + context.log_sql_error(self, query) + return false + end + return true + end +end + +# Any entity which posesses a unique ID +abstract class UniqueEntity + super Entity + serialize + + redef fun to_s do return id.to_s + redef fun ==(o) do return o isa SELF and id == o.id + redef fun hash do return id + + # `self` unique id. + var id: Int = -1 is serialize_as "_id", writable + + redef fun commit do + if id == -1 then return insert + return update + end + + redef fun basic_insert(q) do + var ret = super + if ret then id = context.connection.last_insert_rowid + return ret + end +end + +# Entities which are a bridge with status between two entities +# +# These entities do not posess a single ID, but rather several foreign +# keys as primary key. +abstract class BridgeEntity + super Entity + + # Has `self` been persisted? + var persisted = false is writable + + redef fun commit do + if not persisted then return insert + return update + end + + redef fun basic_insert(query) do + var res = super + if res then persisted = true + return res + end end # Something that occurs at some point in time abstract class Event - super Entity + super UniqueEntity serialize # Timestamp when this event occurred. - var timestamp: Int = get_time + var timestamp: Int is lazy, writable do return get_time + + redef fun insert do + var db = context.connection + var query = "INSERT INTO events(datetime) VALUES ({timestamp});" + if not db.execute(query) then + print "Unable to create new Event" + return false + end + id = db.last_insert_rowid + return true + end + + redef fun update do return true +end + +# A worker specialized in getting data from Database Statements +abstract class EntityWorker + # The kind of entity `self` supports + type ENTITY: Entity + + # Checks the content of a row for compatibility with an object `ENTITY` + fun check_data(row: StatementRow): Bool do + var m = row.map + for i in expected_data do + if not m.has_key(i) then + print "Missing data `{i}` in map for `{entity_type}`" + print "map was {m.join("\n", ": ")}" + return false + end + end + return true + end + + # Tries to fetch an entity from a row. + fun perform(ctx: DBContext, row: StatementRow): nullable ENTITY do + if not check_data(row) then return null + return make_entity_from_row(ctx, row) + end + + # Fetch one `ENTITY` from DB with `query` + fun fetch_one(ctx: DBContext, query: String): nullable ENTITY do + var res = ctx.try_select(query) + if res == null then + ctx.log_sql_error(self, query) + return null + end + return fetch_one_from_statement(ctx, res) + end + + # Fetch multiple `ENTITY` from DB with `query` + fun fetch_multiple(ctx: DBContext, query: String): Array[ENTITY] do + var res = ctx.try_select(query) + if res == null then + ctx.log_sql_error(self, query) + return new Array[ENTITY] + end + return fetch_multiple_from_statement(ctx, res) + end + + # Fetch multiple `ENTITY` from DB with `rows` + fun fetch_one_from_statement(ctx: DBContext, row: Statement): nullable ENTITY do + var ret = fetch_multiple_from_statement(ctx, row) + if ret.is_empty then return null + return ret.first + end + + # Fetch multiple `ENTITY` from DB with `rows` + fun fetch_multiple_from_statement(ctx: DBContext, rows: Statement): Array[ENTITY] do + var ret = new Array[ENTITY] + for i in rows do + var el = perform(ctx, i) + if el == null then + print "Error when deserializing `{entity_type}` from database" + print "Got `{i.map}`" + ret.clear + break + end + ret.add el + end + return ret + end + + # Which data is expected in a map? + fun expected_data: Array[String] is abstract + + # Returns a user-readable version of `ENTITY` + fun entity_type: String is abstract + + # Buils an entity from a Database Row + fun make_entity_from_row(ctx: DBContext, row: StatementRow): ENTITY is abstract end # Remove inner references from JSON serialization @@ -50,3 +258,35 @@ redef class JsonSerializer # Remove caching when saving refs to db redef fun serialize_reference(object) do serialize object end + +redef class String + # Replace sequences of non-alphanumerical characters by underscore. + # Also trims additional `_` at the beginning and the end of the string. + # All uppercase alpha characters will be morphed into lowercase. + # + # ~~~ + # assert "abcXYZ123_".strip_id == "abcxyz123" + # assert ", 'A[]\nB#$_".strip_id == "a_b" + # ~~~ + fun strip_id: String + do + var res = new Buffer + var sp = false + for c in chars do + if not c.is_alphanumeric then + sp = true + continue + end + if sp then + res.add '_' + sp = false + end + res.add c.to_lower + end + var st = 0 + while res[st] == '_' do st += 1 + var ed = res.length - 1 + while res[ed] == '_' do ed -= 1 + return res.to_s.substring(st, ed - st + 1) + end +end diff --git a/src/model/notifications.nit b/src/model/notifications.nit index d01ff47..100140a 100644 --- a/src/model/notifications.nit +++ b/src/model/notifications.nit @@ -16,56 +16,77 @@ module notifications import model::players -redef class AppConfig - var notifications = new PlayerNotificationRepo(db.collection("notifications")) is lazy +redef class DBContext + fun notification_worker: NotificationWorker do return once new NotificationWorker + + fun notification_by_id(id: Int): nullable PlayerNotification do return notification_worker.fetch_one(self, "* FROM notifications WHERE id = {id};") end -redef class Player - fun notifications(config: AppConfig): Array[PlayerNotification] do - return config.notifications.find_by_player(self) +redef class Statement + fun to_notifications(ctx: DBContext): Array[PlayerNotification] do + return ctx.notification_worker. + fetch_multiple_from_statement(ctx, self) end +end - fun add_notification(config: AppConfig, notification: PlayerNotification) do - config.notifications.save notification - end +class NotificationWorker + super EntityWorker - fun clear_notifications(config: AppConfig): Bool do - return config.notifications.remove_by_player(self) + redef type ENTITY: PlayerNotification + + redef fun entity_type do return "PlayerNotification" + + redef fun expected_data do return once ["id", "event_id", "player_id", "object", "body", "read", "timestamp"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + var pid = m["player_id"].as(Int) + var obj = m["object"].as(String) + var body = m["body"].as(String) + var eid = m["event_id"].as(Int) + var read = m["read"].as(Int) == 1 + var timestamp = m["timestamp"].as(Int) + var ret = new PlayerNotification(ctx, eid, pid) + ret.object = obj + ret.body = body + ret.id = id + ret.read = read + ret.timestamp = timestamp + return ret end +end - fun clear_notification(config: AppConfig, notification: PlayerNotification): Bool do - if id != notification.player.id then return false - return config.notifications.remove_by_id(notification.id) +redef class Player + fun notifications: Array[PlayerNotification] do return context.notification_worker.fetch_multiple(context, "* FROM notifications, events WHERE notifications.player_id = {id} AND events.id = notifications.event_id;") + + fun open_notifications: Array[PlayerNotification] do return context.notification_worker.fetch_multiple(context, "* FROM notifications, events WHERE notifications.player_id = {id} AND events.id = notifications.event_id AND notifications.read = 0;") + + fun clear_notifications: Bool do + var query = "UPDATE notifications SET read = 1 WHERE notifications.read = 0 AND notifications.player_id = {id}" + return context.connection.execute(query) end end -# Player representation -# -# Each player is linked to a Github user +# Notification of an event to a player class PlayerNotification - super Event + super UniqueEntity serialize - var player: Player - var object: String - var body: String + var event_id: Int + var player_id: Int + var timestamp: Int is lazy do return get_time + var object: String is noinit + var body: String is noinit + var read = false var icon = "envelope" -end - -class PlayerNotificationRepo - super MongoRepository[PlayerNotification] - redef fun find_all(q, s, l) do - var oq = new MongoMatch - if q isa MongoMatch then oq = q - return aggregate((new MongoPipeline).match(oq).sort((new MongoMatch).eq("timestamp", -1))) + fun clear: Bool do + read = true + return commit end - fun find_by_player(player: Player): Array[PlayerNotification] do - return find_all((new MongoMatch).eq("player._id", player.id)) - end + redef fun insert do return basic_insert("INSERT INTO notifications(event_id, player_id, object, body, read, timestamp) VALUES({event_id}, {player_id}, {object.to_sql_string}, {body.to_sql_string}, 0, {timestamp});") - fun remove_by_player(player: Player): Bool do - return remove_all((new MongoMatch).eq("player._id", player.id)) - end + redef fun update do return basic_update("UPDATE notifications SET read = {if read then 1 else 0} WHERE id = {id};") end diff --git a/src/model/players.nit b/src/model/players.nit index ad21db8..ea77105 100644 --- a/src/model/players.nit +++ b/src/model/players.nit @@ -16,28 +16,90 @@ module players import model::model_base -redef class AppConfig - var players = new PlayerRepo(db.collection("players")) is lazy +redef class DBContext + + var player_cache = new HashMap[Int, Player] + + fun player_worker: PlayerWorker do return once new PlayerWorker + + # Tries to find a player item from its `id` + fun player_by_id(id: Int): nullable Player do + if player_cache.has_key(id) then return player_cache[id] + var p = player_worker.fetch_one(self, "* FROM players WHERE id = {id};") + if p != null then player_cache[id] = p + return p + end + + # Gets the `limit` top players by score + fun all_players: Array[Player] do return player_worker.fetch_multiple(self, "* FROM players;") + + # Gets a player by its slug + fun player_by_slug(slug: String): nullable Player do return player_worker.fetch_one( self, "* FROM players WHERE slug = {slug.to_sql_string};") +end + +class PlayerWorker + super EntityWorker + + redef type ENTITY: Player + + redef fun entity_type do return "Player" + + redef fun expected_data do return once ["id", "slug", "name", "email", "avatar_url", "date_joined"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + if ctx.player_cache.has_key(id) then return ctx.player_cache[id] + var slug = m["slug"].as(String) + var name = m["name"].as(String) + var email = m["email"].as(String) + var avatar_url = m["avatar_url"].as(String) + var date = m["date_joined"].as(Int) + var p = new Player(ctx, slug, name, email, avatar_url) + p.id = id + p.date_joined = date + ctx.player_cache[id] = p + return p + end +end + +redef class Statement + # Gets all the Player items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of a Player + fun to_players(ctx: DBContext): Array[Player] do + return ctx.player_worker. + fetch_multiple_from_statement(ctx, self) + end end # Player representation class Player - super Entity + super UniqueEntity serialize - autoinit id, name, email, avatar_url - redef var id + # The user-readable identifier + var slug: String is writable # The screen name - var name: nullable String is writable + var name: String is writable # The email - var email: nullable String is writable + var email: String is writable # The image to use as avatar - var avatar_url: nullable String is writable -end + var avatar_url: String is writable + + # Date at which `self` has joined the game (UNIX Timestamp) + var date_joined: Int = -1 is writable + + redef fun insert do + if date_joined == -1 then date_joined = get_time + var ret = basic_insert("INSERT INTO players(slug, name, date_joined, email, avatar_url) VALUES({slug.to_sql_string}, {name.to_sql_string}, {date_joined}, {email.to_sql_string}, {avatar_url.to_sql_string});") + if ret then context.player_cache[id] = self + return ret + end -class PlayerRepo - super MongoRepository[Player] + redef fun update do return basic_update("UPDATE players SET name = {name.to_sql_string}, email = {email.to_sql_string}, avatar_url = {avatar_url.to_sql_string} WHERE id = {id};") end diff --git a/src/model/stars.nit b/src/model/stars.nit new file mode 100644 index 0000000..c3181e1 --- /dev/null +++ b/src/model/stars.nit @@ -0,0 +1,287 @@ +# 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. + +module stars + +import model::tracks + +redef class DBContext + fun star_worker: StarWorker do return once new StarWorker + + fun star_status_worker: StarStatusWorker do return once new StarStatusWorker + + # The types of stars and their related ID + # + # NOTE: Requires manual database synchronization + var star_types = new HashMap[String, Int] + + init do + star_types["Size"] = 1 + star_types["Time"] = 2 + end + + fun star_by_id(id: Int): nullable MissionStar do return star_worker.fetch_one(self, "* FROM stars WHERE id = {id};") + + ## Star status codes + fun star_locked: Int do return 1 + fun star_unlocked: Int do return 2 + # + + fun star_count_track(track_id: Int): Int do + var res = try_select("COUNT(*) FROM stars, missions WHERE missions.track_id = {track_id} AND missions.id = stars.mission_id;") + if res == null then return 0 + return res.get_count + end +end + +redef class Statement + # Gets all the Star items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of a Star + fun to_stars(ctx: DBContext): Array[MissionStar] do + return ctx.star_worker. + fetch_multiple_from_statement(ctx, self) + end + + # Gets all the StarStatus items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of a StarStatus + fun to_star_statuses(ctx: DBContext): Array[StarStatus] do + return ctx.star_status_worker. + fetch_multiple_from_statement(ctx, self) + end +end + +class StarWorker + super EntityWorker + + redef type ENTITY: MissionStar + + redef fun entity_type do return "MissionStar" + + redef fun expected_data do return once ["id", "title", "mission_id", "score", "reward", "type_id"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + var title = m["title"].as(String) + var mid = m["mission_id"].as(Int) + var score = m["score"].as(Int) + var reward = m["reward"].as(Int) + var tid = m["type_id"].as(Int) + var ret = new MissionStar(ctx, title, reward, mid, tid) + ret.id = id + ret.goal = score + return ret + end +end +class StarStatusWorker + super EntityWorker + + redef type ENTITY: StarStatus + + redef fun entity_type do return "StarStatus" + + redef fun expected_data do return once ["star_id", "player_id", "status"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var sid = m["star_id"].as(Int) + var pid = m["player_id"].as(Int) + var status = m["status"].as(Int) + var ret = new StarStatus(ctx, pid, sid) + ret.is_unlocked = status != 0 + ret.persisted = true + return ret + end +end + +# Mission requirements +abstract class MissionStar + super UniqueEntity + serialize + + # The star explanation + var title: String + + # The reward (in points) accorded when this star is unlocked + var reward: Int + + # The mission `self` is attached to + var mission_id: Int + + # The value to earn the star + var goal = 0 is writable + + new(context: DBContext, title: String, reward, mission_id, type_id: Int) do + if type_id == 1 then return new SizeStar(context, title, reward, mission_id) + if type_id == 2 then return new TimeStar(context, title, reward, mission_id) + # Add more star types to `new` as they are added to model + abort + end + + # The name of the type + fun type_name: String is abstract + + # Identifier for the type of star + fun type_id: Int do return context.star_types[type_name] + + fun status(player_id: Int): nullable StarStatus do return context.star_status_worker.fetch_one(context, "* FROM star_status WHERE player_id = {player_id} AND star_id = {id}") + + redef fun insert do return basic_insert("INSERT INTO stars(title, mission_id, type_id, reward, score) VALUES({title.to_sql_string}, {mission_id}, {type_id}, {reward}, {goal});") + + redef fun update do return basic_update("UPDATE stars SET mission_id = {mission_id}, reward = {reward}, score = {goal}, title = {title.to_sql_string} WHERE id = {id};") + +end + +class SizeStar + super MissionStar + serialize + + redef fun type_name do return "Size" +end + +class TimeStar + super MissionStar + serialize + + redef fun type_name do return "Time" +end + +# The link between a Player and a Star +class StarStatus + super BridgeEntity + serialize + + # The player associated to the status + var player_id: Int + + # The associated star + var star_id: Int + + # Is the star granted? + var is_unlocked = false is writable + + redef fun insert do return basic_insert("INSERT INTO star_status(star_id, player_id, status) VALUES({star_id}, {player_id}, {if is_unlocked then 1 else 0});") + + redef fun update do return basic_update("UPDATE star_status SET status = {if is_unlocked then 1 else 0} WHERE star_id = {star_id} AND player_id = {player_id};") + +end + +redef class Mission + serialize + var stars: Array[MissionStar] is lazy do + var db = context.connection + var rows = db.select("stars.* FROM stars WHERE mission_id = {id};") + if rows == null then + print "Error when querying for stars '{db.error or else "Unknown error"}'" + return new Array[MissionStar] + end + return rows.to_stars(context) + end + + fun star_statuses(player_id: Int): Array[StarStatus] do + var stars = stars + var stats = context.star_status_worker.fetch_multiple(context, "star_status.* FROM missions, star_status, stars WHERE star_status.star_id = stars.id AND stars.mission_id = missions.id AND missions.id = {id};") + if stars.length == stats.length then return stats + for i in stars do + var found = false + for j in stats do + if j.star_id == i.id then + found = true + break + end + end + if not found then + var s = new StarStatus(context, player_id, i.id) + s.commit + stats.add s + end + end + return stats + end + + fun success_for(pid: Int) do + var status = status_for(pid) + if status == null then status = new MissionStatus(context, id, pid, context.mission_success) + status.status_code = context.mission_success + var chlds = children + for i in chlds do + var can_unlock = true + var deps = i.parents + for j in deps do + var pstat = j.status_for(pid) + if pstat == null or pstat.status == context.mission_locked then + can_unlock = false + end + end + if not can_unlock then continue + var stat = i.status_for(pid) + if stat == null or stat.status == context.mission_success then continue + stat.status_code = context.mission_open + stat.commit + end + status.commit + end +end + +redef class MissionStatus + serialize + var star_status: Array[StarStatus] is lazy do + var mission = context.mission_by_id(mission_id) + if mission == null then return new Array[StarStatus] + return mission.star_statuses(player_id) + end +end + +redef class Track + fun stars: Array[MissionStar] do + var db = context.connection + var rows = db.select("stars.* FROM stars, missions WHERE missions.track_id = {id} AND stars.mission_id = mission.id;") + if rows == null then + print "Error when querying for stars '{db.error or else "Unknown Error"}'" + return new Array[MissionStar] + end + return rows.to_stars(context) + end + + fun star_statuses_for(player_id: Int): Array[StarStatus] do + var m = missions + var stats = new Array[StarStatus] + for i in m do stats.add_all i.star_statuses(player_id) + return stats + end +end + +redef class Player + fun star_count: Int do + var res = context.try_select("COUNT(*) FROM star_status WHERE player_id = {id} AND status = 1;") + if res == null then return 0 + return res.get_count + end + + fun unlocked_stars: Array[MissionStar] do return context.star_worker.fetch_multiple(context, "stars.* FROM stars, star_status AS stat WHERE stat.player_id = {id} AND stat.status = {context.star_unlocked};") +end + +redef class TrackStatus + serialize + # Star count for track `track_id` + var stars_count: Int is lazy do return context.star_count_track(track_id) + # Unlocked stars for `player_id` in track `track_id` + var stars_unlocked: Int is lazy do + var res = context.try_select("COUNT(*) FROM star_status, stars, missions WHERE missions.track_id = {track_id} AND star_status.player_id = {player_id} AND stars.mission_id = missions.id AND star_status.star_id = stars.id AND star_status.status = 1;") + if res == null then return 0 + return res.get_count + end +end diff --git a/src/model/stats.nit b/src/model/stats.nit index 212ac5f..4b233fa 100644 --- a/src/model/stats.nit +++ b/src/model/stats.nit @@ -14,44 +14,61 @@ module stats -import model::status +import model::stars import model::achievements -redef class AppConfig +redef class DBContext fun players_ranking: Array[PlayerStats] do - var res = new Array[PlayerStats] - for player in players.find_all do - res.add player.stats(self) - end - default_comparator.sort(res) - return res + var pls = all_players + var stats = new Array[PlayerStats] + for i in pls do stats.add(i.stats) + return stats + end + + fun mission_count: Int do + var res = try_select("COUNT(*) FROM missions;") + if res == null then return 0 + return res.get_count + end + + fun star_count: Int do + var res = try_select("COUNT(*) FROM stars;") + if res == null then return 0 + return res.get_count end end redef class Player - fun stats(config: AppConfig): PlayerStats do - var stats = new PlayerStats(self) - for track_status in tracks_status(config) do - stats.missions_count += track_status.missions_count - stats.missions_locked += track_status.missions_locked - stats.missions_open += track_status.missions_open - stats.missions_success += track_status.missions_success - stats.stars_count += track_status.stars_count - stats.stars_unlocked += track_status.stars_unlocked - for mission_status in track_status.missions do - if mission_status.is_success then - stats.score += mission_status.mission.solve_reward - end - for star in mission_status.unlocked_stars do - stats.score += star.reward - end - end - end - for a in achievements(config) do - stats.score += a.reward - stats.achievements += 1 - end + fun achievement_score: Int do + var res = context.try_select("SUM(a.reward) FROM achievements AS a, achievement_unlocks AS au WHERE au.player_id = {id} AND au.achievement_id = a.id;") + if res == null then return 0 + return res.get_count + end + + fun mission_score: Int do + var res = context.try_select("SUM(m.reward) FROM missions AS m, mission_status AS ms WHERE ms.player_id = {id} AND ms.mission_id = m.id AND ms.status = {context.mission_success}") + if res == null then return 0 + return res.get_count + end + + fun star_score: Int do + var res = context.try_select("SUM(s.reward) FROM stars AS s, star_status AS ss WHERE ss.player_id = {id} AND ss.status = 1 AND ss.star_id = s.id;") + if res == null then return 0 + return res.get_count + end + + fun score: Int do return achievement_score + mission_score + star_score + + fun stats: PlayerStats do + var stats = new PlayerStats(context, self) + stats.achievements = achievement_count + stats.missions_count = context.mission_count + stats.missions_open = open_missions_count + stats.missions_success = successful_missions_count + stats.missions_locked = stats.missions_count - (stats.missions_open + stats.missions_success) + stats.stars_count = context.star_count + stats.stars_unlocked = star_count return stats end end @@ -65,7 +82,7 @@ class PlayerStats var player: Player - var score = 0 + var score: Int is lazy do return player.score var achievements = 0 var missions_count = 0 var missions_locked = 0 diff --git a/src/model/status.nit b/src/model/status.nit deleted file mode 100644 index ab21717..0000000 --- a/src/model/status.nit +++ /dev/null @@ -1,162 +0,0 @@ -# Copyright 2016 Alexandre Terrasa . -# -# 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. - -module status - -import model::missions - -redef class AppConfig - var missions_status = new MissionStatusRepo(db.collection("missions_status")) is lazy -end - -redef class Player - - fun tracks_status(config: AppConfig): Array[TrackStatus] do - var statuses = new Array[TrackStatus] - for track in config.tracks.find_all do - statuses.add track_status(config, track) - end - return statuses - end - - fun track_status(config: AppConfig, track: Track): TrackStatus do - var status = new TrackStatus(self, track) - - var missions = track.missions(config) - for mission in missions do - var mission_status = mission_status(config, mission) - status.missions_count += 1 - if mission_status.is_locked then status.missions_locked += 1 - if mission_status.is_open then status.missions_open += 1 - if mission_status.is_success then status.missions_success += 1 - status.stars_count += mission.stars.length - status.stars_unlocked += mission_status.unlocked_stars.length - status.missions.add mission_status - end - - return status - end - - fun mission_status(config: AppConfig, mission: Mission): MissionStatus do - var status = config.missions_status.find_by_mission_and_player(mission, self) - if status != null then return status - status = new MissionStatus(mission, self, mission.track) - if mission.is_unlocked_for_player(config, self) then - status.status = "open" - end - return status - end -end - -redef class Mission - - # Is a mission available for a player depending on the mission parents status - fun is_unlocked_for_player(config: AppConfig, player: Player): Bool do - if parents.is_empty then return true - for parent in load_parents(config) do - var status = player.mission_status(config, parent) - if not status.is_success then return false - end - return true - end -end - -class TrackStatus - super Entity - serialize - - var player: Player - var track: Track - - var missions = new Array[MissionStatus] - - var missions_count: Int = 0 - var missions_locked: Int = 0 - var missions_open: Int = 0 - var missions_success: Int = 0 - var stars_count = 0 - var stars_unlocked = 0 -end - -# The link between a Player and a Mission -class MissionStatus - super Entity - serialize - - var mission: Mission - var player: Player - var track: nullable Track - - # The last submission, if any - # - # So the player do not lost its work-in-progress - # - # TODO: save more than the last one - var last_submission: nullable String = null is writable - - # Unlocked stars - fun unlocked_stars: Array[MissionStar] do - var stars = new Array[MissionStar] - for status in stars_status do - if status.is_unlocked then stars.add status.star - end - return stars - end - - # The state of each star - var stars_status = new Array[StarStatus] - - # `mission` status for `player` - # - # Can be one of: - # * `locked`: the mission cannot be played - # * `open`: the mission can be played - # * `success`: the mission is a success - # - # If no status exists for a couple Mission & Player, one should assume - # that the mission is locked and check the parents dependencies. - var status: String = "locked" is writable - - fun is_locked: Bool do return status == "locked" - fun is_open: Bool do return status == "open" - fun is_success: Bool do return status == "success" -end - -# The link between a Player and a Star -class StarStatus - super Entity - serialize - - # The associated star - var star: MissionStar - - # Is the star granted? - var is_unlocked = false is writable, optional - - # The current best score (for ScoreStar) - var best_score: nullable Int = null is writable -end - -class MissionStatusRepo - super MongoRepository[MissionStatus] - - fun find_by_track_and_player(track: Track, player: Player): Array[MissionStatus] do - return find_all( - (new MongoMatch).eq("track._id", track.id).eq("player._id", player.id)) - end - - fun find_by_mission_and_player(mission: Mission, player: Player): nullable MissionStatus do - return find((new MongoMatch).eq("mission._id", mission.id).eq("player._id", player.id)) - end -end diff --git a/src/model/submissions.nit b/src/model/submissions.nit index d341209..cddae52 100644 --- a/src/model/submissions.nit +++ b/src/model/submissions.nit @@ -13,12 +13,40 @@ # Player's submissions for any kind of mission module submissions -import missions +import stars import players -import status +import loader private import markdown private import poset +redef class DBContext + ## Submission status codes + + # Submission submitted code + fun submission_submitted: Int do return 1 + + # Submission pending code + fun submission_pending: Int do return 2 + + # Submission successful code + fun submission_success: Int do return 3 + + # Submission error code + fun submission_error: Int do return 4 + + ## + + # Mapping between verbatim status and database ID + protected var submission_statuses = new HashMap[String, Int] + + init do + submission_statuses["submitted"] = submission_submitted + submission_statuses["pending"] = submission_pending + submission_statuses["success"] = submission_success + submission_statuses["error"] = submission_error + end +end + # An entry submitted by a player for a mission. # # The last submitted programs and/or the ones that beat stars @@ -30,10 +58,13 @@ class Submission serialize # The submitter - var player: Player + var player_id: Int # The attempted mission - var mission: Mission + var mission_id: Int + + # Get the mission linked to the mission id + var mission: nullable Mission is lazy, noserialize do return context.mission_by_id(mission_id) # The submitted source code var source: String @@ -42,7 +73,7 @@ class Submission var next_missions: nullable Array[Mission] = null # All information about the compilation - var compilation = new CompilationResult + var compilation: CompilationResult is lazy do return new CompilationResult # Individual results for each test case # @@ -59,7 +90,7 @@ class Submission # The name of the working directory. # It is where the source is saved and artifacts are generated. - var workspace: nullable String = null is writable + var workspace: nullable String = null is writable, noserialize # Object file size in bytes. # @@ -86,37 +117,23 @@ class Submission # The results of each star var star_results = new Array[StarResult] - # Update status of `self` in DB - fun update_status(config: AppConfig) do - var mission_status = player.mission_status(config, mission) - self.mission_status = mission_status - - mission_status.last_submission = source + redef fun to_json do return serialize_to_json - # Update/unlock stars + redef fun insert do + var ws = workspace or else "null" + var stat = context.submission_statuses.get_or_null(status) + if stat == null then + print "Error when inserting submission: Unknown status" + return false + end + if not (super and basic_insert("INSERT INTO submissions(event_id, player_id, mission_id, workspace_path, status) VALUES ({id}, {player_id}, {mission_id}, {ws.to_sql_string}, {if successful then context.submission_success else context.submission_error});")) then return false if successful then - if mission_status.status != "success" then - is_first_solve = true - end - mission_status.status = "success" - for star in mission.stars do star.check(self, mission_status) - - # Unlock next missions - # Add next missions to successful submissions - var next_missions = new Array[Mission] - for mission in mission.load_children(config) do - var cstatus = player.mission_status(config, mission) - cstatus.status = "open" - config.missions_status.save(cstatus) - next_missions.add mission - end - self.next_missions = next_missions + var m = mission + if m == null then return false + m.success_for(player_id) end - - config.missions_status.save(mission_status) + return true end - - redef fun to_json do return serialize_to_json end # This model provides easy deserialization of posted submission forms @@ -127,59 +144,69 @@ class SubmissionForm var source: String # Engine or runner to be used var engine: String - # Language in which the source code is writte + # Language in which the source code is written var lang: String end redef class MissionStar + + # The current best score for the star result + fun best_score(player_id: Int): nullable Int do + var db = context.connection + var res = db.select("star_results.score FROM star_results, submissions WHERE star_id = {id} AND submissions.event_id = star_results.submission_id AND submissions.player_id = {player_id} ORDER BY score DESC LIMIT 1;") + if res == null then return null + var cnt = res.get_count + if cnt == 0 then return null + return cnt + end + # Check if the star is unlocked for the `submission` # Also update `status` - fun check(submission: Submission, status: MissionStatus): Bool do return false -end - -redef class ScoreStar - redef fun check(submission, status) do - # Search or create the corresponding StarStatus - # Just iterate the array - var star_status = null - for ss in status.stars_status do - if ss.star == self then - star_status = ss - break - end + fun check(submission: Submission): Bool do + if not submission.successful then + print "Submission unsuccessful" + return false end - if star_status == null then - star_status = new StarStatus(self) - status.stars_status.add star_status + # Since we are adding data to the DB which are related + # to a submission, its id must be set, hence the submission + # must be commited before checking for stars + assert submission.id != -1 + var score = self.score(submission) + if score == null then + print "No score registered" + return false end - if not submission.successful then return false + # Search or create the corresponding StarStatus + var star_status = status(submission.player_id) + if star_status == null then star_status = new StarStatus(context, submission.player_id, id) + var star_result = new StarResult(context, submission.id, id, score) - var score = self.score(submission) - if score == null then return false + var changed = false - var star_result = new StarResult(self) - submission.star_results.add star_result - star_result.goal = goal - star_result.new_score = score - var best = star_status.best_score + # Best score? + var best = best_score(submission.player_id) star_result.old_score = best # Best score? if best == null or score < best then - star_status.best_score = score if best != null then star_result.is_highscore = true end + changed = true end # Star granted? - if not status.unlocked_stars.has(self) and score <= goal then + if not star_status.is_unlocked and score <= goal then star_status.is_unlocked = true star_result.is_unlocked = true - return true + changed = true end - return false + + star_status.commit + star_result.commit + submission.star_results.add star_result + return changed end # The specific score in submission associated to `self` @@ -207,7 +234,6 @@ end # The specific information about compilation (or any internal affair) class CompilationResult - super Entity serialize # The title of the box @@ -222,13 +248,12 @@ end # A specific execution of a test case by a submission class TestResult - super Entity serialize # The test case considered var testcase: TestCase - # The output of the `submission` when feed by `testcase.provided_input`. + # The output of the `submission` when fed by `testcase.provided_input`. var produced_output: nullable String = null is writable # Error message @@ -245,20 +270,20 @@ end # The specific submission result on a star # Unlike the star status, this shows what is *new* class StarResult - super Entity + super UniqueEntity serialize - # Information about the star - var star: MissionStar + # The associated submission id + var submission_id: Int - # The goal of the star, if any - var goal: nullable Int = null + # The associated star id + var star_id: Int - # The previous score, if any - var old_score: nullable Int = null + # The star associated to result + var star: nullable MissionStar is lazy do return context.star_by_id(star_id) - # The new score, if any - var new_score: nullable Int = null + # The new score + var score: Int # Is the star unlocked? var is_unlocked = false @@ -266,23 +291,24 @@ class StarResult # Is the new_score higher than then old_score? var is_highscore = false + # Old best score, if exists + var old_score: nullable Int + redef fun to_s do - var res = "STAR {star.title}" + var st = star + var title = "Unknown star" + if st != null then title = st.title + var res = "STAR {title}" if is_unlocked then res += " UNLOCKED!" else if is_highscore then res += " NEW BEST SCORE!" end - var goal = self.goal - if goal != null then - res += " goal: {goal}" - end - - var new_score = self.new_score - if new_score != null then - res += " score: {new_score}" + if st != null then + res += " goal: {st.goal}" end + res += " score: {score}" var old_score = self.old_score if old_score != null then @@ -290,4 +316,6 @@ class StarResult end return res end + + redef fun insert do return basic_insert("INSERT INTO star_results(submission_id, star_id, score) VALUES ({submission_id}, {star_id}, {score});") end diff --git a/src/model/tracks.nit b/src/model/tracks.nit index ceacbb8..a9f8f04 100644 --- a/src/model/tracks.nit +++ b/src/model/tracks.nit @@ -12,25 +12,219 @@ # See the License for the specific language governing permissions and # limitations under the License. -module tracks +import missions -import players +redef class DBContext -redef class AppConfig - var tracks = new TrackRepo(db.collection("tracks")) is lazy + fun track_worker: TrackWorker do return once new TrackWorker + + fun track_status_worker: TrackStatusWorker do return once new TrackStatusWorker + + fun track_by_id(id: Int): nullable Track do return track_worker.fetch_one(self, "* FROM tracks WHERE id = {id};") + + fun track_by_slug(slug: String): nullable Track do return track_worker.fetch_one(self, "* FROM tracks WHERE slug = {slug.to_sql_string};") + + fun all_tracks: Array[Track] do return track_worker.fetch_multiple(self, "* FROM tracks;") + + ## Track status codes + + fun track_open: Int do return 1 + fun track_success: Int do return 2 + + ## +end + +redef class Statement + + # Gets all the Track items from `self` + # + # Returns an empty array if none were found or if a row + # was non-compliant with the construction of a Track + fun to_tracks(ctx: DBContext): Array[Track] do + return ctx.track_worker. + fetch_multiple_from_statement(ctx, self) + end + + fun to_track_statuses(ctx: DBContext): Array[TrackStatus] do + return ctx.track_status_worker. + fetch_multiple_from_statement(ctx, self) + end +end + +class TrackWorker + super EntityWorker + + redef type ENTITY: Track + + redef fun entity_type do return "Track" + + redef fun expected_data do return once ["id", "slug", "title", "description", "path"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var id = m["id"].as(Int) + var slug = m["slug"].as(String) + var title = m["title"].as(String) + var desc = m["description"].as(String) + var ret = new Track(ctx, title, desc, slug) + if m["path"] != null then ret.path = m["path"].as(String) + ret.id = id + ret.load_languages + return ret + end +end + +class TrackStatusWorker + super EntityWorker + + redef type ENTITY: TrackStatus + + redef fun entity_type do return "TrackStatus" + + redef fun expected_data do return once ["track_id", "player_id", "status"] + + redef fun make_entity_from_row(ctx, row) do + var m = row.map + var tid = m["track_id"].as(Int) + var pid = m["player_id"].as(Int) + var status = m["status"].as(Int) + var res = new TrackStatus(ctx, tid, pid, status) + res.persisted = true + return res + end end class Track - super Entity + super UniqueEntity serialize - redef var id var title: String var desc: String + var slug: String + + # List of allowed languages + var languages = new Array[String] + + var path: nullable String is writable + + fun load_languages do + var res = context.try_select("languages.name FROM track_languages, languages WHERE track_languages.track_id = {id} AND languages.id = track_languages.language_id;") + if res == null then return + for i in res do languages.add(i[0].to_s) + end redef fun to_s do return title + + fun missions: Array[Mission] do + var db = context.connection + var res = db.select("* FROM missions WHERE track_id = {id};") + if res == null then return new Array[Mission] + return res.to_missions(context) + end + + fun status_for(player_id: Int): nullable TrackStatus do + var st = context.track_status_worker.fetch_one(context, "* FROM track_statuses WHERE track_id = {id} AND player_id = {player_id};") + if st == null then st = new TrackStatus(context, id, player_id, context.track_open) + return st + end + + fun mission_statuses_for(player_id: Int): Array[MissionStatus] do + var mstats = context.mission_status_worker.fetch_multiple(context, "stat.* FROM mission_status AS stat, missions AS m WHERE stat.player_id = {player_id} AND m.track_id = {id} AND stat.mission_id = m.id;") + var missions = missions + for i in missions do + var has_status = false + for j in mstats do + if i.id == j.mission_id then + has_status = true + break + end + end + if not has_status then + var s = i.status_for(player_id) + if s != null then mstats.add s + end + end + return mstats + end + + fun mission_count: Int do + var res = context.try_select("COUNT(*) FROM missions WHERE missions.track_id = {id}") + if res == null then return 0 + return res.get_count + end + + redef fun insert do + var p = path + if p != null then p = p.to_sql_string + return basic_insert("INSERT INTO tracks(slug, title, description, path) VALUES ({slug.to_sql_string}, {title.to_sql_string}, {desc.to_sql_string}, {p or else "NULL"});") + end + + redef fun update do + var p = path + if p != null then p = p.to_sql_string + return basic_update("UPDATE tracks SET slug = {slug.to_sql_string}, title = {title.to_sql_string}, description = {desc.to_sql_string}, path = {p or else "NULL"} WHERE id = {id}") + end end -class TrackRepo - super MongoRepository[Track] +class TrackStatus + super BridgeEntity + serialize + + # The concerned track's id + var track_id: Int + # The player's id + var player_id: Int + # Track status + # + # Can be either: + # 1 - Open + # 2 - Success + var status: Int + # Number of missions in track `track_id` + var missions_count: Int is lazy do + var res = context.try_select("COUNT(*) FROM missions WHERE track_id = {track_id};") + if res == null then return 0 + return res.get_count + end + # Number of missions completed for `player_id` in track `track_id` + var missions_success: Int is lazy do + var res = context.try_select("COUNT(*) FROM mission_status, missions WHERE mission_status.player_id = {player_id} AND mission_status.status = {context.mission_success} AND missions.track_id = {track_id} AND mission_status.mission_id = missions.id;") + if res == null then return 0 + return res.get_count + end + # Mission statuses for track `track_id` and player `player_id + var missions_status: Array[MissionStatus] is lazy do + var track = context.track_by_id(track_id) + if track == null then return new Array[MissionStatus] + return track.mission_statuses_for(player_id) + end + + redef fun insert do return basic_insert("INSERT INTO track_statuses(track_id, player_id, status) VALUES ({track_id}, {player_id}, {status});") + + redef fun update do return basic_update("UPDATE track_statuses SET status = {status} WHERE player_id = {player_id} AND track_id = {track_id}") + +end + +redef class Mission + var track: nullable Track is lazy do return context.track_by_id(track_id) +end + +redef class Player + + fun track_statuses: Array[TrackStatus] do + var tracks = context.all_tracks + var track_statuses = context.track_status_worker.fetch_multiple(context, "* FROM track_statuses WHERE player_id = {id};") + var tmap = new HashMap[Int, TrackStatus] + for i in track_statuses do tmap[i.track_id] = i + for i in tracks do + if tmap.has_key(id) then continue + var stat = i.status_for(id) + if stat == null then + print "Error getting track status for track {i.id} and player {id}" + return new Array[TrackStatus] + end + track_statuses.add stat + end + return track_statuses + end end diff --git a/tests/test_nitc.nit b/tests/test_nitc.nit index e80379b..f7cd5da 100644 --- a/tests/test_nitc.nit +++ b/tests/test_nitc.nit @@ -9,56 +9,54 @@ import submissions import api import debug -var opts = new AppOptions.from_args(args) -var config = new AppConfig.from_options(opts) +var config = new AppConfig +config.parse_options(args) -# clean bd -config.db.drop +with ctx = new DBContext do + # Create a dummy user + var player = new Player(ctx, "nit_doe", "Nit Doe", "john.doe@unknown.ld", "avatar.cx") + player.commit -# Load nit -config.load_track("tracks/nit") -# Create a dummy user -var player = new Player("John", "Doe") -config.players.save player + # Run some submission on the missions + var mission = ctx.mission_by_slug("nit:hello_world") + if mission == null then return -# Run some submission on the missions -var mission = config.missions.find_all.first - -print "Mission {mission} {mission.testsuite.length}" -var i = 0 -for source in [ -""" -""", -""" -echo Hello, World! -""", -""" -print "hello world" -""", -""" -class Hello - fun hi: String do return "Hello, World!" -end -print((new Hello).hi) -""", -""" -print "Hello, World!" -""" -] do - print "## Try source {i} ##" - var prog = new Submission(player, mission, source) - var runner = config.engine_map["nitc"] - runner.run(prog, config) - print "** {prog.status} errors={prog.test_errors}/{prog.results.length} size={prog.size_score or else "-"} time={prog.time_score or else "-"}" - var msg = prog.compilation.message - if msg != null then print "{msg}" - for res in prog.results do - var msg_test = res.error - if msg_test != null then print "test {res.testcase.number}: {msg_test}" + print "Mission {mission} {mission.testsuite.length}" + var i = 0 + for source in [ + """ + """, + """ + echo Hello, World! + """, + """ + print "hello world" + """, + """ + class Hello + fun hi: String do return "Hello, World!" end - for e in prog.star_results do - print e + print((new Hello).hi) + """, + """ + print "Hello, World!" + """ + ] do + print "## Try source {i} ##" + var prog = new Submission(ctx, player.id, mission.id, source) + var runner = config.engine_map["nitc"] + runner.run(prog) + print "** {prog.status} errors={prog.test_errors}/{prog.results.length} size={prog.size_score or else "-"} time={prog.time_score or else "-"}" + var msg = prog.compilation.message + if msg != null then print "{msg}" + for res in prog.results do + var msg_test = res.error + if msg_test != null then print "test {res.testcase.number}: {msg_test}" + end + for e in prog.star_results do + print e + end + i += 1 end - i += 1 end diff --git a/tests/test_pep.nit b/tests/test_pep.nit index 5fa2420..1161a46 100644 --- a/tests/test_pep.nit +++ b/tests/test_pep.nit @@ -8,75 +8,72 @@ import model::loader import submissions import api -var opts = new AppOptions.from_args(args) -var config = new AppConfig.from_options(opts) +var config = new AppConfig +config.parse_options(args) -# clean bd -config.db.drop +with ctx = new DBContext do + # Create a dummy user + var player = new Player(ctx, "pep_doe", "Pep Doe", "john.doe@unknown.ld", "avatar.cx") + player.commit -# Load pep8 -config.load_track("tracks/pep8") - -# Create a dummy user -var player = new Player("John", "Doe") -config.players.save player - -# Run some submission on the missions -var mission = config.missions.find_all.first -do - print "Mission {mission} {mission.testsuite.length}" - var i = 0 - for source in [ -""" -""", -""" -DECO 10,i -.END -""", -""" -DECI n,d -LDA n,d -ADDA 10,i -STA n,d -DECO n,d -STOP -n: .BLOCK 3 -.END -""", -""" -DECI n,d -LDA n,d -ADDA 10,i -STA n,d -DECO n,d -STOP -n: .BLOCK 2 -.END -""", -""" -DECI 0,d -LDA 0,d -ADDA 10,i -STA 0,d -DECO 0,d -STOP -.END -""" -] do - print "## Try source {i} ##" - var sub = new Submission(player, mission, source) - var runner = config.engine_map["pep8term"] - runner.run(sub, config) - print "** {sub.status} errors={sub.test_errors}/{sub.results.length} size={sub.size_score or else "-"} time={sub.time_score or else "-"}" - var msg = sub.compilation.message - if msg != null then print "{msg}" - for res in sub.results do - var msg_test = res.error - if msg_test != null then print "test {res.testcase.number}. {msg_test}" - end - for e in sub.star_results do - print e + # Run some submission on the missions + var mission = ctx.mission_by_slug("pep8:addition_simple") + if mission == null then return + do + print "Mission {mission} {mission.testsuite.length}" + var i = 0 + for source in [ + """ + """, + """ + DECO 10,i + .END + """, + """ + DECI n,d + LDA n,d + ADDA 10,i + STA n,d + DECO n,d + STOP + n: .BLOCK 3 + .END + """, + """ + DECI n,d + LDA n,d + ADDA 10,i + STA n,d + DECO n,d + STOP + n: .BLOCK 2 + .END + """, + """ + DECI 0,d + LDA 0,d + ADDA 10,i + STA 0,d + DECO 0,d + STOP + .END + """ + ] do + print "## Try source {i} ##" + var sub = new Submission(ctx, player.id, mission.id, source) + var runner = config.engine_map["pep8term"] + runner.run(sub) + print "** {sub.status} errors={sub.test_errors}/{sub.results.length} size={sub.size_score or else "-"} time={sub.time_score or else "-"}" + var msg = sub.compilation.message + if msg != null then print "{msg}" + for res in sub.results do + var msg_test = res.error + if msg_test != null then print "test {res.testcase.number}. {msg_test}" + end + for e in sub.star_results do + print e + end + i += 1 end - i += 1 end end diff --git a/www/directives/breadcrumbs.html b/www/directives/breadcrumbs.html index 4b6ae7c..a7d20b6 100644 --- a/www/directives/breadcrumbs.html +++ b/www/directives/breadcrumbs.html @@ -1,14 +1,14 @@ diff --git a/www/directives/events/Solve.html b/www/directives/events/Solve.html index 3470468..925d987 100644 --- a/www/directives/events/Solve.html +++ b/www/directives/events/Solve.html @@ -6,9 +6,9 @@

Mission achieved

You achieved - {{event.mission.track.title}} + {{event.mission.track.title}} :: - {{event.mission.title}} + {{event.mission.title}}

diff --git a/www/directives/friends/friends-list.html b/www/directives/friends/friends-list.html index 5f0d041..81d4ed7 100644 --- a/www/directives/friends/friends-list.html +++ b/www/directives/friends/friends-list.html @@ -4,7 +4,7 @@

Friends

  • diff --git a/www/directives/missions/button.html b/www/directives/missions/button.html index 5b7e1f0..d4d91ee 100644 --- a/www/directives/missions/button.html +++ b/www/directives/missions/button.html @@ -1,11 +1,11 @@ - + Try mission - + Retry - + Locked diff --git a/www/directives/missions/mission-panel.html b/www/directives/missions/mission-panel.html index cb44745..92a3d26 100644 --- a/www/directives/missions/mission-panel.html +++ b/www/directives/missions/mission-panel.html @@ -8,14 +8,14 @@

  • - {{missionCtrl.mission.title}} + {{missionCtrl.mission.title}}

    Reward: {{missionCtrl.mission.reward}} pts

    -   +  

    diff --git a/www/directives/missions/mission.html b/www/directives/missions/mission.html index 305bf3a..5bd32ec 100644 --- a/www/directives/missions/mission.html +++ b/www/directives/missions/mission.html @@ -38,6 +38,6 @@

    + ng-if='missionCtrl.playerId && missionCtrl.missionStatus.status != "locked"' /> diff --git a/www/directives/missions/stars.html b/www/directives/missions/stars.html index ab4a647..274bce2 100644 --- a/www/directives/missions/stars.html +++ b/www/directives/missions/stars.html @@ -1,5 +1,5 @@ + ng-repeat='(index, star) in starsCtrl.mission.stars' + star='star' star-status='starsCtrl.missionStatus.star_status.__items' /> diff --git a/www/directives/player/link.html b/www/directives/player/link.html index 7ed1add..a5d27ca 100644 --- a/www/directives/player/link.html +++ b/www/directives/player/link.html @@ -1,8 +1,8 @@ - + - + {{player.name}} diff --git a/www/directives/player/sidebar.html b/www/directives/player/sidebar.html index 2fd412b..9120524 100644 --- a/www/directives/player/sidebar.html +++ b/www/directives/player/sidebar.html @@ -26,12 +26,12 @@


    -