1/* -*- Mode: vala; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- 2 * 3 * Copyright © 2014 Nikhar Agrawal 4 * Copyright © 2015 Michael Catanzaro <mcatanzaro@gnome.org> 5 * 6 * This file is part of libgnome-games-support. 7 * 8 * libgnome-games-support is free software: you can redistribute it and/or modify 9 * it under the terms of the GNU Lesser General Public License as published by 10 * the Free Software Foundation, either version 3 of the License, or 11 * (at your option) any later version. 12 * 13 * libgnome-games-support is distributed in the hope that it will be useful, 14 * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 * GNU Lesser General Public License for more details. 17 * 18 * You should have received a copy of the GNU Lesser General Public License 19 * along with libgnome-games-support. If not, see <http://www.gnu.org/licenses/>. 20 */ 21 22namespace Games { 23namespace Scores { 24 25public enum Style 26{ 27 POINTS_GREATER_IS_BETTER, 28 POINTS_LESS_IS_BETTER, 29 TIME_GREATER_IS_BETTER, 30 TIME_LESS_IS_BETTER 31} 32 33public class Context : Object 34{ 35 public string app_name { get; construct; } 36 public string category_type { get; construct; } 37 public Gtk.Window? game_window { get; construct; } 38 public Style style { get; construct; } 39 public Importer? importer { get; construct; } 40 public string icon_name { get; construct; } 41 42 private Category? current_category = null; 43 44 private static Gee.HashDataFunc<Category?> category_hash = (a) => { 45 return str_hash (a.key); 46 }; 47 private static Gee.EqualDataFunc<Category?> category_equal = (a,b) => { 48 return str_equal (a.key, b.key); 49 }; 50 private Gee.HashMap<Category?, Gee.List<Score>> scores_per_category = 51 new Gee.HashMap<Category?, Gee.List<Score>> ((owned) category_hash, (owned) category_equal); 52 53 private string user_score_dir; 54 private bool scores_loaded = false; 55 56 /* A function provided by the game that converts the category key to a 57 * category. Why do we have this, instead of expecting games to pass in a 58 * list of categories? Because some games need to create categories on the 59 * fly, like Mines, which allows for custom board sizes. These games do not 60 * know in advance which categories may be in use. 61 */ 62 public delegate Category? CategoryRequestFunc (string category_key); 63 private CategoryRequestFunc? category_request = null; 64 65 class construct 66 { 67 Intl.bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR); 68 Intl.bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8"); 69 } 70 71 public Context (string app_name, 72 string category_type, 73 Gtk.Window? game_window, 74 CategoryRequestFunc category_request, 75 Style style) 76 { 77 this.with_importer_and_icon_name (app_name, category_type, game_window, category_request, style, null, null); 78 } 79 80 public Context.with_icon_name (string app_name, 81 string category_type, 82 Gtk.Window? game_window, 83 CategoryRequestFunc category_request, 84 Style style, 85 string icon_name) 86 { 87 this.with_importer_and_icon_name (app_name, category_type, game_window, category_request, style, null, icon_name); 88 } 89 90 public Context.with_importer (string app_name, 91 string category_type, 92 Gtk.Window? game_window, 93 CategoryRequestFunc category_request, 94 Style style, 95 Importer? importer) 96 { 97 this.with_importer_and_icon_name (app_name, category_type, game_window, category_request, style, importer, null); 98 } 99 100 public Context.with_importer_and_icon_name (string app_name, 101 string category_type, 102 Gtk.Window? game_window, 103 CategoryRequestFunc category_request, 104 Style style, 105 Importer? importer = null, 106 string? icon_name = null) 107 { 108 Object (app_name: app_name, 109 category_type: category_type, 110 game_window: game_window, 111 style: style, 112 importer: importer, 113 icon_name: icon_name ?? app_name); 114 115 /* Note: the following functionality can be performed manually by 116 * calling Context.load_scores, to ensure Context is usable even if 117 * constructed with g_object_new. 118 */ 119 this.category_request = (key) => { return category_request (key); }; 120 try 121 { 122 load_scores_from_files (); 123 } 124 catch (Error e) 125 { 126 warning ("Failed to load scores: %s", e.message); 127 } 128 } 129 130 public override void constructed () 131 { 132 user_score_dir = Path.build_filename (Environment.get_user_data_dir (), app_name, "scores", null); 133 134 if (importer != null) 135 importer.run (this, user_score_dir); 136 } 137 138 internal List<Category?> get_categories () 139 { 140 var categories = new List<Category?> (); 141 var iterator = scores_per_category.map_iterator (); 142 143 while (iterator.next ()) 144 { 145 categories.append (iterator.get_key ()); 146 } 147 148 return categories; 149 } 150 151 /* Primarily used to change name of player and save the changed score to file */ 152 internal void update_score_name (Score old_score, Category category, string new_name) 153 { 154 foreach (var score in scores_per_category[category]) 155 { 156 if (Score.equals (score, old_score)) 157 { 158 score.user = new_name; 159 return; 160 } 161 } 162 assert_not_reached (); 163 } 164 165 /* Get the best n scores from the given category, sorted */ 166 public Gee.List<Score> get_high_scores (Category category, int n = 10) 167 { 168 var result = new Gee.ArrayList<Score> (); 169 if (!scores_per_category.has_key (category)) 170 return result; 171 172 if (style == Style.POINTS_GREATER_IS_BETTER || style == Style.TIME_GREATER_IS_BETTER) 173 { 174 scores_per_category[category].sort ((a,b) => { 175 return (int) (b.score > a.score) - (int) (a.score > b.score); 176 }); 177 } 178 else 179 { 180 scores_per_category[category].sort ((a,b) => { 181 return (int) (b.score < a.score) - (int) (a.score < b.score); 182 }); 183 } 184 185 for (int i = 0; i < n && i < scores_per_category[category].size; i++) 186 result.add (scores_per_category[category][i]); 187 return result; 188 } 189 190 private bool is_high_score (long score_value, Category category) 191 { 192 var best_scores = get_high_scores (category); 193 194 /* The given category doesn't yet exist and thus this score would be the first score and hence a high score. */ 195 if (best_scores == null) 196 return true; 197 198 if (best_scores.size < 10) 199 return true; 200 201 var lowest = best_scores.@get (9).score; 202 203 if (style == Style.POINTS_LESS_IS_BETTER || style == Style.TIME_LESS_IS_BETTER) 204 return score_value < lowest; 205 206 return score_value > lowest; 207 } 208 209 private async void save_score_to_file (Score score, Category category, Cancellable? cancellable) throws Error 210 { 211 if (DirUtils.create_with_parents (user_score_dir, 0766) == -1) 212 { 213 throw new FileError.FAILED ("Failed to create %s: %s", user_score_dir, strerror (errno)); 214 } 215 216 var file = File.new_for_path (Path.build_filename (user_score_dir, category.key)); 217 var stream = file.append_to (FileCreateFlags.NONE); 218 var line = @"$(score.score) $(score.time) $(score.user)\n"; 219 220 yield stream.write_all_async (line.data, Priority.DEFAULT, cancellable, null); 221 } 222 223 internal async bool add_score_internal (Score score, 224 Category category, 225 bool allow_dialog, 226 Cancellable? cancellable) throws Error 227 { 228 var high_score_added = is_high_score (score.score, category); 229 230 /* Check if category exists in the HashTable. Insert one if not. */ 231 if (!scores_per_category.has_key (category)) 232 scores_per_category.set (category, new Gee.ArrayList<Score> ()); 233 234 if (scores_per_category[category].add (score)) 235 current_category = category; 236 237 /* Note that the score's player name can change while the dialog is 238 * running. 239 */ 240 if (high_score_added && allow_dialog) 241 run_dialog_internal (score); 242 243 yield save_score_to_file (score, current_category, cancellable); 244 return high_score_added; 245 } 246 247 /* Return true if a dialog was launched on attaining high score */ 248 public async bool add_score (long score, Category category, Cancellable? cancellable) throws Error 249 { 250 /* Don't allow the dialog if it wouldn't have a parent, or in tests. */ 251 return yield add_score_internal (new Score (score), category, game_window != null, cancellable); 252 } 253 254 internal bool add_score_sync (Score score, Category category) throws Error 255 { 256 var main_context = new MainContext (); 257 var main_loop = new MainLoop (main_context); 258 var ret = false; 259 Error error = null; 260 261 main_context.push_thread_default (); 262 add_score_internal.begin (score, category, false, null, (object, result) => { 263 try 264 { 265 ret = add_score_internal.end (result); 266 } 267 catch (Error e) 268 { 269 error = e; 270 } 271 main_loop.quit (); 272 }); 273 main_loop.run (); 274 main_context.pop_thread_default (); 275 276 if (error != null) 277 throw error; 278 return ret; 279 } 280 281 private void load_scores_from_file (FileInfo file_info) throws Error 282 { 283 var category_key = file_info.get_name (); 284 var category = category_request (category_key); 285 if (category == null) 286 return; 287 288 var filename = Path.build_filename (user_score_dir, category_key); 289 var scores_of_single_category = new Gee.ArrayList<Score> (); 290 var stream = FileStream.open (filename, "r"); 291 string line; 292 while ((line = stream.read_line ()) != null) 293 { 294 var tokens = line.split (" ", 3); 295 string? user = null; 296 297 if (tokens.length < 2) 298 { 299 warning ("Failed to read malformed score %s in %s.", line, filename); 300 continue; 301 } 302 303 var score_value = long.parse (tokens[0]); 304 var time = int64.parse (tokens[1]); 305 306 if (score_value == 0 && tokens[0] != "0" || 307 time == 0 && tokens[1] != "0") 308 { 309 warning ("Failed to read malformed score %s in %s.", line, filename); 310 continue; 311 } 312 313 if (tokens.length == 3) 314 user = tokens[2]; 315 else 316 debug ("Assuming current username for old score %s in %s.", line, filename); 317 318 scores_of_single_category.add (new Score (score_value, time, user)); 319 } 320 321 scores_per_category.set (category, scores_of_single_category); 322 } 323 324 private void load_scores_from_files () throws Error 325 requires (!scores_loaded) 326 { 327 scores_loaded = true; 328 329 if (game_window != null && game_window.visible) 330 { 331 error ("The application window associated with the GamesScoresContext " + 332 "was set visible before loading scores. The Context performs " + 333 "synchronous I/O in the default main context to load scores, so " + 334 "so you should do this before showing your main window."); 335 } 336 337 var directory = File.new_for_path (user_score_dir); 338 if (!directory.query_exists ()) 339 return; 340 341 var enumerator = directory.enumerate_children (FileAttribute.STANDARD_NAME, 0); 342 FileInfo file_info; 343 while ((file_info = enumerator.next_file ()) != null) 344 { 345 load_scores_from_file (file_info); 346 } 347 } 348 349 /* Must be called *immediately* after construction, if constructed using 350 * g_object_new. */ 351 public void load_scores (CategoryRequestFunc category_request) throws Error 352 requires (this.category_request == null) 353 { 354 this.category_request = (key) => { return category_request (key); }; 355 load_scores_from_files (); 356 } 357 358 internal void run_dialog_internal (Score? new_high_score) 359 requires (game_window != null) 360 { 361 var dialog = new Dialog (this, category_type, style, new_high_score, current_category, game_window, icon_name); 362 dialog.run (); 363 dialog.destroy (); 364 } 365 366 public void run_dialog () 367 { 368 run_dialog_internal (null); 369 } 370 371 public bool has_scores () 372 { 373 foreach (var scores in scores_per_category.values) 374 { 375 if (scores.size > 0) 376 return true; 377 } 378 return false; 379 } 380} 381 382} /* namespace Scores */ 383} /* namespace Games */ 384