Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion games/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion games/games.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,7 +111,6 @@ def delete_image_handler(self, data, suffix=""):
Delete an image by storage key.
Expected: { "key": "gamesxblock/<block_id>/<hash>.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
Expand Down
11 changes: 8 additions & 3 deletions games/handlers/flashcards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
66 changes: 37 additions & 29 deletions games/handlers/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -104,21 +112,31 @@ 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');}}}}"
)

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"
Expand Down Expand Up @@ -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

Expand Down
45 changes: 44 additions & 1 deletion games/static/js/src/matching.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
});
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down