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