diff --git a/games/constants.py b/games/constants.py index f4a908b..1551fe9 100644 --- a/games/constants.py +++ b/games/constants.py @@ -20,7 +20,7 @@ class DEFAULT: MATCHING_TITLE = "Matching" FLASHCARDS_TITLE = "Flashcards" DISPLAY_NAME = "Games" - GAME_TYPE = GAME_TYPE.MATCHING + GAME_TYPE = GAME_TYPE.FLASHCARDS IS_SHUFFLED = True HAS_TIMER = True diff --git a/games/games.py b/games/games.py index 5ea1e13..a4ef5d9 100644 --- a/games/games.py +++ b/games/games.py @@ -72,6 +72,12 @@ def resource_string(self, path): data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") + def get_mode(self): + """Detect if in preview/author mode.""" + if hasattr(self.runtime, 'is_author_mode') and self.runtime.is_author_mode: + return "preview" + return "normal" + def student_view(self, context=None): """ The primary view of the GamesXBlock, shown to students @@ -105,7 +111,6 @@ def delete_image_handler(self, data, suffix=""): Delete an image by storage key. Expected: { "key": "gamesxblock//.ext" } """ - # TODO: Delete API is not integrated yet, will handle this one after API is integrated if any change needed. return CommonHandlers.delete_image_handler(self, data, suffix) @XBlock.json_handler diff --git a/games/handlers/flashcards.py b/games/handlers/flashcards.py index 0691e68..6a58cbf 100644 --- a/games/handlers/flashcards.py +++ b/games/handlers/flashcards.py @@ -55,9 +55,14 @@ def student_view(xblock, context=None): random.choices(string.ascii_lowercase + string.digits, k=16) ) - # Obfuscated decoder named function FlashcardsInit + # Generate unique function name per XBlock to avoid conflicts + init_function_name = "FlashcardsInit_" + "".join( + random.choices(string.ascii_lowercase + string.digits, k=8) + ) + + # Obfuscated decoder with unique function name obf_decoder = ( - f"function FlashcardsInit({var_names['runtime']},{var_names['elem']}){{" # function header + f"function {init_function_name}({var_names['runtime']},{var_names['elem']}){{" # function header f"var {var_names['tag']}=$('#{data_element_id}',{var_names['elem']});" # locate script tag f"if(!{var_names['tag']}.length)return;try{{" # guard f"var {var_names['payload']}=JSON.parse(atob({var_names['tag']}.text()));" # decode @@ -92,5 +97,5 @@ def student_view(xblock, context=None): __name__, "../static/js/src/flashcards.js" ).decode("utf8") ) - frag.initialize_js("FlashcardsInit") + frag.initialize_js(init_function_name) return frag diff --git a/games/handlers/matching.py b/games/handlers/matching.py index e55199e..b5791ff 100644 --- a/games/handlers/matching.py +++ b/games/handlers/matching.py @@ -13,14 +13,16 @@ from xblock.core import Response from django.template import Context, Template from web_fragments.fragment import Fragment -from ..constants import CONFIG, CONTAINER_TYPE, DEFAULT +from ..constants import CONFIG, DEFAULT from .common import CommonHandlers class MatchingHandlers: + """Handlers specific to the matching game type.""" @staticmethod def student_view(xblock, context=None): + """Render the student view for the matching game.""" # Prepare cards cards = list(xblock.cards) if xblock.cards else [] list_length = len(cards) @@ -36,7 +38,10 @@ def student_view(xblock, context=None): total_items = len(cards) * 2 key_length = CONFIG.RANDOM_STRING_LENGTH bits_needed = key_length * 4 # Each hex char = 4 bits - all_keys = [format(random.getrandbits(bits_needed), f'0{key_length}x') for _ in range(total_items)] + all_keys = [ + format(random.getrandbits(bits_needed), f"0{key_length}x") + for _ in range(total_items) + ] # Pre-allocate array with None slots (will be filled with {key, index} objects) matched_entries = [None] * total_items @@ -64,18 +69,21 @@ def student_view(xblock, context=None): global_counter += 1 # Add bidirectional mapping entries as UUID-like strings for obfuscation - matched_entries[term_index] = CommonHandlers.format_as_uuid_like(term_key, def_index) - matched_entries[def_index] = CommonHandlers.format_as_uuid_like(def_key, term_index) + matched_entries[term_index] = CommonHandlers.format_as_uuid_like( + term_key, def_index + ) + matched_entries[def_index] = CommonHandlers.format_as_uuid_like( + def_key, term_index + ) # Shuffle left and right items per page if enabled if xblock.is_shuffled: random.shuffle(left_items) random.shuffle(right_items) - all_pages_data.append({ - "left_items": left_items, - "right_items": right_items - }) + all_pages_data.append( + {"left_items": left_items, "right_items": right_items} + ) encryption_key = CommonHandlers.generate_encryption_key(xblock) encrypted_hash = CommonHandlers.encrypt_data(matched_entries, encryption_key) @@ -104,14 +112,23 @@ def student_view(xblock, context=None): random.choices(string.ascii_lowercase + string.digits, k=16) ) + # Generate unique function name per XBlock to avoid conflicts + init_function_name = "MatchingInit_" + "".join( + random.choices(string.ascii_lowercase + string.digits, k=8) + ) + + runtime = var_names["runtime"] + elem = var_names["elem"] + payload = var_names["payload"] + # Build obfuscated decoder function; initializes JS via payload obf_decoder = ( - f"function MatchingInit({var_names['runtime']},{var_names['elem']}){{" + f"function {init_function_name}({var_names['runtime']},{var_names['elem']}){{" f"var {var_names['tag']}=$('#{data_element_id}',{var_names['elem']});" f"if(!{var_names['tag']}.length)return;try{{" f"var {var_names['payload']}=JSON.parse(atob({var_names['tag']}.text()));" f"{var_names['tag']}.remove();if({var_names['payload']}&&{var_names['payload']}.pages)" - f"GamesXBlockMatchingInit({var_names['runtime']},{var_names['elem']},{var_names['payload']}.pages,{var_names['payload']}.key);" + f"GamesXBlockMatchingInit({runtime},{elem},{payload}.pages,{payload}.key);" f"$('#obf_decoder_script',{var_names['elem']}).remove();" f"}}catch({var_names['err']}){{console.warn('Decode failed');}}}}" ) @@ -119,6 +136,7 @@ def student_view(xblock, context=None): template_context["encoded_mapping"] = encoded_mapping template_context["obf_decoder"] = obf_decoder template_context["data_element_id"] = data_element_id + template_context["init_function_name"] = init_function_name template_str = pkg_resources.resource_string( __name__, "../static/html/matching.html" @@ -147,44 +165,34 @@ def student_view(xblock, context=None): __name__, "../static/js/src/confetti.js" ).decode("utf8") ) - frag.initialize_js("MatchingInit") + frag.initialize_js(init_function_name) return frag @staticmethod def get_matching_key_mapping(xblock, data, suffix=""): + """Decrypt and return the key mapping for matching game validation.""" try: matching_key = data.get("matching_key") if not matching_key: - return { - "success": False, - "error": "Missing matching_key parameter" - } + return {"success": False, "error": "Missing matching_key parameter"} encryption_key = CommonHandlers.generate_encryption_key(xblock) key_mapping = CommonHandlers.decrypt_data(matching_key, encryption_key) - return { - "success": True, - "data": key_mapping - } - except Exception as e: - return { - "success": False, - "error": f"Failed to decrypt mapping: {str(e)}" - } + return {"success": True, "data": key_mapping, "mode": xblock.get_mode()} + except Exception as e: # pylint: disable=broad-exception-caught + return {"success": False, "error": f"Failed to decrypt mapping: {str(e)}"} @staticmethod def refresh_game(xblock, request, suffix=""): + """Refresh the game view with new shuffled data.""" frag = MatchingHandlers.student_view(xblock, context=None) - return Response( - frag.content, - content_type='text/html', - charset='UTF-8' - ) + return Response(frag.content, content_type="text/html", charset="UTF-8") @staticmethod def complete_matching_game(xblock, data, suffix=""): + """Complete the matching game and compare the user's time to the best_time field.""" new_time = data["new_time"] prev_best_time = xblock.best_time diff --git a/games/static/js/src/matching.js b/games/static/js/src/matching.js index 7a3dfbc..122aa2d 100644 --- a/games/static/js/src/matching.js +++ b/games/static/js/src/matching.js @@ -18,6 +18,7 @@ function GamesXBlockMatchingInit(runtime, element, pages, matching_key) { let timerInterval = null; let timeSeconds = 0; + let isPreviewMode = false; function formatTime(seconds) { const hours = Math.floor(seconds / 3600); @@ -132,8 +133,34 @@ function GamesXBlockMatchingInit(runtime, element, pages, matching_key) { } }, error: function(xhr, status, error) { + if (xhr.status === 404) { + isPreviewMode = true; + indexLink = {}; + let idx = 0; + allPages.forEach(page => { + page.left_items.forEach(() => { + const termIdx = idx++; + const defIdx = idx++; + indexLink[termIdx] = defIdx; + indexLink[defIdx] = termIdx; + }); + }); + if (allPages && allPages[currentPageIndex]) { + currentPagePairs = allPages[currentPageIndex].left_items.length; + } + + $('.matching-start-screen', element).remove(); + $('.matching-grid', element).addClass('active'); + $('.matching-footer', element).addClass('active'); + + if (has_timer) { + startTimer(); + } + spinner.removeClass('active'); + return; + } alert('Failed to start game. Please try again.'); - spinner.hide(); + spinner.removeClass('active'); startButton.prop('disabled', false); } }); @@ -284,6 +311,22 @@ function GamesXBlockMatchingInit(runtime, element, pages, matching_key) { return; } + // In preview mode, skip server call and show completion directly + if (isPreviewMode) { + $('.matching-end-screen', element).addClass('active'); + $('.matching-grid', element).remove(); + $('.matching-footer', element).remove(); + $('.matching-new-best', element).addClass('active'); + $('.matching-prev-best', element).remove(); + $('#matching-current-result', element).text(formatTime(timeSeconds)); + $('.matching-new-prev-best', element).remove(); + + if (typeof GamesConfetti !== 'undefined') { + GamesConfetti.trigger($('.confetti-container', element), 20); + } + return; + } + $.ajax({ type: 'POST', url: runtime.handlerUrl(element, 'complete_matching_game'), diff --git a/setup.py b/setup.py index bdd85b2..14ec0e6 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def package_data(pkg, roots): setup( name="edx-games", - version="1.0.6", + version="1.0.7", description="Interactive games XBlock for Open edX - Create flashcards and matching games with image support", author="edX", author_email="edx@edx.org",