├── .gitattributes ├── pix └── monologo.svg ├── README.md ├── templates ├── notready.mustache ├── waitscreen.mustache ├── questionleaderboard_player.mustache ├── preparation.mustache ├── donescreen_player.mustache ├── questionresult_player.mustache ├── joinscreen.mustache ├── question.mustache ├── donescreen.mustache ├── questionleaderboard_gamemaster.mustache ├── lobby.mustache ├── questionresult_gamemaster.mustache ├── question_player.mustache └── question_gamemaster.mustache ├── version.php ├── styles.css ├── lang └── en │ └── kahoodle.php ├── classes ├── event │ ├── course_module_instance_list_viewed.php │ └── course_module_viewed.php ├── constants.php ├── output │ └── renderer.php ├── game.php └── api.php ├── tests ├── generator │ └── lib.php ├── behat │ └── basic_actions.feature └── lib_test.php ├── backup └── moodle2 │ ├── backup_kahoodle_stepslib.php │ ├── restore_kahoodle_stepslib.php │ ├── backup_kahoodle_activity_task.class.php │ └── restore_kahoodle_activity_task.class.php ├── mod_form.php ├── view.php ├── db ├── access.php ├── install.xml └── upgrade.php ├── index.php ├── amd ├── build │ ├── game.min.js │ └── game.min.js.map └── src │ └── game.js ├── .github └── workflows │ └── gha.yml └── lib.php /.gitattributes: -------------------------------------------------------------------------------- 1 | amd/build/** -diff 2 | 3 | -------------------------------------------------------------------------------- /pix/monologo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kahoodle - real-time quiz game for Moodle using web sockets 2 | 3 | This plugin was originally developed during DevCamp in MoodlemootDACH 2025 by: 4 | Marina Glancy, Jan Britz, Immanuel Pasanec, Vasco Grossmann, Lars Dreier, Kathleen Aermes and Monika Weber. 5 | 6 | We use [tool_realtime](https://github.com/marinaglancy/moodle-tool_realtime) with an additional subplugin for bi-directional websockets. 7 | 8 | We demoed it during the DevCamp and had 70 people playing it at the same time. 9 | 10 | This plugin is still in early development and not ready for production use. 11 | 12 | If you are a UI designer and want to help us, please contact Marina Glancy. 13 | -------------------------------------------------------------------------------- /templates/notready.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/notready 19 | 20 | TODO describe template notready 21 | 22 | Example context (json): 23 | { 24 | } 25 | }} 26 |

Activity is not available

27 | -------------------------------------------------------------------------------- /templates/waitscreen.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/waitscreen 19 | 20 | TODO describe template waitscreen 21 | 22 | Example context (json): 23 | { 24 | } 25 | }} 26 |

Please look at the screen

-------------------------------------------------------------------------------- /templates/questionleaderboard_player.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/questionleaderboard_player 19 | 20 | TODO describe template questionleaderboard_player 21 | 22 | Example context (json): 23 | { 24 | "score": 1050 25 | } 26 | }} 27 |

Here you will see your score after this question

28 |

Your total score is {{score}}

29 | -------------------------------------------------------------------------------- /templates/preparation.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/preparation 19 | 20 | TODO describe template preparation 21 | 22 | Example context (json): 23 | { 24 | } 25 | }} 26 |

Activity is in preparation state. You can allow users to join the game.

27 |
28 | -------------------------------------------------------------------------------- /templates/donescreen_player.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/donescreen_player 19 | 20 | TODO describe template donescreen_player 21 | 22 | Example context (json): 23 | { 24 | "player": { 25 | "playerid": 10, 26 | "name": "Max Mustermann", 27 | "points": 100 28 | } 29 | } 30 | }} 31 | {{#player}} 32 |

The game is finished! Your score is {{points}} points

33 | {{/player}} 34 | -------------------------------------------------------------------------------- /version.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Version information for Kahoodle 19 | * 20 | * @package mod_kahoodle 21 | * @copyright Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | $plugin->component = 'mod_kahoodle'; 28 | $plugin->release = '1.0'; 29 | $plugin->version = 2025090208; 30 | $plugin->requires = 2024100700; 31 | $plugin->supported = [405, 500]; 32 | $plugin->maturity = MATURITY_STABLE; 33 | $plugin->dependencies = [ 34 | 'tool_realtime' => ANY_VERSION, 35 | ]; 36 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --option-color-1: #F98012; 3 | --option-color-1-hover: #dc6700; 4 | 5 | --option-color-2: #9ABB50; 6 | --option-color-2-hover: #708a38; 7 | 8 | --option-color-3: #69E0FF; 9 | --option-color-3-hover: #4ea4bd; 10 | 11 | --option-color-4: #FFE84F; 12 | --option-color-4-hover: #cab83f; 13 | } 14 | 15 | .mod_kahoodle_option:nth-of-type(1) { 16 | background-color: var(--option-color-1); 17 | } 18 | 19 | .mod_kahoodle_option:nth-child(2) { 20 | background-color: var(--option-color-2); 21 | } 22 | 23 | .mod_kahoodle_option:nth-child(3) { 24 | background-color: var(--option-color-3); 25 | } 26 | 27 | .mod_kahoodle_option:nth-child(4) { 28 | background-color: var(--option-color-4); 29 | } 30 | 31 | /* Hover states */ 32 | .mod_kahoodle_option[data-action="answer"]:nth-child(1):hover:not([disabled]) { 33 | background-color: var(--option-color-1-hover); 34 | } 35 | 36 | .mod_kahoodle_option[data-action="answer"]:nth-child(2):hover:not([disabled]) { 37 | background-color: var(--option-color-2-hover); 38 | } 39 | 40 | .mod_kahoodle_option[data-action="answer"]:nth-child(3):hover:not([disabled]) { 41 | background-color: var(--option-color-3-hover); 42 | } 43 | 44 | [data-action="answer"]:nth-child(4):hover:not([disabled]) { 45 | background-color: var(--option-color-4-hover); 46 | } -------------------------------------------------------------------------------- /templates/questionresult_player.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/questionresult_player 19 | 20 | TODO describe template questionresult_player 21 | 22 | Example context (json): 23 | { 24 | } 25 | }} 26 | 27 |
28 |
29 |
You get {{points}} points!
30 | {{#options}} 31 | {{#iscorrect}} 32 |

The correct answer is:

33 |

{{text}}

34 | {{/iscorrect}} 35 | {{/options}} 36 |
37 |
-------------------------------------------------------------------------------- /templates/joinscreen.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/joinscreen 19 | 20 | TODO describe template joinscreen 21 | 22 | Example context (json): 23 | { 24 | "url": "/", 25 | "sesskey": "abc123" 26 | } 27 | }} 28 |
29 | 30 | 31 | 34 |
35 | 36 |
37 |
38 | -------------------------------------------------------------------------------- /lang/en/kahoodle.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * English language pack for Kahoodle 19 | * 20 | * @package mod_kahoodle 21 | * @category string 22 | * @copyright Marina Glancy 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $string['kahoodle:addinstance'] = 'Add a new Kahoodle'; 29 | $string['kahoodle:answer'] = 'Answer Kahoodle'; 30 | $string['kahoodle:transition'] = 'Transition Kahoodle'; 31 | $string['kahoodle:view'] = 'View Kahoodle'; 32 | $string['modulename'] = 'Kahoodle'; 33 | $string['modulenameplural'] = 'Kahoodles'; 34 | $string['pluginadministration'] = 'Kahoodle administration'; 35 | $string['pluginname'] = 'Kahoodle'; 36 | -------------------------------------------------------------------------------- /templates/question.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/question 19 | 20 | TODO describe template question 21 | 22 | Example context (json): 23 | { 24 | "title": "Question", 25 | "source": "https://example.com/image.png", 26 | "answers": [ 27 | {"text": "Answer 1", "id": 1 }, 28 | {"text": "Answer 2", "id": 2 }, 29 | {"text": "Answer 3", "id": 3 } 30 | ] 31 | } 32 | }} 33 | 34 | {{#source}} 35 | {{title}} 36 | {{/source}} 37 |

{{{title}}}

38 |
    39 | {{#answers}} 40 |
  • 41 | 44 |
  • 45 | {{/answers}} 46 |
47 |
48 | -------------------------------------------------------------------------------- /templates/donescreen.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/donescreen 19 | 20 | TODO describe template donescreen 21 | 22 | Example context (json): 23 | { 24 | "players": [ 25 | { 26 | "playerid": 10, 27 | "name": "Max Mustermann", 28 | "points": 100, 29 | "color": "text-primary" 30 | } 31 | ] 32 | } 33 | }} 34 |

This game has finished!

35 |

Score

36 |
    37 | {{#players}} 38 |
  • 39 | 40 | 41 | {{name}}: {{points}} points 42 | 43 |
  • 44 | {{/players}} 45 |
46 | -------------------------------------------------------------------------------- /classes/event/course_module_instance_list_viewed.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle\event; 18 | 19 | /** 20 | * Event course_module_instance_list_viewed 21 | * 22 | * @package mod_kahoodle 23 | * @copyright Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed { 27 | 28 | /** 29 | * Create the event from course record. 30 | * 31 | * @param \stdClass $course 32 | * @return course_module_instance_list_viewed 33 | */ 34 | public static function create_from_course(\stdClass $course) { 35 | $params = [ 36 | 'context' => \context_course::instance($course->id), 37 | ]; 38 | /** @var course_module_instance_list_viewed $event */ 39 | $event = static::create($params); 40 | $event->add_record_snapshot('course', $course); 41 | return $event; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /templates/questionleaderboard_gamemaster.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/questionleaderboard_gamemaster 19 | 20 | TODO describe template questionleaderboard_gamemaster 21 | 22 | Example context (json): 23 | { 24 | "players": [ 25 | { 26 | "playerid": 10, 27 | "name": "Max Mustermann", 28 | "points": 100 29 | } 30 | ] 31 | } 32 | }} 33 |

Current Score

34 |
    35 | {{#players}} 36 |
  • 37 | 38 | 39 | {{name}}: {{points}} points 40 | 41 |
  • 42 | {{/players}} 43 |
44 |
45 | -------------------------------------------------------------------------------- /templates/lobby.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/lobby 19 | 20 | TODO describe template lobby 21 | 22 | Example context (json): 23 | { 24 | "title": "Lobby", 25 | "players": [ 26 | {"name": "Player 1"}, 27 | {"name": "Player 2"}, 28 | {"name": "Player 3"} 29 | ] 30 | } 31 | }} 32 | 33 |
34 |
35 |

{{{title}}}

36 |

Waiting for players to join...

37 | 38 |
    39 | {{#players}} 40 |
  • 41 | 42 | {{{name}}} 43 |
  • 44 | {{/players}} 45 |
46 | 47 |
48 | 51 |
52 |
53 |
54 | -------------------------------------------------------------------------------- /templates/questionresult_gamemaster.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/questionresult_gamemaster 19 | 20 | TODO describe template questionresult_gamemaster 21 | 22 | Example context (json): 23 | { 24 | "uniqid": "uniqueid123", 25 | "chartdata": "\"\"" 26 | } 27 | }} 28 | 29 |
30 | 31 |
32 | 33 | {{#js}} 34 | require([ 35 | 'jquery', 36 | 'core/chart_builder', 37 | 'core/chart_output_chartjs', 38 | ], function($, Builder, Output) { 39 | var data = {{{chartdata}}}, 40 | uniqid = "{{uniqid}}", 41 | chartArea = $('#chart-area-' + uniqid), 42 | chartImage = chartArea.find('.chart-image'); 43 | Builder.make(data).then(function(ChartInst) { 44 | return new Output(chartImage, ChartInst); 45 | }).catch(function(error) { 46 | window.console.error('Error creating chart:', error); 47 | }); 48 | }); 49 | {{/js}} 50 | 51 |
52 | -------------------------------------------------------------------------------- /templates/question_player.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/question_player 19 | 20 | TODO describe template question_player 21 | 22 | Example context (json): 23 | { 24 | "isanswered": false, 25 | "questionid": 5, 26 | "options": [ 27 | {"id": 1, "text": "3"}, 28 | {"id": 2, "text": "4"}, 29 | {"id": 3, "text": "5"}, 30 | {"id": 4, "text": "22"} 31 | ] 32 | } 33 | }} 34 |
35 |
36 |
37 | {{#options}} 38 | 45 | {{/options}} 46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /classes/constants.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle; 18 | 19 | /** 20 | * Constants for the kahoodle module 21 | * 22 | * @package mod_kahoodle 23 | * @copyright Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class constants { 27 | /** @var string game state: game did not start yet, teacher is editing, players are not allowed to join */ 28 | public const STATE_PREPARATION = 'PREPARATION'; 29 | /** @var string game state: waiting for players to join (aka lobby) */ 30 | public const STATE_WAITING = 'WAITING'; 31 | /** @var string game state: game is in progress */ 32 | public const STATE_INPROGRESS = 'INPROGRESS'; 33 | /** @var string game state: game is finished */ 34 | public const STATE_DONE = 'DONE'; 35 | 36 | /** @var string question state: waiting for answers to the current question */ 37 | public const QSTATE_ASKING = 'ASKING'; 38 | /** @var string question state: showing results for the current question */ 39 | public const QSTATE_RESULTS = 'RESULTS'; 40 | /** @var string question state: showing leaderboard for the current question */ 41 | public const QSTATE_LEADERBOARD = 'LEADERBOARD'; 42 | } 43 | -------------------------------------------------------------------------------- /tests/generator/lib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Data generator class 19 | * 20 | * @package mod_kahoodle 21 | * @category test 22 | * @copyright Marina Glancy 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | class mod_kahoodle_generator extends testing_module_generator { 26 | 27 | /** 28 | * Creates an instance of the module for testing purposes. 29 | * 30 | * Module type will be taken from the class name. 31 | * 32 | * @param array|stdClass $record data for module being generated. Requires 'course' key 33 | * (an id or the full object). Also can have any fields from add module form. 34 | * @param null|array $options general options for course module, can be merged into $record 35 | * @return stdClass record from module-defined table with additional field 36 | * cmid (corresponding id in course_modules table) 37 | */ 38 | public function create_instance($record = null, ?array $options = null) { 39 | $record = (object)(array)$record; 40 | // TODO add default values for plugin-specific fields here. 41 | $instance = parent::create_instance($record, (array)$options); 42 | 43 | return $instance; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /classes/event/course_module_viewed.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle\event; 18 | 19 | /** 20 | * Event course_module_viewed 21 | * 22 | * @package mod_kahoodle 23 | * @copyright Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class course_module_viewed extends \core\event\course_module_viewed { 27 | 28 | /** 29 | * Init method. 30 | */ 31 | protected function init() { 32 | parent::init(); 33 | $this->data['objecttable'] = 'kahoodle'; 34 | } 35 | 36 | /** 37 | * Creates an instance of event 38 | * 39 | * @param \stdClass $record 40 | * @param \cm_info|\stdClass $cm 41 | * @param \stdClass $course 42 | * @return course_module_viewed 43 | */ 44 | public static function create_from_record($record, $cm, $course) { 45 | /** @var course_module_viewed $event */ 46 | $event = self::create([ 47 | 'objectid' => $record->id, 48 | 'context' => \context_module::instance($cm->id), 49 | ]); 50 | $event->add_record_snapshot('course_modules', $cm); 51 | $event->add_record_snapshot('course', $course); 52 | $event->add_record_snapshot('kahoodle', $record); 53 | return $event; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backup/moodle2/backup_kahoodle_stepslib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Provides all the settings and steps to perform one complete backup of the activity 19 | * 20 | * @package mod_kahoodle 21 | * @copyright Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | class backup_kahoodle_activity_structure_step extends backup_activity_structure_step { 25 | 26 | /** 27 | * Backup structure 28 | */ 29 | protected function define_structure() { 30 | 31 | // To know if we are including userinfo. 32 | $userinfo = $this->get_setting_value('userinfo'); 33 | 34 | // TODO: add all additional fields from the kahoodle table. 35 | $kahoodle = new backup_nested_element('kahoodle', ['id'], 36 | ['name', 'intro', 'introformat', 'timemodified']); 37 | 38 | // Define sources. 39 | $kahoodle->set_source_table('kahoodle', ['id' => backup::VAR_ACTIVITYID]); 40 | 41 | // Define id annotations. 42 | // TODO: add all additional id annotations. 43 | 44 | // Define file annotations. 45 | // TODO: add all additional file annotations. 46 | $kahoodle->annotate_files('mod_kahoodle', 'intro', null); 47 | 48 | // Return the root element (kahoodle), wrapped into standard activity structure. 49 | return $this->prepare_activity_structure($kahoodle); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /mod_form.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/course/moodleform_mod.php'); 20 | 21 | /** 22 | * Form for adding and editing Kahoodle instances 23 | * 24 | * @package mod_kahoodle 25 | * @copyright Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class mod_kahoodle_mod_form extends moodleform_mod { 29 | 30 | /** 31 | * Defines forms elements 32 | */ 33 | public function definition() { 34 | global $CFG; 35 | 36 | $mform = $this->_form; 37 | 38 | // General fieldset. 39 | $mform->addElement('header', 'general', get_string('general', 'form')); 40 | 41 | $mform->addElement('text', 'name', get_string('name'), ['size' => '64']); 42 | $mform->setType('name', empty($CFG->formatstringstriptags) ? PARAM_CLEANHTML : PARAM_TEXT); 43 | $mform->addRule('name', null, 'required', null, 'client'); 44 | $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); 45 | 46 | if (!empty($this->_features->introeditor)) { 47 | // Description element that is usually added to the General fieldset. 48 | $this->standard_intro_elements(); 49 | } 50 | 51 | // Other standard elements that are displayed in their own fieldsets. 52 | $this->standard_grading_coursemodule_elements(); 53 | $this->standard_coursemodule_elements(); 54 | 55 | $this->add_action_buttons(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /templates/question_gamemaster.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | This file is part of Moodle - http://moodle.org/ 3 | 4 | Moodle is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | Moodle is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with Moodle. If not, see . 16 | }} 17 | {{! 18 | @template mod_kahoodle/question_gamemaster 19 | 20 | TODO describe template question_gamemaster 21 | 22 | Example context (json): 23 | { 24 | "image": "https://raw.githubusercontent.com/moodle/moodle/MOODLE_405_STABLE/mod/feedback/pix/monologo.svg", 25 | "question": "What is 2 + 2?", 26 | "options": [ 27 | {"id": 1, "text": "3"}, 28 | {"id": 2, "text": "4"}, 29 | {"id": 3, "text": "5"}, 30 | {"id": 4, "text": "22"} 31 | ] 32 | } 33 | }} 34 |
35 | {{#image}} 36 |
37 |
38 | Question image 39 |
40 |
41 | {{/image}} 42 | 43 |
44 |
45 |
{{{question}}}
46 |
47 |
48 | 49 |
50 |
51 | {{#options}} 52 |
53 |
{{{text}}}
54 |
55 | {{/options}} 56 |
57 |
58 | 59 |
60 |
61 | 62 |
63 |
64 |
65 | -------------------------------------------------------------------------------- /view.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * View Kahoodle instance 19 | * 20 | * @package mod_kahoodle 21 | * @copyright Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | require(__DIR__.'/../../config.php'); 26 | require_once(__DIR__.'/lib.php'); 27 | 28 | // Course module id. 29 | $id = optional_param('id', 0, PARAM_INT); 30 | 31 | // Activity instance id. 32 | $k = optional_param('k', 0, PARAM_INT); 33 | 34 | if ($id) { 35 | $cm = get_coursemodule_from_id('kahoodle', $id, 0, false, MUST_EXIST); 36 | $course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); 37 | $moduleinstance = $DB->get_record('kahoodle', ['id' => $cm->instance], '*', MUST_EXIST); 38 | } else { 39 | $moduleinstance = $DB->get_record('kahoodle', ['id' => $k], '*', MUST_EXIST); 40 | $course = $DB->get_record('course', ['id' => $moduleinstance->course], '*', MUST_EXIST); 41 | $cm = get_coursemodule_from_instance('kahoodle', $moduleinstance->id, $course->id, false, MUST_EXIST); 42 | } 43 | 44 | require_login($course, true, $cm); 45 | 46 | \mod_kahoodle\event\course_module_viewed::create_from_record($moduleinstance, $cm, $course)->trigger(); 47 | 48 | $api = new \mod_kahoodle\api($PAGE->cm, $PAGE->activityrecord); 49 | $api->process_simple_action(); 50 | 51 | $PAGE->set_url('/mod/kahoodle/view.php', ['id' => $cm->id]); 52 | $PAGE->set_title(format_string($moduleinstance->name)); 53 | $PAGE->set_heading(format_string($course->fullname)); 54 | 55 | echo $OUTPUT->header(); 56 | 57 | $renderer = $PAGE->get_renderer('mod_kahoodle'); 58 | echo $renderer->game($api); 59 | 60 | echo $OUTPUT->footer(); 61 | -------------------------------------------------------------------------------- /backup/moodle2/restore_kahoodle_stepslib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Structure step to restore one Kahoodle activity 19 | * 20 | * @package mod_kahoodle 21 | * @copyright Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | class restore_kahoodle_activity_structure_step extends restore_activity_structure_step { 25 | 26 | /** 27 | * Structure step to restore one kahoodle activity 28 | * 29 | * @return array 30 | */ 31 | protected function define_structure() { 32 | 33 | $paths = []; 34 | $paths[] = new restore_path_element('kahoodle', '/activity/kahoodle'); 35 | 36 | // Return the paths wrapped into standard activity structure. 37 | return $this->prepare_activity_structure($paths); 38 | } 39 | 40 | /** 41 | * Process a kahoodle restore 42 | * 43 | * @param array $data 44 | * @return void 45 | */ 46 | protected function process_kahoodle($data) { 47 | global $DB; 48 | 49 | $data = (object)$data; 50 | $oldid = $data->id; 51 | $data->course = $this->get_courseid(); 52 | 53 | // Insert the kahoodle record. 54 | $newitemid = $DB->insert_record('kahoodle', $data); 55 | // Immediately after inserting "activity" record, call this. 56 | $this->apply_activity_instance($newitemid); 57 | } 58 | 59 | /** 60 | * Actions to be executed after the restore is completed 61 | */ 62 | protected function after_execute() { 63 | // Add kahoodle related files, no need to match by itemname (just internally handled context). 64 | $this->add_related_files('mod_kahoodle', 'intro', null); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /db/access.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Capability definitions for Kahoodle 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/apis/subsystems/access} 21 | * 22 | * @package mod_kahoodle 23 | * @category access 24 | * @copyright Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | 28 | defined('MOODLE_INTERNAL') || die(); 29 | 30 | $capabilities = [ 31 | 32 | 'mod/kahoodle:view' => [ 33 | 'captype' => 'read', 34 | 'contextlevel' => CONTEXT_COURSE, 35 | 'archetypes' => [ 36 | 'guest' => CAP_ALLOW, 37 | 'student' => CAP_ALLOW, 38 | 'teacher' => CAP_ALLOW, 39 | 'editingteacher' => CAP_ALLOW, 40 | 'manager' => CAP_ALLOW, 41 | 'frontpage' => CAP_ALLOW, 42 | ], 43 | ], 44 | 45 | 'mod/kahoodle:addinstance' => [ 46 | 'captype' => 'write', 47 | 'riskbitmask' => RISK_XSS, 48 | 'contextlevel' => CONTEXT_COURSE, 49 | 'archetypes' => [ 50 | 'editingteacher' => CAP_ALLOW, 51 | 'manager' => CAP_ALLOW, 52 | ], 53 | 'clonepermissionsfrom' => 'moodle/course:manageactivities', 54 | ], 55 | 56 | 'mod/kahoodle:transition' => [ 57 | 'captype' => 'write', 58 | 'riskbitmask' => RISK_SPAM, 59 | 'contextlevel' => CONTEXT_MODULE, 60 | 'archetypes' => [ 61 | 'teacher' => CAP_ALLOW, 62 | 'editingteacher' => CAP_ALLOW, 63 | ], 64 | ], 65 | 66 | 'mod/kahoodle:answer' => [ 67 | 'captype' => 'read', 68 | 'contextlevel' => CONTEXT_MODULE, 69 | 'archetypes' => [ 70 | 'student' => CAP_ALLOW, 71 | 'guest' => CAP_ALLOW, 72 | 'frontpage' => CAP_ALLOW, 73 | ], 74 | ], 75 | ]; 76 | -------------------------------------------------------------------------------- /backup/moodle2/backup_kahoodle_activity_task.class.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/mod/kahoodle/backup/moodle2/backup_kahoodle_stepslib.php'); 20 | 21 | /** 22 | * Provides the steps to perform one complete backup of the Kahoodle instance 23 | * 24 | * @package mod_kahoodle 25 | * @copyright Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class backup_kahoodle_activity_task extends backup_activity_task { 29 | 30 | /** 31 | * No specific settings for this activity 32 | */ 33 | protected function define_my_settings() { 34 | } 35 | 36 | /** 37 | * Defines a backup step to store the instance data in the kahoodle.xml file 38 | */ 39 | protected function define_my_steps() { 40 | $this->add_step(new backup_kahoodle_activity_structure_step('kahoodle_structure', 'kahoodle.xml')); 41 | } 42 | 43 | /** 44 | * Encodes URLs to the index.php and view.php scripts 45 | * 46 | * @param string $content some HTML text that eventually contains URLs to the activity instance scripts 47 | * @return string the content with the URLs encoded 48 | */ 49 | public static function encode_content_links($content) { 50 | global $CFG; 51 | 52 | $base = preg_quote($CFG->wwwroot, "/"); 53 | 54 | // Link to the list of kahoodles. 55 | $search = "/(".$base."\/mod\/kahoodle\/index.php\?id\=)([0-9]+)/"; 56 | $content = preg_replace($search, '$@KAHOODLEINDEX*$2@$', $content); 57 | 58 | // Link to kahoodle view by moduleid. 59 | $search = "/(".$base."\/mod\/kahoodle\/view.php\?id\=)([0-9]+)/"; 60 | $content = preg_replace($search, '$@KAHOODLEVIEWBYID*$2@$', $content); 61 | 62 | return $content; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/behat/basic_actions.feature: -------------------------------------------------------------------------------- 1 | @mod @mod_kahoodle 2 | Feature: Basic operations with module Kahoodle 3 | In order to use Kahoodle in Moodle 4 | As a teacher and student 5 | I need to be able to modify and view Kahoodle 6 | 7 | Background: 8 | Given the following "users" exist: 9 | | username | firstname | lastname | email | 10 | | student1 | Sam | Student | student1@example.com | 11 | | teacher1 | Terry | Teacher | teacher1@example.com | 12 | And the following "courses" exist: 13 | | fullname | shortname | category | 14 | | Course 1 | C1 | 0 | 15 | And the following "course enrolments" exist: 16 | | user | course | role | 17 | | student1 | C1 | student | 18 | | teacher1 | C1 | editingteacher | 19 | 20 | @javascript 21 | Scenario: Viewing Kahoodle module and activities index page 22 | Given the following "activities" exist: 23 | | activity | name | course | intro | section | 24 | | kahoodle | Test module name | C1 | Test module description | 1 | 25 | When I log in as "teacher1" 26 | And I am on "Course 1" course homepage with editing mode on 27 | And I add the "Activities" block 28 | And I log out 29 | And I log in as "student1" 30 | And I am on "Course 1" course homepage 31 | And I click on "Test module name" "link" in the "region-main" "region" 32 | And I should see "Test module description" 33 | And I am on "Course 1" course homepage 34 | And I click on "Kahoodles" "link" in the "Activities" "block" 35 | And I should see "1" in the "Test module name" "table_row" 36 | 37 | @javascript 38 | Scenario: Creating and updating Kahoodle module 39 | When I log in as "teacher1" 40 | And I am on "Course 1" course homepage with editing mode on 41 | And I add a "Kahoodle" to section 1 using the activity chooser 42 | And I set the following fields to these values: 43 | | Name | Test module name | 44 | | Description | Test module description | 45 | | Display description on course page | 1 | 46 | And I press "Save and return to course" 47 | And I open "Test module name" actions menu 48 | And I click on "Edit settings" "link" in the "Test module name" activity 49 | And I set the field "Name" to "Test module new name" 50 | And I press "Save and return to course" 51 | And I should see "Test module new name" 52 | And I should not see "Test module name" 53 | And I should see "Test module description" 54 | -------------------------------------------------------------------------------- /classes/output/renderer.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle\output; 18 | 19 | use context_module; 20 | use core\output\html_writer; 21 | use mod_kahoodle\api; 22 | 23 | /** 24 | * Renderer for Kahoodle 25 | * 26 | * @package mod_kahoodle 27 | * @copyright Marina Glancy 28 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 29 | */ 30 | class renderer extends \plugin_renderer_base { 31 | /** 32 | * Summary of render_game 33 | * 34 | * @param api $api 35 | * @return string 36 | */ 37 | public function game(api $api) { 38 | $data = $api->get_game_state(); 39 | $context = $api->get_context(); 40 | $attrs = ['data-cmid' => $api->get_cm()->id, 'data-contextid' => $context->id]; 41 | if ($api->can_transition()) { 42 | $channel = new \tool_realtime\channel($context, 'mod_kahoodle', 'gamemaster', 0); 43 | $channel->subscribe(); 44 | } else if ($playerid = $api->get_player_id()) { 45 | $channel = new \tool_realtime\channel($context, 'mod_kahoodle', 'game', $playerid); 46 | $channel->subscribe(); 47 | $channel2 = new \tool_realtime\channel($context, 'mod_kahoodle', 'game', 0); 48 | $channel2->subscribe(); 49 | $attrs['playerid'] = $playerid; 50 | } 51 | $this->page->requires->js_call_amd('mod_kahoodle/game', 'init'); 52 | $res = html_writer::start_div('', ['id' => 'mod_kahoodle_game'] + $attrs) 53 | . $this->render_from_template($data['template'], $data['data']) 54 | . html_writer::end_div(); 55 | 56 | if ($api->can_transition()) { 57 | $reseturl = new \moodle_url("/mod/kahoodle/view.php", 58 | ['id' => $api->get_cm()->id, 'action' => 'reset', 'sesskey' => sesskey()]); 59 | $res .= "
". 60 | html_writer::link($reseturl, "Reset game") 61 | ."
"; 62 | } 63 | 64 | return $res; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Display information about all the Kahoodle modules in the requested course 19 | * 20 | * @package mod_kahoodle 21 | * @copyright Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | require(__DIR__ . '/../../config.php'); 26 | 27 | $id = required_param('id', PARAM_INT); 28 | 29 | $course = $DB->get_record('course', ['id' => $id], '*', MUST_EXIST); 30 | require_course_login($course); 31 | 32 | \mod_kahoodle\event\course_module_instance_list_viewed::create_from_course($course)->trigger(); 33 | 34 | $PAGE->set_url('/mod/kahoodle/index.php', ['id' => $id]); 35 | $PAGE->set_title(format_string($course->fullname)); 36 | $PAGE->set_heading(format_string($course->fullname)); 37 | 38 | echo $OUTPUT->header(); 39 | 40 | $modulenameplural = get_string('modulenameplural', 'mod_kahoodle'); 41 | echo $OUTPUT->heading($modulenameplural); 42 | 43 | $instances = get_all_instances_in_course('kahoodle', $course); 44 | 45 | if (empty($instances)) { 46 | notice(get_string('thereareno', 'moodle', $modulenameplural), 47 | new moodle_url('/course/view.php', ['id' => $course->id])); 48 | } 49 | 50 | $table = new html_table(); 51 | $table->attributes['class'] = 'generaltable mod_index'; 52 | 53 | $usesections = course_format_uses_sections($course->format); 54 | 55 | $table = new html_table(); 56 | $table->attributes['class'] = 'generaltable mod_index'; 57 | 58 | if ($usesections) { 59 | $strsectionname = get_string('sectionname', 'format_'.$course->format); 60 | $table->head = [$strsectionname, get_string('name')]; 61 | $table->align = ['left', 'left']; 62 | } else { 63 | $table->head = [get_string('name')]; 64 | $table->align = ['left']; 65 | } 66 | 67 | foreach ($instances as $instance) { 68 | $attrs = $instance->visible ? [] : ['class' => 'dimmed']; // Hidden modules are dimmed. 69 | $link = html_writer::link( 70 | new moodle_url('/mod/kahoodle/view.php', ['id' => $instance->coursemodule]), 71 | format_string($instance->name, true), 72 | $attrs); 73 | 74 | if ($usesections) { 75 | $table->data[] = [$instance->section, $link]; 76 | } else { 77 | $table->data[] = [$link]; 78 | } 79 | } 80 | 81 | echo html_writer::table($table); 82 | echo $OUTPUT->footer(); 83 | -------------------------------------------------------------------------------- /backup/moodle2/restore_kahoodle_activity_task.class.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/mod/kahoodle/backup/moodle2/restore_kahoodle_stepslib.php'); 20 | 21 | /** 22 | * Testore task that provides all the settings and steps to perform one complete restore of the activity 23 | * 24 | * @package mod_kahoodle 25 | * @copyright Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class restore_kahoodle_activity_task extends restore_activity_task { 29 | 30 | /** 31 | * Define (add) particular settings this activity can have 32 | */ 33 | protected function define_my_settings() { 34 | // No particular settings for this activity. 35 | } 36 | 37 | /** 38 | * Define (add) particular steps this activity can have 39 | */ 40 | protected function define_my_steps() { 41 | $this->add_step(new restore_kahoodle_activity_structure_step('kahoodle_structure', 'kahoodle.xml')); 42 | } 43 | 44 | /** 45 | * Define the contents in the activity that must be processed by the link decoder 46 | * 47 | * @return array 48 | */ 49 | public static function define_decode_contents() { 50 | $contents = []; 51 | 52 | $contents[] = new restore_decode_content('kahoodle', ['intro'], 'kahoodle'); 53 | 54 | return $contents; 55 | } 56 | 57 | /** 58 | * Define the decoding rules for links belonging to the activity to be executed by the link decoder 59 | * 60 | * @return array 61 | */ 62 | public static function define_decode_rules() { 63 | $rules = []; 64 | 65 | $rules[] = new restore_decode_rule('KAHOODLEVIEWBYID', '/mod/kahoodle/view.php?id=$1', 'course_module'); 66 | $rules[] = new restore_decode_rule('KAHOODLEINDEX', '/mod/kahoodle/index.php?id=$1', 'course'); 67 | 68 | return $rules; 69 | } 70 | 71 | /** 72 | * Define the restoring rules for logs belonging to the activity to be executed by the link decoder. 73 | * 74 | * @return array 75 | */ 76 | public static function define_restore_log_rules() { 77 | $rules = []; 78 | 79 | $rules[] = new restore_log_rule('kahoodle', 'add', 'view.php?id={course_module}', '{kahoodle}'); 80 | $rules[] = new restore_log_rule('kahoodle', 'update', 'view.php?id={course_module}', '{kahoodle}'); 81 | $rules[] = new restore_log_rule('kahoodle', 'view', 'view.php?id={course_module}', '{kahoodle}'); 82 | 83 | return $rules; 84 | } 85 | 86 | /** 87 | * Define the restoring rules for course associated to the activity to be executed by the link decoder. 88 | * 89 | * @return array 90 | */ 91 | public static function define_restore_log_rules_for_course() { 92 | $rules = []; 93 | 94 | $rules[] = new restore_log_rule('kahoodle', 'view all', 'index.php?id={course}', null); 95 | 96 | return $rules; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/lib_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle; 18 | 19 | /** 20 | * Tests for Kahoodle 21 | * 22 | * @package mod_kahoodle 23 | * @category test 24 | * @copyright Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | final class lib_test extends \advanced_testcase { 28 | 29 | /** 30 | * Test create and delete module 31 | * 32 | * @covers ::kahoodle_add_instance 33 | * @covers ::kahoodle_delete_instance 34 | * @return void 35 | */ 36 | public function test_create_delete_module(): void { 37 | global $DB; 38 | $this->resetAfterTest(); 39 | 40 | // Disable recycle bin so we are testing module deletion and not backup. 41 | set_config('coursebinenable', 0, 'tool_recyclebin'); 42 | 43 | // Create an instance of a module. 44 | $course = $this->getDataGenerator()->create_course(); 45 | $mod = $this->getDataGenerator()->create_module('kahoodle', ['course' => $course->id]); 46 | $cm = get_coursemodule_from_instance('kahoodle', $mod->id); 47 | 48 | // Assert it was created. 49 | $this->assertNotEmpty(\context_module::instance($mod->cmid)); 50 | $this->assertEquals($mod->id, $cm->instance); 51 | $this->assertEquals('kahoodle', $cm->modname); 52 | $this->assertEquals(1, $DB->count_records('kahoodle', ['id' => $mod->id])); 53 | $this->assertEquals(1, $DB->count_records('course_modules', ['id' => $cm->id])); 54 | 55 | // Delete module. 56 | course_delete_module($cm->id); 57 | $this->assertEquals(0, $DB->count_records('kahoodle', ['id' => $mod->id])); 58 | $this->assertEquals(0, $DB->count_records('course_modules', ['id' => $cm->id])); 59 | } 60 | 61 | /** 62 | * Test module backup and restore by duplicating it 63 | * 64 | * @covers \backup_kahoodle_activity_structure_step 65 | * @covers \restore_kahoodle_activity_structure_step 66 | * @return void 67 | */ 68 | public function test_backup_restore(): void { 69 | global $DB; 70 | $this->resetAfterTest(); 71 | $this->setAdminUser(); 72 | 73 | // Createa a module. 74 | $course = $this->getDataGenerator()->create_course(); 75 | $mod = $this->getDataGenerator()->create_module('kahoodle', 76 | ['course' => $course->id, 'name' => 'My test module']); 77 | $cm = get_coursemodule_from_instance('kahoodle', $mod->id); 78 | 79 | // Call duplicate_module - it will backup and restore this module. 80 | $cmnew = duplicate_module($course, $cm); 81 | 82 | $this->assertNotNull($cmnew); 83 | $this->assertGreaterThan($cm->id, $cmnew->id); 84 | $this->assertGreaterThan($mod->id, $cmnew->instance); 85 | $this->assertEquals('kahoodle', $cmnew->modname); 86 | 87 | $name = $DB->get_field('kahoodle', 'name', ['id' => $cmnew->instance]); 88 | $this->assertEquals('My test module (copy)', $name); 89 | // TODO: check other fields and related tables. 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /amd/build/game.min.js: -------------------------------------------------------------------------------- 1 | define("mod_kahoodle/game",["exports","core/pubsub","tool_realtime/events","core/notification","tool_realtime/api","core/templates"],(function(_exports,PubSub,RealTimeEvents,Notification,RealTimeApi,_templates){var obj;function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} 2 | /** 3 | * Kahoodle game actions 4 | * 5 | * @module mod_kahoodle/game 6 | * @copyright Marina Glancy 7 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 8 | */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.doTransition=_exports.doAnswer=void 0,PubSub=_interopRequireWildcard(PubSub),RealTimeEvents=_interopRequireWildcard(RealTimeEvents),Notification=_interopRequireWildcard(Notification),RealTimeApi=_interopRequireWildcard(RealTimeApi),_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj};const SELECTORS_MAINDIV="#mod_kahoodle_game[data-cmid][data-contextid]",SELECTORS_TRANSITIONBUTTON='[data-action="transition"]',SELECTORS_ANSWERBUTTON='[data-action="answer"][data-questionid]';var initialised=!1;_exports.init=()=>{initialised||(initialised=!0,PubSub.subscribe(RealTimeEvents.CONNECTION_LOST,(e=>{window.console.log("Error",e),Notification.exception({name:"Error",message:"Something went wrong, please refresh the page"})})),PubSub.subscribe(RealTimeEvents.EVENT,(data=>{window.console.log("received event "+JSON.stringify(data));var{context:context,contextid:contextid,component:component,payload:payload}=data;!contextid&&null!=context&&context.id&&(contextid=context.id),window.console.log("-details-: "+JSON.stringify({contextid:contextid,component:component,payload:payload}));const node=document.querySelector(SELECTORS_MAINDIV);if(!payload||"mod_kahoodle"!=component||contextid!=node.dataset.contextid)return void window.console.log("Ignoring event for different context or component");const updates=data.payload;window.console.log("updates = "+JSON.stringify(updates)),updates.template?void 0===updates.data?window.console.error("Unexpected result - data is missing"):_templates.default.render(updates.template,updates.data).then((function(html,js){return _templates.default.replaceNodeContents(node,html,js),null})).catch((function(error){window.console.error("Error rendering template:",error)})):window.console.error("Unexpected result - template is missing")})),document.addEventListener("click",(e=>{const answerButton=e.target.closest(SELECTORS_MAINDIV+" "+SELECTORS_ANSWERBUTTON);answerButton&&doAnswer(parseInt(answerButton.dataset.questionid),parseInt(answerButton.dataset.optionid));e.target.closest(SELECTORS_MAINDIV+" "+SELECTORS_TRANSITIONBUTTON)&&doTransition()})))};const doTransition=()=>{const node=document.querySelector(SELECTORS_MAINDIV);RealTimeApi.sendToServer({contextid:node.dataset.contextid,component:"mod_kahoodle",area:"game",itemid:0},{action:"transition"})};_exports.doTransition=doTransition;const doAnswer=(questionid,answer)=>{var _node$dataset$playeri;const node=document.querySelector(SELECTORS_MAINDIV);RealTimeApi.sendToServer({contextid:node.dataset.contextid,component:"mod_kahoodle",area:"game",itemid:parseInt(null!==(_node$dataset$playeri=node.dataset.playerid)&&void 0!==_node$dataset$playeri?_node$dataset$playeri:0)},{action:"answer",questionid:questionid,answer:answer})};_exports.doAnswer=doAnswer})); 9 | 10 | //# sourceMappingURL=game.min.js.map -------------------------------------------------------------------------------- /db/install.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 |
70 |
71 | -------------------------------------------------------------------------------- /amd/src/game.js: -------------------------------------------------------------------------------- 1 | // This file is part of Moodle - http://moodle.org/ 2 | // 3 | // Moodle is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // Moodle is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with Moodle. If not, see . 15 | 16 | /** 17 | * Kahoodle game actions 18 | * 19 | * @module mod_kahoodle/game 20 | * @copyright Marina Glancy 21 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 22 | */ 23 | 24 | import * as PubSub from 'core/pubsub'; 25 | import * as RealTimeEvents from 'tool_realtime/events'; 26 | import * as Notification from 'core/notification'; 27 | import * as RealTimeApi from 'tool_realtime/api'; 28 | import Templates from 'core/templates'; 29 | 30 | const SELECTORS = { 31 | MAINDIV: '#mod_kahoodle_game[data-cmid][data-contextid]', 32 | TRANSITIONBUTTON: '[data-action="transition"]', 33 | ANSWERBUTTON: '[data-action="answer"][data-questionid]', 34 | 35 | }; 36 | 37 | var initialised = false; 38 | 39 | export const init = () => { 40 | if (initialised) { 41 | return; 42 | } 43 | initialised = true; 44 | 45 | PubSub.subscribe(RealTimeEvents.CONNECTION_LOST, (e) => { 46 | window.console.log('Error', e); 47 | Notification.exception({ 48 | name: 'Error', 49 | message: 'Something went wrong, please refresh the page'}); 50 | }); 51 | 52 | PubSub.subscribe(RealTimeEvents.EVENT, (data) => { 53 | window.console.log('received event ' + JSON.stringify(data)); 54 | var {context, contextid, component, payload} = data; 55 | if (!contextid && context?.id) { 56 | contextid = context.id; 57 | } 58 | window.console.log('-details-: ' + JSON.stringify({contextid: contextid, component, payload})); 59 | const node = document.querySelector(SELECTORS.MAINDIV); 60 | if (!payload || component != 'mod_kahoodle' || contextid != node.dataset.contextid) { 61 | window.console.log('Ignoring event for different context or component'); 62 | return; 63 | } 64 | 65 | const updates = data.payload; 66 | window.console.log('updates = ' + JSON.stringify(updates)); 67 | // Render updates. 68 | if (!updates.template) { 69 | window.console.error('Unexpected result - template is missing'); 70 | } else if (updates.data === undefined) { 71 | window.console.error('Unexpected result - data is missing'); 72 | } else { 73 | // TODO validate template and data. 74 | Templates.render(updates.template, updates.data) 75 | .then(function(html, js) { 76 | // Append the link to the most suitable place on the page with fallback to legacy selectors and finally the body if 77 | // there is no better place. 78 | Templates.replaceNodeContents(node, html, js); 79 | 80 | return null; 81 | }) 82 | .catch(function(error) { 83 | window.console.error('Error rendering template:', error); 84 | }); 85 | } 86 | }); 87 | 88 | document.addEventListener('click', (e) => { 89 | const answerButton = e.target.closest(SELECTORS.MAINDIV + " " + SELECTORS.ANSWERBUTTON); 90 | if (answerButton) { 91 | doAnswer(parseInt(answerButton.dataset.questionid), parseInt(answerButton.dataset.optionid)); 92 | } 93 | const transitionButton = e.target.closest(SELECTORS.MAINDIV + " " + SELECTORS.TRANSITIONBUTTON); 94 | if (transitionButton) { 95 | doTransition(); 96 | } 97 | }); 98 | }; 99 | 100 | export const doTransition = () => { 101 | const node = document.querySelector(SELECTORS.MAINDIV); 102 | RealTimeApi.sendToServer({ 103 | contextid: node.dataset.contextid, 104 | component: 'mod_kahoodle', 105 | area: 'game', 106 | itemid: 0, 107 | }, { 108 | action: 'transition' 109 | }); 110 | }; 111 | 112 | export const doAnswer = (questionid, answer) => { 113 | const node = document.querySelector(SELECTORS.MAINDIV); 114 | RealTimeApi.sendToServer({ 115 | contextid: node.dataset.contextid, 116 | component: 'mod_kahoodle', 117 | area: 'game', 118 | itemid: parseInt(node.dataset.playerid ?? 0) 119 | }, { 120 | action: 'answer', 121 | questionid, 122 | answer 123 | }); 124 | }; 125 | -------------------------------------------------------------------------------- /.github/workflows/gha.yml: -------------------------------------------------------------------------------- 1 | name: Moodle Plugin CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | 9 | services: 10 | postgres: 11 | image: postgres:15 12 | env: 13 | POSTGRES_USER: 'postgres' 14 | POSTGRES_HOST_AUTH_METHOD: 'trust' 15 | ports: 16 | - 5432:5432 17 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 18 | 19 | mariadb: 20 | image: mariadb:10 21 | env: 22 | MYSQL_USER: 'root' 23 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 24 | MYSQL_CHARACTER_SET_SERVER: "utf8mb4" 25 | MYSQL_COLLATION_SERVER: "utf8mb4_unicode_ci" 26 | ports: 27 | - 3306:3306 28 | options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | include: 34 | - php: '8.3' 35 | # Main job. Run all checks that do not require setup and only need to be run once. 36 | runchecks: 'all' 37 | moodle-branch: 'MOODLE_405_STABLE' 38 | database: 'pgsql' 39 | - php: '8.1' 40 | moodle-branch: 'MOODLE_405_STABLE' 41 | database: 'mariadb' 42 | - php: '8.4' 43 | moodle-branch: 'MOODLE_500_STABLE' 44 | database: 'mariadb' 45 | - php: '8.2' 46 | moodle-branch: 'MOODLE_500_STABLE' 47 | database: 'pgsql' 48 | 49 | steps: 50 | - name: Check out repository code 51 | uses: actions/checkout@v4 52 | with: 53 | path: plugin 54 | 55 | - name: Setup PHP ${{ matrix.php }} 56 | uses: shivammathur/setup-php@v2 57 | with: 58 | php-version: ${{ matrix.php }} 59 | extensions: ${{ matrix.extensions }} 60 | ini-values: max_input_vars=5000 61 | # If you are not using code coverage, keep "none". Otherwise, use "pcov" (Moodle 3.10 and up) or "xdebug". 62 | # If you try to use code coverage with "none", it will fallback to phpdbg (which has known problems). 63 | coverage: none 64 | 65 | - name: Initialise moodle-plugin-ci 66 | run: | 67 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 68 | echo $(cd ci/bin; pwd) >> $GITHUB_PATH 69 | echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH 70 | sudo locale-gen en_AU.UTF-8 71 | echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV 72 | 73 | - name: Install moodle-plugin-ci 74 | run: | 75 | moodle-plugin-ci add-plugin marinaglancy/moodle-tool_realtime 76 | moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 77 | env: 78 | DB: ${{ matrix.database }} 79 | MOODLE_BRANCH: ${{ matrix.moodle-branch }} 80 | # Uncomment this to run Behat tests using the Moodle App. 81 | # MOODLE_APP: 'true' 82 | 83 | - name: PHP Lint 84 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 85 | run: moodle-plugin-ci phplint 86 | 87 | - name: PHP Mess Detector 88 | continue-on-error: true # This step will show errors but will not fail 89 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 90 | run: moodle-plugin-ci phpmd 91 | 92 | - name: Moodle Code Checker 93 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 94 | run: moodle-plugin-ci phpcs --max-warnings 0 95 | 96 | - name: Moodle PHPDoc Checker 97 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 98 | run: moodle-plugin-ci phpdoc --max-warnings 0 99 | 100 | - name: Validating 101 | if: ${{ !cancelled() }} 102 | run: moodle-plugin-ci validate 103 | 104 | - name: Check upgrade savepoints 105 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 106 | run: moodle-plugin-ci savepoints 107 | 108 | - name: Mustache Lint 109 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 110 | run: moodle-plugin-ci mustache 111 | 112 | - name: Grunt 113 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 114 | run: moodle-plugin-ci grunt --max-lint-warnings 0 115 | 116 | - name: PHPUnit tests 117 | if: ${{ !cancelled() }} 118 | run: moodle-plugin-ci phpunit --fail-on-warning 119 | 120 | - name: Behat features 121 | id: behat 122 | if: ${{ !cancelled() }} 123 | run: moodle-plugin-ci behat --profile chrome --scss-deprecations 124 | 125 | - name: Upload Behat Faildump 126 | if: ${{ failure() && steps.behat.outcome == 'failure' }} 127 | uses: actions/upload-artifact@v4 128 | with: 129 | name: Behat Faildump (${{ join(matrix.*, ', ') }}) 130 | path: ${{ github.workspace }}/moodledata/behat_dump 131 | retention-days: 7 132 | if-no-files-found: ignore 133 | 134 | - name: Mark cancelled jobs as failed. 135 | if: ${{ cancelled() }} 136 | run: exit 1 137 | -------------------------------------------------------------------------------- /lib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Callback implementations for Kahoodle 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/apis/plugintypes/mod} 21 | * 22 | * @package mod_kahoodle 23 | * @copyright Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | 27 | /** 28 | * List of features supported in module 29 | * 30 | * @param string $feature FEATURE_xx constant for requested feature 31 | * @return mixed True if module supports feature, false if not, null if doesn't know or string for the module purpose. 32 | */ 33 | function kahoodle_supports($feature) { 34 | switch ($feature) { 35 | case FEATURE_MOD_INTRO: 36 | return true; 37 | case FEATURE_SHOW_DESCRIPTION: 38 | return true; 39 | case FEATURE_BACKUP_MOODLE2: 40 | return true; 41 | case FEATURE_MOD_PURPOSE: 42 | return MOD_PURPOSE_CONTENT; 43 | default: 44 | return null; 45 | } 46 | } 47 | 48 | /** 49 | * Add Kahoodle instance 50 | * 51 | * Given an object containing all the necessary data, (defined by the form in mod_form.php) 52 | * this function will create a new instance and return the id of the instance 53 | * 54 | * @param stdClass $moduleinstance form data 55 | * @param mod_kahoodle_mod_form $form the form 56 | * @return int new instance id 57 | */ 58 | function kahoodle_add_instance($moduleinstance, $form = null) { 59 | global $DB; 60 | 61 | $moduleinstance->timecreated = time(); 62 | $moduleinstance->timemodified = time(); 63 | 64 | $id = $DB->insert_record('kahoodle', $moduleinstance); 65 | $completiontimeexpected = !empty($moduleinstance->completionexpected) ? $moduleinstance->completionexpected : null; 66 | \core_completion\api::update_completion_date_event($moduleinstance->coursemodule, 67 | 'kahoodle', $id, $completiontimeexpected); 68 | return $id; 69 | } 70 | 71 | /** 72 | * Updates an instance of the Kahoodle in the database. 73 | * 74 | * Given an object containing all the necessary data (defined in mod_form.php), 75 | * this function will update an existing instance with new data. 76 | * 77 | * @param stdClass $moduleinstance An object from the form in mod_form.php 78 | * @param mod_kahoodle_mod_form $form The form 79 | * @return bool True if successful, false otherwis 80 | */ 81 | function kahoodle_update_instance($moduleinstance, $form = null) { 82 | global $DB; 83 | 84 | $moduleinstance->timemodified = time(); 85 | $moduleinstance->id = $moduleinstance->instance; 86 | 87 | $DB->update_record('kahoodle', $moduleinstance); 88 | 89 | $completiontimeexpected = !empty($moduleinstance->completionexpected) ? $moduleinstance->completionexpected : null; 90 | \core_completion\api::update_completion_date_event($moduleinstance->coursemodule, 'kahoodle', 91 | $moduleinstance->id, $completiontimeexpected); 92 | 93 | return true; 94 | } 95 | 96 | /** 97 | * Removes an instance of the Kahoodle from the database. 98 | * 99 | * @param int $id Id of the module instance 100 | * @return bool True if successful, false otherwise 101 | */ 102 | function kahoodle_delete_instance($id) { 103 | global $DB; 104 | 105 | $record = $DB->get_record('kahoodle', ['id' => $id]); 106 | if (!$record) { 107 | return false; 108 | } 109 | 110 | // Delete all calendar events. 111 | $events = $DB->get_records('event', ['modulename' => 'kahoodle', 'instance' => $record->id]); 112 | foreach ($events as $event) { 113 | calendar_event::load($event)->delete(); 114 | } 115 | 116 | // Delete the instance. 117 | $DB->delete_records('kahoodle', ['id' => $id]); 118 | 119 | return true; 120 | } 121 | 122 | /** 123 | * Check if the module has any update that affects the current user since a given time. 124 | * 125 | * @param cm_info $cm course module data 126 | * @param int $from the time to check updates from 127 | * @param array $filter if we need to check only specific updates 128 | * @return stdClass an object with the different type of areas indicating if they were updated or not 129 | */ 130 | function mod_kahoodle_check_updates_since(cm_info $cm, $from, $filter = []) { 131 | $updates = course_check_module_updates_since($cm, $from, ['content'], $filter); 132 | return $updates; 133 | } 134 | 135 | /** 136 | * Callback for tool_realtime 137 | * 138 | * @param tool_realtime\channel $channel 139 | * @param mixed $payload 140 | * @return array 141 | */ 142 | function mod_kahoodle_realtime_event_received($channel, $payload) { 143 | global $DB; 144 | $context = context::instance_by_id($channel->get_properties()['contextid']); 145 | if ($context->contextlevel != CONTEXT_MODULE) { 146 | throw new \moodle_exception('invalidcontext'); 147 | } 148 | $cmid = $context->instanceid; 149 | [$course, $cm] = get_course_and_cm_from_cmid($cmid, 'kahoodle'); 150 | $activity = $DB->get_record('kahoodle', ['id' => $cm->instance], '*', MUST_EXIST); 151 | 152 | (new \mod_kahoodle\api($cm, $activity))->handle_realtime_event($payload); 153 | return []; 154 | } 155 | -------------------------------------------------------------------------------- /amd/build/game.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"game.min.js","sources":["../src/game.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Kahoodle game actions\n *\n * @module mod_kahoodle/game\n * @copyright Marina Glancy\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as PubSub from 'core/pubsub';\nimport * as RealTimeEvents from 'tool_realtime/events';\nimport * as Notification from 'core/notification';\nimport * as RealTimeApi from 'tool_realtime/api';\nimport Templates from 'core/templates';\n\nconst SELECTORS = {\n MAINDIV: '#mod_kahoodle_game[data-cmid][data-contextid]',\n TRANSITIONBUTTON: '[data-action=\"transition\"]',\n ANSWERBUTTON: '[data-action=\"answer\"][data-questionid]',\n\n};\n\nvar initialised = false;\n\nexport const init = () => {\n if (initialised) {\n return;\n }\n initialised = true;\n\n PubSub.subscribe(RealTimeEvents.CONNECTION_LOST, (e) => {\n window.console.log('Error', e);\n Notification.exception({\n name: 'Error',\n message: 'Something went wrong, please refresh the page'});\n });\n\n PubSub.subscribe(RealTimeEvents.EVENT, (data) => {\n window.console.log('received event ' + JSON.stringify(data));\n var {context, contextid, component, payload} = data;\n if (!contextid && context?.id) {\n contextid = context.id;\n }\n window.console.log('-details-: ' + JSON.stringify({contextid: contextid, component, payload}));\n const node = document.querySelector(SELECTORS.MAINDIV);\n if (!payload || component != 'mod_kahoodle' || contextid != node.dataset.contextid) {\n window.console.log('Ignoring event for different context or component');\n return;\n }\n\n const updates = data.payload;\n window.console.log('updates = ' + JSON.stringify(updates));\n // Render updates.\n if (!updates.template) {\n window.console.error('Unexpected result - template is missing');\n } else if (updates.data === undefined) {\n window.console.error('Unexpected result - data is missing');\n } else {\n // TODO validate template and data.\n Templates.render(updates.template, updates.data)\n .then(function(html, js) {\n // Append the link to the most suitable place on the page with fallback to legacy selectors and finally the body if\n // there is no better place.\n Templates.replaceNodeContents(node, html, js);\n\n return null;\n })\n .catch(function(error) {\n window.console.error('Error rendering template:', error);\n });\n }\n });\n\n document.addEventListener('click', (e) => {\n const answerButton = e.target.closest(SELECTORS.MAINDIV + \" \" + SELECTORS.ANSWERBUTTON);\n if (answerButton) {\n doAnswer(parseInt(answerButton.dataset.questionid), parseInt(answerButton.dataset.optionid));\n }\n const transitionButton = e.target.closest(SELECTORS.MAINDIV + \" \" + SELECTORS.TRANSITIONBUTTON);\n if (transitionButton) {\n doTransition();\n }\n });\n};\n\nexport const doTransition = () => {\n const node = document.querySelector(SELECTORS.MAINDIV);\n RealTimeApi.sendToServer({\n contextid: node.dataset.contextid,\n component: 'mod_kahoodle',\n area: 'game',\n itemid: 0,\n }, {\n action: 'transition'\n });\n};\n\nexport const doAnswer = (questionid, answer) => {\n const node = document.querySelector(SELECTORS.MAINDIV);\n RealTimeApi.sendToServer({\n contextid: node.dataset.contextid,\n component: 'mod_kahoodle',\n area: 'game',\n itemid: parseInt(node.dataset.playerid ?? 0)\n }, {\n action: 'answer',\n questionid,\n answer\n });\n};\n"],"names":["SELECTORS","initialised","PubSub","subscribe","RealTimeEvents","CONNECTION_LOST","e","window","console","log","Notification","exception","name","message","EVENT","data","JSON","stringify","context","contextid","component","payload","id","node","document","querySelector","dataset","updates","template","undefined","error","render","then","html","js","replaceNodeContents","catch","addEventListener","answerButton","target","closest","doAnswer","parseInt","questionid","optionid","doTransition","RealTimeApi","sendToServer","area","itemid","action","answer","playerid"],"mappings":";;;;;;;gYA6BMA,kBACO,gDADPA,2BAEgB,6BAFhBA,uBAGY,8CAIdC,aAAc,gBAEE,KACZA,cAGJA,aAAc,EAEdC,OAAOC,UAAUC,eAAeC,iBAAkBC,IAC9CC,OAAOC,QAAQC,IAAI,QAASH,GAC5BI,aAAaC,UAAU,CACnBC,KAAM,QACNC,QAAS,qDAGjBX,OAAOC,UAAUC,eAAeU,OAAQC,OACpCR,OAAOC,QAAQC,IAAI,kBAAoBO,KAAKC,UAAUF,WAClDG,QAACA,QAADC,UAAUA,UAAVC,UAAqBA,UAArBC,QAAgCA,SAAWN,MAC1CI,WAAD,MAAcD,SAAAA,QAASI,KACvBH,UAAYD,QAAQI,IAExBf,OAAOC,QAAQC,IAAI,cAAgBO,KAAKC,UAAU,CAACE,UAAWA,UAAWC,UAAAA,UAAWC,QAAAA,iBAC9EE,KAAOC,SAASC,cAAczB,uBAC/BqB,SAAwB,gBAAbD,WAA+BD,WAAaI,KAAKG,QAAQP,sBACrEZ,OAAOC,QAAQC,IAAI,2DAIjBkB,QAAUZ,KAAKM,QACrBd,OAAOC,QAAQC,IAAI,aAAeO,KAAKC,UAAUU,UAE5CA,QAAQC,cAEeC,IAAjBF,QAAQZ,KACfR,OAAOC,QAAQsB,MAAM,0DAGXC,OAAOJ,QAAQC,SAAUD,QAAQZ,MAC1CiB,MAAK,SAASC,KAAMC,8BAGPC,oBAAoBZ,KAAMU,KAAMC,IAEnC,QAEVE,OAAM,SAASN,OACZvB,OAAOC,QAAQsB,MAAM,4BAA6BA,UAdtDvB,OAAOC,QAAQsB,MAAM,8CAmB7BN,SAASa,iBAAiB,SAAU/B,UAC1BgC,aAAehC,EAAEiC,OAAOC,QAAQxC,kBAAoB,IAAMA,wBAC5DsC,cACAG,SAASC,SAASJ,aAAaZ,QAAQiB,YAAaD,SAASJ,aAAaZ,QAAQkB,WAE7DtC,EAAEiC,OAAOC,QAAQxC,kBAAoB,IAAMA,6BAEhE6C,0BAKCA,aAAe,WAClBtB,KAAOC,SAASC,cAAczB,mBACpC8C,YAAYC,aAAa,CACrB5B,UAAWI,KAAKG,QAAQP,UACxBC,UAAW,eACX4B,KAAM,OACNC,OAAQ,GACT,CACCC,OAAQ,yDAIHT,SAAW,CAACE,WAAYQ,0CAC3B5B,KAAOC,SAASC,cAAczB,mBACpC8C,YAAYC,aAAa,CACrB5B,UAAWI,KAAKG,QAAQP,UACxBC,UAAW,eACX4B,KAAM,OACNC,OAAQP,uCAASnB,KAAKG,QAAQ0B,gEAAY,IAC3C,CACCF,OAAQ,SACRP,WAAAA,WACAQ,OAAAA"} -------------------------------------------------------------------------------- /db/upgrade.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Upgrade steps for Kahoodle 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/guides/upgrade} 21 | * 22 | * @package mod_kahoodle 23 | * @category upgrade 24 | * @copyright Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | 28 | /** 29 | * Execute the plugin upgrade steps from the given old version. 30 | * 31 | * @param int $oldversion 32 | * @return bool 33 | */ 34 | function xmldb_kahoodle_upgrade($oldversion) { 35 | global $DB; 36 | $dbman = $DB->get_manager(); 37 | 38 | if ($oldversion < 2025090204) { 39 | 40 | // Define table kahoodle_questions to be created. 41 | $table = new xmldb_table('kahoodle_questions'); 42 | 43 | // Adding fields to table kahoodle_questions. 44 | $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); 45 | $table->add_field('kahoodle_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 46 | $table->add_field('started_at', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 47 | $table->add_field('question', XMLDB_TYPE_TEXT, null, null, null, null, null); 48 | $table->add_field('duration', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '60'); 49 | $table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 50 | 51 | // Adding keys to table kahoodle_questions. 52 | $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); 53 | $table->add_key('kahoodle_id', XMLDB_KEY_FOREIGN, ['kahoodle_id'], 'kahoodle', ['id']); 54 | 55 | // Conditionally launch create table for kahoodle_questions. 56 | if (!$dbman->table_exists($table)) { 57 | $dbman->create_table($table); 58 | } 59 | 60 | // Define table kahoodle_players to be created. 61 | $table = new xmldb_table('kahoodle_players'); 62 | 63 | // Adding fields to table kahoodle_players. 64 | $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); 65 | $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); 66 | $table->add_field('user_id', XMLDB_TYPE_INTEGER, '10', null, null, null, null); 67 | $table->add_field('session_id', XMLDB_TYPE_CHAR, '100', null, null, null, null); 68 | 69 | // Adding keys to table kahoodle_players. 70 | $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); 71 | $table->add_key('user_id', XMLDB_KEY_FOREIGN, ['user_id'], 'user', ['id']); 72 | 73 | // Conditionally launch create table for kahoodle_players. 74 | if (!$dbman->table_exists($table)) { 75 | $dbman->create_table($table); 76 | } 77 | 78 | // Define table kahoodle_answers to be created. 79 | $table = new xmldb_table('kahoodle_answers'); 80 | 81 | // Adding fields to table kahoodle_answers. 82 | $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); 83 | $table->add_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 84 | $table->add_field('player_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 85 | $table->add_field('points', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); 86 | $table->add_field('answer', XMLDB_TYPE_TEXT, null, null, null, null, null); 87 | 88 | // Adding keys to table kahoodle_answers. 89 | $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); 90 | $table->add_key('player_id', XMLDB_KEY_FOREIGN, ['player_id'], 'kahoodle_players', ['id']); 91 | $table->add_key('question_id', XMLDB_KEY_FOREIGN, ['question_id'], 'kahoodle_questions', ['id']); 92 | 93 | // Conditionally launch create table for kahoodle_answers. 94 | if (!$dbman->table_exists($table)) { 95 | $dbman->create_table($table); 96 | } 97 | 98 | // Define field state to be added to kahoodle. 99 | $table = new xmldb_table('kahoodle'); 100 | $field = new xmldb_field('state', XMLDB_TYPE_CHAR, '32', null, XMLDB_NOTNULL, null, 'PREPARATION', 'timemodified'); 101 | 102 | // Conditionally launch add field state. 103 | if (!$dbman->field_exists($table, $field)) { 104 | $dbman->add_field($table, $field); 105 | } 106 | 107 | // Define field configuration to be added to kahoodle. 108 | $table = new xmldb_table('kahoodle'); 109 | $field = new xmldb_field('configuration', XMLDB_TYPE_TEXT, null, null, null, null, null, 'state'); 110 | 111 | // Conditionally launch add field configuration. 112 | if (!$dbman->field_exists($table, $field)) { 113 | $dbman->add_field($table, $field); 114 | } 115 | 116 | // Define field current_question_id to be added to kahoodle. 117 | $table = new xmldb_table('kahoodle'); 118 | $field = new xmldb_field('current_question_id', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'configuration'); 119 | 120 | // Conditionally launch add field current_question_id. 121 | if (!$dbman->field_exists($table, $field)) { 122 | $dbman->add_field($table, $field); 123 | } 124 | 125 | // Kahoodle savepoint reached. 126 | upgrade_mod_savepoint(true, 2025090204, 'kahoodle'); 127 | } 128 | 129 | if ($oldversion < 2025090206) { 130 | 131 | // Define field kahoodle_id to be added to kahoodle_players. 132 | $table = new xmldb_table('kahoodle_players'); 133 | $field = new xmldb_field('kahoodle_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'session_id'); 134 | 135 | // Conditionally launch add field kahoodle_id. 136 | if (!$dbman->field_exists($table, $field)) { 137 | $dbman->add_field($table, $field); 138 | } 139 | 140 | // Define key kahoodle_id (foreign) to be added to kahoodle_players. 141 | $table = new xmldb_table('kahoodle_players'); 142 | $key = new xmldb_key('kahoodle_id', XMLDB_KEY_FOREIGN, ['kahoodle_id'], 'kahoodle', ['id']); 143 | 144 | // Launch add key kahoodle_id. 145 | $dbman->add_key($table, $key); 146 | 147 | // Kahoodle savepoint reached. 148 | upgrade_mod_savepoint(true, 2025090206, 'kahoodle'); 149 | } 150 | 151 | if ($oldversion < 2025090207) { 152 | 153 | // Define field current_question_state to be added to kahoodle. 154 | $table = new xmldb_table('kahoodle'); 155 | $field = new xmldb_field('current_question_state', XMLDB_TYPE_CHAR, '32', null, XMLDB_NOTNULL, null, 'ASKING', 156 | 'current_question_id'); 157 | 158 | // Conditionally launch add field current_question_state. 159 | if (!$dbman->field_exists($table, $field)) { 160 | $dbman->add_field($table, $field); 161 | } 162 | 163 | // Kahoodle savepoint reached. 164 | upgrade_mod_savepoint(true, 2025090207, 'kahoodle'); 165 | } 166 | 167 | return true; 168 | } 169 | -------------------------------------------------------------------------------- /classes/game.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle; 18 | 19 | use moodle_url; 20 | use stdClass; 21 | 22 | /** 23 | * Stores information about a Kahoodle game instance and provides helper methods 24 | * 25 | * @package mod_kahoodle 26 | * @copyright Marina Glancy 27 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 28 | */ 29 | class game { 30 | /** @var array|null cached questions */ 31 | protected $questions = null; 32 | 33 | /** 34 | * Initialises the game instance 35 | * 36 | * @param \cm_info $cm 37 | * @param \stdClass $game 38 | */ 39 | public function __construct( 40 | /** @var \cm_info */ 41 | protected \cm_info $cm, 42 | /** @var \stdClass */ 43 | protected stdClass $game) { 44 | } 45 | 46 | /** 47 | * Getter for the course module instance 48 | * 49 | * @return \cm_info 50 | */ 51 | public function get_cm(): \cm_info { 52 | return $this->cm; 53 | } 54 | 55 | /** 56 | * Getter for the game record 57 | * 58 | * @return \stdClass 59 | */ 60 | public function get_game(): \stdClass { 61 | return $this->game; 62 | } 63 | 64 | /** 65 | * Course module context 66 | * 67 | * @return \context 68 | */ 69 | public function get_context(): \context { 70 | return \context_module::instance($this->cm->id); 71 | } 72 | 73 | /** 74 | * URL to the game view page 75 | * 76 | * @return moodle_url 77 | */ 78 | public function get_url(): moodle_url { 79 | return new moodle_url('/mod/kahoodle/view.php', ['id' => $this->cm->id]); 80 | } 81 | 82 | /** 83 | * Is the game in preparation state? 84 | * 85 | * @return moodle_url 86 | */ 87 | public function is_in_preparation(): bool { 88 | return $this->game->state === constants::STATE_PREPARATION; 89 | } 90 | 91 | /** 92 | * Is the game finished? 93 | * 94 | * @return moodle_url 95 | */ 96 | public function is_done(): bool { 97 | return $this->game->state === constants::STATE_DONE; 98 | } 99 | 100 | /** 101 | * Is the game in lobby/waiting for players state? 102 | * 103 | * @return bool 104 | */ 105 | public function is_in_lobby(): bool { 106 | return $this->game->state === constants::STATE_WAITING; 107 | } 108 | 109 | /** 110 | * Is the game in progress state? 111 | * 112 | * @return bool 113 | */ 114 | public function is_in_progress(): bool { 115 | return $this->game->state === constants::STATE_INPROGRESS; 116 | } 117 | 118 | /** 119 | * Gets the current question id if the game is in progress 120 | * 121 | * @return int|null question id or null if no question is active 122 | */ 123 | public function get_current_question_id(): int|null { 124 | return $this->game->state === constants::STATE_INPROGRESS && $this->game->current_question_id ? 125 | $this->game->current_question_id : null; 126 | } 127 | 128 | /** 129 | * Is current question state ASKING (accepting answers)? 130 | * 131 | * @return bool 132 | */ 133 | public function is_current_question_state_asking(): bool { 134 | return $this->get_current_question_id() > 0 && 135 | $this->game->current_question_state === constants::QSTATE_ASKING; 136 | } 137 | 138 | /** 139 | * Is current question state RESULTS (showing results)? 140 | * 141 | * @return bool 142 | */ 143 | public function is_current_question_state_results(): bool { 144 | return $this->get_current_question_id() > 0 && 145 | $this->game->current_question_state === constants::QSTATE_RESULTS; 146 | } 147 | 148 | /** 149 | * Is current question state LEADERBOARD (showing leaderboard)? 150 | * 151 | * @return bool 152 | */ 153 | public function is_current_question_state_leaderboard(): bool { 154 | return $this->get_current_question_id() > 0 && 155 | $this->game->current_question_state === constants::QSTATE_LEADERBOARD; 156 | } 157 | 158 | /** 159 | * Getter for the game id 160 | * 161 | * @return int 162 | */ 163 | public function get_id(): int { 164 | return $this->game->id; 165 | } 166 | 167 | /** 168 | * Updates the game state and optionally current question id and question state 169 | * 170 | * @param string $newstate new game state 171 | * @param int|null $questionid new current question id or null (when the game status is changed to "PROGRESS") 172 | * @param string $qstate new current question state 173 | * @return void 174 | */ 175 | public function update_game_state(string $newstate, ?int $questionid = null, string $qstate = constants::QSTATE_ASKING) { 176 | global $DB; 177 | $DB->update_record('kahoodle', [ 178 | 'state' => $newstate, 179 | 'id' => $this->game->id, 180 | 'current_question_id' => $questionid, 181 | 'current_question_state' => $qstate, 182 | ]); 183 | $this->game->state = $newstate; 184 | $this->game->current_question_id = $questionid; 185 | $this->game->current_question_state = $qstate; 186 | } 187 | 188 | /** 189 | * Getter for the questions of this game 190 | * 191 | * @return \stdClass[] question records 192 | */ 193 | protected function get_questions() { 194 | if ($this->questions === null) { 195 | global $DB; 196 | $this->questions = $DB->get_records('kahoodle_questions', ['kahoodle_id' => $this->game->id], 197 | 'sortorder ASC'); 198 | } 199 | return $this->questions; 200 | } 201 | 202 | /** 203 | * Gets the next question id after the given question id 204 | * 205 | * @param int|null $currentquestionid current question id or null to get the first question 206 | * @return int|null next question id or null if there is no next question 207 | */ 208 | protected function get_next_question_id(?int $currentquestionid): int|null { 209 | $questionids = array_keys($this->get_questions()); 210 | if ($currentquestionid === null) { 211 | return reset($questionids); 212 | } 213 | $currentindex = array_search($currentquestionid, $questionids); 214 | return $currentindex !== false && isset($questionids[$currentindex + 1]) ? $questionids[$currentindex + 1] : null; 215 | } 216 | 217 | /** 218 | * Transitions the game to the next state 219 | * 220 | * @return bool "easy transition", meaning that the notification is the same for all players (usually when question starts) 221 | */ 222 | public function transition_game(): bool { 223 | global $DB; 224 | if ($this->game->state == constants::STATE_PREPARATION) { 225 | $this->update_game_state(constants::STATE_WAITING); 226 | } else if ($this->game->state == constants::STATE_WAITING) { 227 | $this->update_game_state(constants::STATE_INPROGRESS, $this->get_next_question_id(null)); 228 | } else if ($this->game->state == constants::STATE_INPROGRESS) { 229 | if ($currentquestionid = $this->get_current_question_id()) { 230 | if ($this->is_current_question_state_asking()) { 231 | $this->update_game_state(constants::STATE_INPROGRESS, $currentquestionid, 232 | constants::QSTATE_RESULTS); 233 | } else if ($this->is_current_question_state_results()) { 234 | $this->update_game_state(constants::STATE_INPROGRESS, $currentquestionid, 235 | constants::QSTATE_LEADERBOARD); 236 | } else if ($nextquestionid = $this->get_next_question_id($currentquestionid)) { 237 | // Move on to the next question. 238 | $this->update_game_state(constants::STATE_INPROGRESS, $nextquestionid, constants::QSTATE_ASKING); 239 | $DB->update_record('kahoodle_questions', ['started_at' => time(), 'id' => $nextquestionid]); 240 | $questions = null; 241 | return true; 242 | } else { 243 | // This was the last question. 244 | $this->update_game_state(constants::STATE_DONE, $currentquestionid, constants::QSTATE_LEADERBOARD); 245 | } 246 | } 247 | } 248 | return false; 249 | } 250 | 251 | /** 252 | * Resets the game to the initial state and populates it with default questions 253 | * 254 | * @return void 255 | */ 256 | public function reset_game() { 257 | global $DB; 258 | $this->update_game_state(constants::STATE_PREPARATION, null); 259 | $DB->execute('DELETE FROM {kahoodle_answers} 260 | WHERE question_id IN (SELECT id FROM {kahoodle_questions} WHERE kahoodle_id = ?)', 261 | [$this->game->id]); 262 | $DB->delete_records('kahoodle_players', ['kahoodle_id' => $this->game->id]); 263 | $DB->delete_records('kahoodle_questions', ['kahoodle_id' => $this->game->id]); 264 | 265 | // phpcs:disable moodle.Files.LineLength.MaxExceeded, moodle.Files.LineLength.TooLong 266 | $questions = [ 267 | [ 268 | 'text' => ' 269 |
270 |
271 |
272 | 273 |
274 |
 
275 |
276 |
277 | Our team members are … 278 |
279 |
280 | ', 281 | 'answers' => ['Kathleen, Jan, Vasco, Immanuel, Pascal, Lars, Marina, Monika', 'Peter, Heike, Klaus', 'Sabine, Tom, Anna, Otto, Hannah', 'Donald Duck, Goofy, Micky Mouse'], 282 | 'correctanswer' => 0, // 0-based. 283 | 'points' => 100, 284 | ], 285 | [ 286 | 'text' => ' 287 |
288 |
289 |
290 | 291 |
292 |
 
293 |
294 |
295 | What is the price of the MoodleMoot DACH T-Shirt? 296 |
297 |
298 | ', 299 | 'answers' => ['19€', '25€', '29€', '50€'], 300 | 'correctanswer' => 0, // 0-based. 301 | 'points' => 100, 302 | ], 303 | [ 304 | 'text' => ' 305 |
306 |
307 |
308 | 309 |
310 |
 
311 |
312 |
313 | There was a session room called Trave. 314 |
315 |
316 | ', 317 | 'answers' => ['True', 'False' ], 318 | 'correctanswer' => 1, // 0-based. 319 | 'points' => 100, 320 | ], 321 | [ 322 | 'text' => ' 323 |
324 |
325 |
326 | 327 |
328 |
 
329 |
330 |
331 | The MoodleMoot DACH in 2024 took place in Vienna. 332 |
333 |
334 | ', 335 | 'answers' => ['True', 'False' ], 336 | 'correctanswer' => 0, // 0-based. 337 | 'points' => 100, 338 | ], 339 | [ 340 | 'text' => ' 341 |
342 |
343 |
344 | 345 |
346 |
 
347 |
348 |
349 | How many groups are there at the DevCamp? 350 |
351 |
352 | ', 353 | 'answers' => ['22', '10', '50', '35' ], 354 | 'correctanswer' => 0, // 0-based. 355 | 'points' => 100, 356 | ], 357 | ]; 358 | // phpcs:enable 359 | 360 | foreach ($questions as $i => $question) { 361 | $DB->insert_record('kahoodle_questions', [ 362 | 'kahoodle_id' => $this->game->id, 363 | 'question' => json_encode($question), 364 | 'duration' => 10, 365 | 'sortorder' => $i, 366 | 'timecreated' => time(), 367 | 'timemodified' => time(), 368 | 'started_at' => 0, // TODO make nullable. 369 | ]); 370 | } 371 | } 372 | 373 | /** 374 | * Gets the current question with options and optionally includes information about the correct answer 375 | * 376 | * The result is already prepared to be used in templates. 377 | * 378 | * @param bool $withcorrect whether to include the correct answer 379 | * @param int|null $studentanswer the option index of the student's answer or null if not answered yet 380 | * @return array|null question data or null if there is no current question 381 | */ 382 | public function get_current_question(bool $withcorrect = false, ?int $studentanswer = null) { 383 | $question = $this->get_current_raw_question(); 384 | 385 | if (!$question) { 386 | return null; 387 | } 388 | $questiondata = json_decode($question->question, true); 389 | 390 | $result = [ 391 | 'questionid' => $question->id, 392 | 'question' => $questiondata['text'] ?? '', 393 | 'options' => [], 394 | 'isanswered' => $studentanswer !== null, 395 | ]; 396 | if ($withcorrect) { 397 | $result['correctanswer'] = $questiondata['correctanswer']; 398 | } 399 | foreach ($questiondata['answers'] as $i => $answer) { 400 | $option = [ 401 | 'id' => $i, 402 | 'text' => $answer, 403 | ]; 404 | if ($withcorrect) { 405 | $option['iscorrect'] = ($i == $questiondata['correctanswer']); 406 | } 407 | if ($studentanswer !== null) { 408 | $option['isanswer'] = $studentanswer == $i; 409 | } 410 | $result['options'][] = $option; 411 | } 412 | 413 | return $result; 414 | } 415 | 416 | /** 417 | * Gets the current question record 418 | * 419 | * @return stdClass|null question record or null if there is no current question 420 | */ 421 | protected function get_current_raw_question(): ?stdClass { 422 | return $this->get_current_question_id() ? $this->get_questions()[$this->get_current_question_id()] : null; 423 | } 424 | 425 | /** 426 | * Calculates the score for the given option index 427 | * 428 | * @param int $optionidx the option index (0-based) 429 | * @return int score (0..points) 430 | */ 431 | public function calculate_score(int $optionidx): int { 432 | $rawquestion = $this->get_current_raw_question(); 433 | $questiondata = json_decode($rawquestion->question, true); 434 | if ($optionidx != $questiondata['correctanswer']) { 435 | return 0; 436 | } 437 | 438 | $timetaken = time() - $rawquestion->started_at; 439 | $penalty = $rawquestion->duration > 0 ? 440 | 0.5 * min($timetaken, $rawquestion->duration) / $rawquestion->duration : 0; 441 | return (int)($questiondata['points'] * (1 - $penalty)); 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /classes/api.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_kahoodle; 18 | 19 | use context_module; 20 | use moodle_url; 21 | use stdClass; 22 | 23 | /** 24 | * Helper methods to work with the kahoodle game. 25 | * 26 | * @package mod_kahoodle 27 | * @copyright Marina Glancy 28 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 29 | */ 30 | class api { 31 | /** @var int|null cached value of the current player id */ 32 | protected $playerid = null; 33 | /** @var array cached value of the players answers (indexed by player id and then by question id) */ 34 | protected $cachedanswers = []; 35 | /** @var game the game instance */ 36 | protected game $game; 37 | /** @var int last used index of the border for the leaderboard */ 38 | protected int $bordercolorindex = 0; 39 | 40 | /** 41 | * Initialise the API for the given activity. 42 | * 43 | * @param \cm_info $cm 44 | * @param \stdClass $activity 45 | */ 46 | public function __construct(\cm_info $cm, stdClass $activity) { 47 | $this->game = new game($cm, $activity); 48 | } 49 | 50 | /** 51 | * Gets the current game state for the current user. 52 | * 53 | * @return array with 'template' and 'data' keys to be used with the renderer 54 | */ 55 | public function get_game_state() { 56 | global $USER; 57 | $cantransition = self::can_transition(); 58 | if ($cantransition) { 59 | return $this->get_game_state_gamemaster(); 60 | } else if (self::can_join()) { 61 | return [ 62 | 'template' => 'mod_kahoodle/joinscreen', 63 | 'data' => [ 64 | 'name' => (isloggedin() && !isguestuser()) ? fullname($USER) : '', 65 | 'url' => $this->game->get_url()->out(false), 66 | 'sesskey' => sesskey(), 67 | ], 68 | ]; 69 | } else if ($playerid = $this->get_player_id()) { 70 | return $this->get_game_state_player($playerid); 71 | } else { 72 | // TODO this is also displayed for people who did not play after the game has finished. 73 | return [ 74 | 'template' => 'mod_kahoodle/notready', 75 | 'data' => [ 76 | ], 77 | ]; 78 | } 79 | } 80 | 81 | /** 82 | * Gets the game state if the current user is the game master. 83 | * 84 | * @return array with 'template' and 'data' keys to be used with the renderer 85 | */ 86 | protected function get_game_state_gamemaster() { 87 | if ($this->game->is_done()) { 88 | return [ 89 | 'template' => 'mod_kahoodle/donescreen', 90 | 'data' => [ 'players' => array_values($this->get_leaderboard())], 91 | ]; 92 | } else if ($this->game->is_in_preparation()) { 93 | return [ 94 | 'template' => 'mod_kahoodle/preparation', 95 | 'data' => [], 96 | ]; 97 | } else if ($this->game->is_in_lobby()) { 98 | return [ 99 | 'template' => 'mod_kahoodle/lobby', 100 | 'data' => [ 101 | 'players' => array_values($this->get_players('name')), 102 | ], 103 | ]; 104 | } else if ($this->game->is_in_progress() && $this->game->get_current_question_id()) { 105 | if ($this->game->is_current_question_state_asking()) { 106 | return [ 107 | 'template' => 'mod_kahoodle/question_gamemaster', 108 | 'data' => $this->game->get_current_question(false), 109 | ]; 110 | } else if ($this->game->is_current_question_state_results()) { 111 | return [ 112 | 'template' => 'mod_kahoodle/questionresult_gamemaster', 113 | 'data' => [ 114 | 'data' => $this->game->get_current_question(true), 115 | 'chartdata' => $this->get_chart_data(), 116 | ], 117 | ]; 118 | } else if ($this->game->is_current_question_state_leaderboard()) { 119 | return [ 120 | 'template' => 'mod_kahoodle/questionleaderboard_gamemaster', 121 | 'data' => [ 'players' => array_values($this->get_leaderboard())], 122 | ]; 123 | } 124 | } 125 | return [ 126 | 'template' => 'mod_kahoodle/notready', 127 | 'data' => [ 128 | ], 129 | ]; 130 | } 131 | 132 | /** 133 | * Summary of get_game_state_player 134 | * 135 | * @param int $playerid the player id (this function may be called for other players too) 136 | * @return array with 'template' and 'data' keys to be used with the renderer 137 | */ 138 | protected function get_game_state_player(int $playerid) { 139 | if ($this->game->is_done()) { 140 | return [ 141 | 'template' => 'mod_kahoodle/donescreen_player', 142 | 'data' => [ 'player' => $this->get_player_aggregated_points($playerid)], 143 | ]; 144 | } else if ($this->game->is_in_progress() && $this->game->get_current_question_id()) { 145 | $answers = $this->get_player_answers($playerid); 146 | $studentanswer = $answers[$this->game->get_current_question_id()] ?? null; 147 | $optionidx = $studentanswer !== null ? $studentanswer->answer['option'] : null; 148 | if ($this->game->is_current_question_state_asking()) { 149 | return [ 150 | 'template' => 'mod_kahoodle/question_player', 151 | 'data' => $this->game->get_current_question(false, $optionidx), 152 | ]; 153 | } else if ($this->game->is_current_question_state_results()) { 154 | return [ 155 | 'template' => 'mod_kahoodle/questionresult_player', 156 | 'data' => $this->game->get_current_question(true, $optionidx) + 157 | ['points' => $studentanswer?->points ?: 0], 158 | ]; 159 | } else { 160 | return [ 161 | 'template' => 'mod_kahoodle/questionleaderboard_player', 162 | 'data' => $this->game->get_current_question(true, $optionidx) + 163 | ['points' => $studentanswer?->points ?: 0, 'score' => $this->get_player_score($playerid)], 164 | ]; 165 | } 166 | } 167 | return [ 168 | 'template' => 'mod_kahoodle/waitscreen', 169 | 'data' => [ 170 | ], 171 | ]; 172 | } 173 | 174 | /** 175 | * Gets the answers for a specific player. 176 | * 177 | * @param int|null $playerid the player id 178 | * @return array the player's answers (questionid => (object)[question_id, points, answer]) 179 | */ 180 | protected function get_player_answers(?int $playerid): array { 181 | global $DB; 182 | if ($playerid === null) { 183 | return []; 184 | } 185 | if ($this->cachedanswers[$playerid] != null) { 186 | return $this->cachedanswers[$playerid]; 187 | } 188 | $this->cachedanswers[$playerid] = $DB->get_records_sql('SELECT a.question_id, a.points, a.answer 189 | FROM {kahoodle_answers} a 190 | JOIN {kahoodle_questions} q ON a.question_id = q.id 191 | WHERE q.kahoodle_id = :kahoodleid AND a.player_id = :playerid 192 | ORDER BY q.sortorder', [ 193 | 'kahoodleid' => $this->game->get_id(), 194 | 'playerid' => $playerid, 195 | ]); 196 | $totalscore = 0; 197 | foreach ($this->cachedanswers[$playerid] as $answer) { 198 | $answer->answer = json_decode($answer->answer, true); 199 | $totalscore += (int)$answer->points; 200 | $answer->score = $totalscore; 201 | } 202 | return $this->cachedanswers[$playerid]; 203 | } 204 | 205 | /** 206 | * Data for the statistics chart for the current question. 207 | * 208 | * @return bool|string 209 | */ 210 | protected function get_chart_data(): string { 211 | $statistics = $this->get_statistics(); 212 | 213 | $y = []; 214 | $x = []; 215 | 216 | foreach ($statistics as $stat) { 217 | $y[] = $stat['count']; 218 | $x[] = $stat['text']; 219 | } 220 | 221 | $data = [ 222 | 'type' => 'bar', 223 | 'series' => [ 224 | [ 225 | 'label' => 'Antworten', 226 | 'labels' => null, 227 | 'type' => null, 228 | 'values' => $y, 229 | 'colors' => [], 230 | 'axes' => [ 231 | 'x' => null, 232 | 'y' => null, 233 | ], 234 | 'smooth' => null, 235 | ], 236 | ], 237 | 'labels' => $x, 238 | 'title' => 'Results', 239 | 'axes' => [ 240 | 'x' => [], 241 | 'y' => [['min' => 0]], 242 | ], 243 | 'config_colorset' => null, 244 | 'horizontal' => false, 245 | ]; 246 | 247 | return json_encode($data); 248 | } 249 | 250 | /** 251 | * Answers statistics for the current question. 252 | * 253 | * @return array 254 | */ 255 | protected function get_statistics(): array { 256 | global $DB; 257 | $answers = $DB->get_records_sql('SELECT a.player_id, a.answer 258 | FROM {kahoodle_answers} a 259 | JOIN {kahoodle_questions} q ON a.question_id = q.id 260 | WHERE q.id = ?', [$this->game->get_current_question_id()] 261 | ); 262 | $res = []; 263 | foreach ($this->game->get_current_question()['options'] as $option) { 264 | $res[] = ['text' => $option['text'], 'count' => 0]; 265 | } 266 | foreach ($answers as &$answer) { 267 | $answer = json_decode($answer->answer, true); 268 | $res[$answer['option']]['count']++; 269 | } 270 | return array_values($res); 271 | } 272 | 273 | /** 274 | * Players with the top scores 275 | * 276 | * @param int $limit 277 | * @return array 278 | */ 279 | protected function get_leaderboard(int $limit = 10): array { 280 | global $DB; 281 | $score = $DB->get_records_sql('SELECT a.player_id AS playerid, p.name AS name, SUM(a.points) AS points 282 | FROM {kahoodle_answers} a 283 | JOIN {kahoodle_questions} q ON a.question_id = q.id 284 | JOIN {kahoodle_players} p on p.id = a.player_id 285 | WHERE q.kahoodle_id = :kahoodleid 286 | GROUP BY a.player_id, p.name 287 | ORDER BY points DESC', [ 288 | 'kahoodleid' => $this->game->get_id(), 289 | ], 0, $limit); 290 | foreach ($score as $key => $value) { 291 | $score[$key]->color = $this->get_next_border_color(); 292 | } 293 | return $score; 294 | } 295 | 296 | /** 297 | * Total score of the player across all questions. 298 | * 299 | * @param int $playerid 300 | */ 301 | protected function get_player_aggregated_points(int $playerid): ?stdClass { 302 | global $DB; 303 | $score = $DB->get_record_sql('SELECT a.player_id AS playerid, p.name AS name, SUM(a.points) AS points 304 | FROM {kahoodle_answers} a 305 | JOIN {kahoodle_questions} q ON a.question_id = q.id 306 | JOIN {kahoodle_players} p on p.id = a.player_id 307 | WHERE q.kahoodle_id = :kahoodleid AND a.player_id = :playerid 308 | GROUP BY a.player_id, p.name', 309 | [ 310 | 'kahoodleid' => $this->game->get_id(), 311 | 'playerid' => $playerid, 312 | ]); 313 | return $score ?: null; 314 | } 315 | 316 | /** 317 | * Total score for the player. 318 | * 319 | * @param int $playerid 320 | * @return int 321 | */ 322 | protected function get_player_score(int $playerid): int { 323 | $answers = $this->get_player_answers($playerid); 324 | $qids = array_keys($answers); 325 | return $answers ? $answers[$qids[count($qids) - 1]]->score : 0; 326 | } 327 | 328 | /** 329 | * Checks if the current player can transition the game to the next state (is allowed to do that). 330 | * 331 | * @return bool 332 | */ 333 | public function can_transition() { 334 | return has_capability('mod/kahoodle:transition', $this->get_context()); 335 | } 336 | 337 | /** 338 | * Checks if the current player can join the game. 339 | * 340 | * @return bool 341 | */ 342 | public function can_join() { 343 | return ($this->game->is_in_progress() || $this->game->is_in_lobby()) && 344 | !self::get_player_id() && 345 | ((isloggedin() && !isguestuser()) || $this->can_auth_guests()) && 346 | has_capability('mod/kahoodle:answer', $this->get_context()); 347 | } 348 | 349 | /** 350 | * Checks if the current player can give an answer to the questions (has capability, joined and the game is in progress). 351 | * 352 | * @return bool 353 | */ 354 | public function can_answer() { 355 | return $this->game->is_in_progress() && 356 | self::get_player_id() && has_capability('mod/kahoodle:answer', $this->get_context()); 357 | } 358 | 359 | /** 360 | * Getter for the course module instance 361 | * 362 | * @return \cm_info 363 | */ 364 | public function get_cm(): \cm_info { 365 | return $this->game->get_cm(); 366 | } 367 | 368 | /** 369 | * Getter for the game context 370 | * 371 | * @return \context 372 | */ 373 | public function get_context(): \context { 374 | return $this->game->get_context(); 375 | } 376 | 377 | /** 378 | * Getter for the player id 379 | * 380 | * @return int|null 381 | */ 382 | public function get_player_id(): int { 383 | global $DB, $USER; 384 | if ($this->playerid !== null) { 385 | return $this->playerid; 386 | } 387 | 388 | if (isloggedin() && !isguestuser()) { 389 | $playerrecord = $DB->get_record('kahoodle_players', 390 | ['user_id' => $USER->id, 'kahoodle_id' => $this->game->get_id()]); 391 | } else { 392 | $playerrecord = $DB->get_record('kahoodle_players', 393 | ['session_id' => session_id(), 'kahoodle_id' => $this->game->get_id()]); 394 | } 395 | $this->playerid = $playerrecord ? $playerrecord->id : 0; 396 | return $this->playerid; 397 | } 398 | 399 | /** 400 | * Does the current game and site settings support authentication of guest users via auth/kahoodle? 401 | * 402 | * @return bool 403 | */ 404 | protected function can_auth_guests(): bool { 405 | return \core_component::get_component_directory('auth_kahoodle') 406 | && is_enabled_auth('kahoodle'); 407 | } 408 | 409 | /** 410 | * Performs an action of joining the game. 411 | * 412 | * @param string $name 413 | * @return void 414 | */ 415 | public function do_join(string $name) { 416 | global $DB, $USER; 417 | if (!$this->can_join()) { 418 | return; 419 | } 420 | if ((!isloggedin() || isguestuser()) && $this->can_auth_guests()) { 421 | /** @var \auth_plugin_kahoodle $auth */ 422 | $auth = get_auth_plugin('kahoodle'); 423 | 424 | $auth->create_fake_user($name); 425 | } 426 | $this->playerid = $DB->insert_record('kahoodle_players', [ 427 | 'name' => $name, 428 | 'user_id' => (isloggedin() && !isguestuser()) ? $USER->id : null, 429 | 'session_id' => session_id(), 430 | 'kahoodle_id' => $this->game->get_id(), 431 | 'timejoined' => time(), // TODO add a field to the database. 432 | ]); 433 | $this->notify_gamemaster(); 434 | } 435 | 436 | /** 437 | * List of all players who joined the game (for the lobby screen). 438 | * 439 | * @param mixed $fields 440 | * @return array 441 | */ 442 | public function get_players($fields = 'id, name') { 443 | global $DB; 444 | return $DB->get_records( 445 | 'kahoodle_players', 446 | ['kahoodle_id' => $this->game->get_id()], 447 | '', // TODO 'timejoined DESC'. 448 | $fields 449 | ); 450 | } 451 | 452 | /** 453 | * Process action other than playing the game (e.g. join, reset). 454 | * 455 | * @return void 456 | */ 457 | public function process_simple_action() { 458 | $action = optional_param('action', '', PARAM_TEXT); 459 | if ($action == 'reset' && $this->can_transition() && confirm_sesskey()) { 460 | $this->game->reset_game(); 461 | redirect($this->game->get_url()); 462 | } else if ($action == 'join' && $this->can_join() && confirm_sesskey()) { 463 | $name = trim(required_param('name', PARAM_TEXT)); 464 | // TODO validate name better. 465 | if (strlen($name) > 0) { 466 | $this->do_join(substr($name, 0, 20)); 467 | redirect($this->game->get_url()); 468 | } 469 | } 470 | } 471 | 472 | /** 473 | * Process action through the real-time API, i.e. transition game or answer a question. 474 | * 475 | * @param array $payload the payload of the event 476 | * @return void 477 | */ 478 | public function handle_realtime_event($payload) { 479 | $action = $payload['action'] ?: null; 480 | if ($action == 'transition' && $this->can_transition()) { 481 | $this->transition_game(); 482 | } 483 | 484 | if ($action == 'answer') { 485 | $questionid = $payload['questionid'] ?? null; 486 | $optionidx = $payload['answer'] ?? null; 487 | if ($questionid && $optionidx !== null) { 488 | $this->do_answer($questionid, $optionidx); 489 | } 490 | } 491 | } 492 | 493 | /** 494 | * Process an aciton of answering a question. 495 | * 496 | * @param int $questionid 497 | * @param int $optionidx 498 | * @return void 499 | */ 500 | protected function do_answer(int $questionid, int $optionidx) { 501 | global $DB; 502 | if (!$this->can_answer() || !$this->game->is_current_question_state_asking() || 503 | $this->game->get_current_question_id() != $questionid) { 504 | return; 505 | } 506 | $playerid = $this->get_player_id(); 507 | if (!$playerid) { 508 | return; 509 | } 510 | $answers = $this->get_player_answers($playerid); 511 | // Check if already answered. 512 | if (array_key_exists($questionid, $answers)) { 513 | return; 514 | } 515 | $points = $this->game->calculate_score($optionidx); 516 | $DB->insert_record('kahoodle_answers', [ 517 | 'question_id' => $questionid, 518 | 'player_id' => $playerid, 519 | 'points' => $points, 520 | 'answer' => json_encode(['option' => $optionidx]), 521 | ]); 522 | // Invalidate cached answers. 523 | unset($this->cachedanswers[$playerid]); 524 | 525 | $this->notify_player($playerid); 526 | } 527 | 528 | /** 529 | * Send notificaiton to all game masters through the real-time API notificaiton channel 530 | * 531 | * @return void 532 | */ 533 | protected function notify_gamemaster() { 534 | $channel = new \tool_realtime\channel($this->get_context(), 535 | 'mod_kahoodle', 'gamemaster', 0); 536 | $channel->notify($this->get_game_state_gamemaster()); 537 | } 538 | 539 | /** 540 | * Send notificaiton to all players through the real-time API notificaiton channel 541 | * 542 | * @param bool $easytransition if true, the content does not depend on the player, so only one notification is sent to all 543 | * @return void 544 | */ 545 | protected function notify_all_players(bool $easytransition = false) { 546 | // If content does not depend on the player, push only one event to itemid = 0. 547 | if ($easytransition) { 548 | $this->notify_player(0); 549 | return; 550 | } 551 | foreach ($this->get_players() as $player) { 552 | $this->notify_player($player->id); 553 | } 554 | } 555 | 556 | /** 557 | * Send notificaiton to a specific player through the real-time API notificaiton channel 558 | * 559 | * @param int $playerid the player id (0 means all players) 560 | * @return void 561 | */ 562 | protected function notify_player(int $playerid) { 563 | $channel = new \tool_realtime\channel($this->get_context(), 564 | 'mod_kahoodle', 'game', $playerid); 565 | $channel->notify($this->get_game_state_player($playerid)); 566 | } 567 | 568 | /** 569 | * Process an action of transitioning the game to the next state. 570 | * 571 | * @return void 572 | */ 573 | protected function transition_game() { 574 | $easytransition = $this->game->transition_game(); 575 | 576 | $this->notify_gamemaster(); 577 | $this->notify_all_players($easytransition); 578 | } 579 | 580 | /** 581 | * Helper method to iterate through a list of border colors. 582 | * 583 | * @return string 584 | */ 585 | protected function get_next_border_color(): string { 586 | $colors = [ 587 | 'primary', 588 | 'secondary', 589 | 'success', 590 | 'danger', 591 | 'warning', 592 | 'info', 593 | ]; 594 | $color = $colors[$this->bordercolorindex % count($colors)]; 595 | $this->bordercolorindex++; 596 | return $color; 597 | } 598 | } 599 | --------------------------------------------------------------------------------