Coverage for integrations / social / game_catalog.py: 57.0%
79 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Game Catalog
3Centralized registry of 100+ games powered by 6 engine classes.
4Config-driven variants: 24 OpenTDB categories x 3 modes = 72 trivia alone.
5"""
6import logging
7from typing import Dict, List, Optional
9logger = logging.getLogger('hevolve_social')
12# ─── OpenTDB Category IDs ──────────────────────────────────────────
13# https://opentdb.com/api_config.php
14OPENTDB_CATEGORIES = {
15 9: 'General Knowledge', 10: 'Books', 11: 'Film', 12: 'Music',
16 13: 'Musicals & Theatre', 14: 'Television', 15: 'Video Games',
17 16: 'Board Games', 17: 'Science & Nature', 18: 'Computers',
18 19: 'Mathematics', 20: 'Mythology', 21: 'Sports', 22: 'Geography',
19 23: 'History', 24: 'Politics', 25: 'Art', 26: 'Celebrities',
20 27: 'Animals', 28: 'Vehicles', 29: 'Comics', 30: 'Gadgets',
21 31: 'Anime & Manga', 32: 'Cartoons',
22}
24TRIVIA_MODES = {
25 'classic': {'label': 'Classic', 'time_per_question': 15, 'default_rounds': 10},
26 'speed': {'label': 'Speed Round', 'time_per_question': 10, 'default_rounds': 20},
27 'survival': {'label': 'Survival', 'time_per_question': 12, 'default_rounds': 30},
28}
30# ─── Catalog Data ──────────────────────────────────────────────────
32def _build_trivia_entries():
33 """Generate 72 trivia variants from 24 categories x 3 modes."""
34 entries = []
35 sort_base = 100
37 for cat_id, cat_name in OPENTDB_CATEGORIES.items():
38 for mode_key, mode_info in TRIVIA_MODES.items():
39 slug = f"trivia-{cat_name.lower().replace(' & ', '-').replace(' ', '-')}-{mode_key}"
40 title = f"{cat_name} — {mode_info['label']}"
42 entries.append({
43 'id': slug,
44 'engine': 'opentdb_trivia',
45 'title': title,
46 'category': 'trivia',
47 'audience': 'adult',
48 'thumbnail': f"trivia_{cat_id}.webp",
49 'multiplayer': True,
50 'min_players': 1,
51 'max_players': 8,
52 'solo_allowed': True,
53 'difficulty_levels': ['easy', 'medium', 'hard'],
54 'default_rounds': mode_info['default_rounds'],
55 'engine_config': {
56 'opentdb_category_id': cat_id,
57 'mode': mode_key,
58 'time_per_question': mode_info['time_per_question'],
59 },
60 'tags': ['trivia', cat_name.lower(), mode_key],
61 'featured': cat_id in (9, 11, 17, 18, 22, 23) and mode_key == 'classic',
62 'sort_order': sort_base,
63 })
64 sort_base += 1
66 return entries
69BOARD_GAMES = [
70 {
71 'id': 'board-tictactoe',
72 'engine': 'boardgame',
73 'title': 'Tic-Tac-Toe',
74 'category': 'board',
75 'audience': 'all',
76 'thumbnail': 'board_tictactoe.webp',
77 'multiplayer': True,
78 'min_players': 2, 'max_players': 2,
79 'solo_allowed': True,
80 'difficulty_levels': ['easy', 'medium', 'hard'],
81 'default_rounds': 1,
82 'engine_config': {'board_type': 'tictactoe', 'board_size': 3},
83 'tags': ['board', 'classic', 'quick'],
84 'featured': True,
85 'sort_order': 200,
86 },
87 {
88 'id': 'board-connect4',
89 'engine': 'boardgame',
90 'title': 'Connect Four',
91 'category': 'board',
92 'audience': 'all',
93 'thumbnail': 'board_connect4.webp',
94 'multiplayer': True,
95 'min_players': 2, 'max_players': 2,
96 'solo_allowed': True,
97 'difficulty_levels': ['easy', 'medium', 'hard'],
98 'default_rounds': 1,
99 'engine_config': {'board_type': 'connect4', 'board_size': 7},
100 'tags': ['board', 'classic', 'strategy'],
101 'featured': True,
102 'sort_order': 201,
103 },
104 {
105 'id': 'board-checkers',
106 'engine': 'boardgame',
107 'title': 'Checkers',
108 'category': 'board',
109 'audience': 'all',
110 'thumbnail': 'board_checkers.webp',
111 'multiplayer': True,
112 'min_players': 2, 'max_players': 2,
113 'solo_allowed': True,
114 'difficulty_levels': ['easy', 'medium', 'hard'],
115 'default_rounds': 1,
116 'engine_config': {'board_type': 'checkers', 'board_size': 8},
117 'tags': ['board', 'classic', 'strategy'],
118 'featured': False,
119 'sort_order': 202,
120 },
121 {
122 'id': 'board-reversi',
123 'engine': 'boardgame',
124 'title': 'Reversi (Othello)',
125 'category': 'board',
126 'audience': 'adult',
127 'thumbnail': 'board_reversi.webp',
128 'multiplayer': True,
129 'min_players': 2, 'max_players': 2,
130 'solo_allowed': True,
131 'difficulty_levels': ['easy', 'medium', 'hard'],
132 'default_rounds': 1,
133 'engine_config': {'board_type': 'reversi', 'board_size': 8},
134 'tags': ['board', 'strategy'],
135 'featured': False,
136 'sort_order': 203,
137 },
138 {
139 'id': 'board-mancala',
140 'engine': 'boardgame',
141 'title': 'Mancala',
142 'category': 'board',
143 'audience': 'all',
144 'thumbnail': 'board_mancala.webp',
145 'multiplayer': True,
146 'min_players': 2, 'max_players': 2,
147 'solo_allowed': True,
148 'difficulty_levels': ['easy', 'medium', 'hard'],
149 'default_rounds': 1,
150 'engine_config': {'board_type': 'mancala', 'board_size': 6},
151 'tags': ['board', 'classic', 'strategy'],
152 'featured': False,
153 'sort_order': 204,
154 },
155 {
156 'id': 'board-dots-and-boxes',
157 'engine': 'boardgame',
158 'title': 'Dots & Boxes',
159 'category': 'board',
160 'audience': 'all',
161 'thumbnail': 'board_dots.webp',
162 'multiplayer': True,
163 'min_players': 2, 'max_players': 4,
164 'solo_allowed': False,
165 'difficulty_levels': None,
166 'default_rounds': 1,
167 'engine_config': {'board_type': 'dots_and_boxes', 'board_size': 5},
168 'tags': ['board', 'party', 'quick'],
169 'featured': False,
170 'sort_order': 205,
171 },
172 {
173 'id': 'board-battleship',
174 'engine': 'boardgame',
175 'title': 'Battleship',
176 'category': 'board',
177 'audience': 'adult',
178 'thumbnail': 'board_battleship.webp',
179 'multiplayer': True,
180 'min_players': 2, 'max_players': 2,
181 'solo_allowed': True,
182 'difficulty_levels': ['easy', 'medium', 'hard'],
183 'default_rounds': 1,
184 'engine_config': {'board_type': 'battleship', 'board_size': 10},
185 'tags': ['board', 'strategy', 'classic'],
186 'featured': True,
187 'sort_order': 206,
188 },
189 {
190 'id': 'board-nim',
191 'engine': 'boardgame',
192 'title': 'Nim',
193 'category': 'board',
194 'audience': 'adult',
195 'thumbnail': 'board_nim.webp',
196 'multiplayer': True,
197 'min_players': 2, 'max_players': 2,
198 'solo_allowed': True,
199 'difficulty_levels': ['easy', 'medium', 'hard'],
200 'default_rounds': 1,
201 'engine_config': {'board_type': 'nim', 'board_size': 4},
202 'tags': ['board', 'math', 'strategy'],
203 'featured': False,
204 'sort_order': 207,
205 },
206]
209ARCADE_GAMES = [
210 {
211 'id': 'arcade-snake',
212 'engine': 'phaser',
213 'title': 'Snake',
214 'category': 'arcade',
215 'audience': 'all',
216 'thumbnail': 'arcade_snake.webp',
217 'multiplayer': True,
218 'min_players': 1, 'max_players': 4,
219 'solo_allowed': True,
220 'difficulty_levels': ['easy', 'medium', 'hard'],
221 'default_rounds': 1,
222 'engine_config': {'scene_id': 'snake', 'starting_lives': 1,
223 'target_score': 0},
224 'tags': ['arcade', 'classic', 'quick'],
225 'featured': True,
226 'sort_order': 300,
227 },
228 {
229 'id': 'arcade-breakout',
230 'engine': 'phaser',
231 'title': 'Breakout',
232 'category': 'arcade',
233 'audience': 'all',
234 'thumbnail': 'arcade_breakout.webp',
235 'multiplayer': True,
236 'min_players': 1, 'max_players': 2,
237 'solo_allowed': True,
238 'difficulty_levels': ['easy', 'medium', 'hard'],
239 'default_rounds': 1,
240 'engine_config': {'scene_id': 'breakout', 'starting_lives': 3},
241 'tags': ['arcade', 'classic'],
242 'featured': True,
243 'sort_order': 301,
244 },
245 {
246 'id': 'arcade-bubble-shooter',
247 'engine': 'phaser',
248 'title': 'Bubble Shooter',
249 'category': 'arcade',
250 'audience': 'all',
251 'thumbnail': 'arcade_bubble.webp',
252 'multiplayer': True,
253 'min_players': 1, 'max_players': 2,
254 'solo_allowed': True,
255 'difficulty_levels': ['easy', 'medium', 'hard'],
256 'default_rounds': 1,
257 'engine_config': {'scene_id': 'bubble_shooter', 'starting_lives': 5},
258 'tags': ['arcade', 'puzzle', 'casual'],
259 'featured': False,
260 'sort_order': 302,
261 },
262 {
263 'id': 'arcade-pong',
264 'engine': 'phaser',
265 'title': 'Pong',
266 'category': 'arcade',
267 'audience': 'all',
268 'thumbnail': 'arcade_pong.webp',
269 'multiplayer': True,
270 'min_players': 2, 'max_players': 2,
271 'solo_allowed': True,
272 'difficulty_levels': ['easy', 'medium', 'hard'],
273 'default_rounds': 1,
274 'engine_config': {'scene_id': 'pong', 'target_score': 11},
275 'tags': ['arcade', 'classic', 'competitive'],
276 'featured': True,
277 'sort_order': 303,
278 },
279 {
280 'id': 'arcade-runner',
281 'engine': 'phaser',
282 'title': 'Endless Runner',
283 'category': 'arcade',
284 'audience': 'all',
285 'thumbnail': 'arcade_runner.webp',
286 'multiplayer': True,
287 'min_players': 1, 'max_players': 4,
288 'solo_allowed': True,
289 'difficulty_levels': None,
290 'default_rounds': 1,
291 'engine_config': {'scene_id': 'runner', 'starting_lives': 1},
292 'tags': ['arcade', 'endless', 'casual'],
293 'featured': False,
294 'sort_order': 304,
295 },
296 {
297 'id': 'arcade-flappy',
298 'engine': 'phaser',
299 'title': 'Flappy Bird',
300 'category': 'arcade',
301 'audience': 'all',
302 'thumbnail': 'arcade_flappy.webp',
303 'multiplayer': True,
304 'min_players': 1, 'max_players': 4,
305 'solo_allowed': True,
306 'difficulty_levels': None,
307 'default_rounds': 1,
308 'engine_config': {'scene_id': 'flappy', 'starting_lives': 1},
309 'tags': ['arcade', 'casual'],
310 'featured': False,
311 'sort_order': 305,
312 },
313 {
314 'id': 'arcade-match3',
315 'engine': 'phaser',
316 'title': 'Gem Match',
317 'category': 'arcade',
318 'audience': 'all',
319 'thumbnail': 'arcade_match3.webp',
320 'multiplayer': True,
321 'min_players': 1, 'max_players': 2,
322 'solo_allowed': True,
323 'difficulty_levels': ['easy', 'medium', 'hard'],
324 'default_rounds': 1,
325 'engine_config': {'scene_id': 'match3', 'target_score': 1000,
326 'max_duration_seconds': 120},
327 'tags': ['arcade', 'puzzle', 'casual'],
328 'featured': True,
329 'sort_order': 306,
330 },
331 {
332 'id': 'arcade-block-stack',
333 'engine': 'phaser',
334 'title': 'Block Stacker',
335 'category': 'arcade',
336 'audience': 'all',
337 'thumbnail': 'arcade_blocks.webp',
338 'multiplayer': True,
339 'min_players': 1, 'max_players': 2,
340 'solo_allowed': True,
341 'difficulty_levels': ['easy', 'medium', 'hard'],
342 'default_rounds': 1,
343 'engine_config': {'scene_id': 'block_stack', 'starting_lives': 1},
344 'tags': ['arcade', 'puzzle', 'classic'],
345 'featured': False,
346 'sort_order': 307,
347 },
348 {
349 'id': 'arcade-space-invaders',
350 'engine': 'phaser',
351 'title': 'Space Invaders',
352 'category': 'arcade',
353 'audience': 'all',
354 'thumbnail': 'arcade_space.webp',
355 'multiplayer': True,
356 'min_players': 1, 'max_players': 2,
357 'solo_allowed': True,
358 'difficulty_levels': ['easy', 'medium', 'hard'],
359 'default_rounds': 1,
360 'engine_config': {'scene_id': 'space_invaders', 'starting_lives': 3},
361 'tags': ['arcade', 'classic', 'shooter'],
362 'featured': False,
363 'sort_order': 308,
364 },
365 {
366 'id': 'arcade-2048',
367 'engine': 'phaser',
368 'title': '2048',
369 'category': 'arcade',
370 'audience': 'all',
371 'thumbnail': 'arcade_2048.webp',
372 'multiplayer': True,
373 'min_players': 1, 'max_players': 2,
374 'solo_allowed': True,
375 'difficulty_levels': None,
376 'default_rounds': 1,
377 'engine_config': {'scene_id': '2048', 'target_score': 2048},
378 'tags': ['arcade', 'puzzle', 'math'],
379 'featured': False,
380 'sort_order': 309,
381 },
382]
385WORD_GAMES = [
386 {
387 'id': 'word-scramble-4',
388 'engine': 'word_scramble',
389 'title': 'Word Scramble (Easy)',
390 'category': 'word',
391 'audience': 'all',
392 'thumbnail': 'word_scramble.webp',
393 'multiplayer': True,
394 'min_players': 1, 'max_players': 6,
395 'solo_allowed': True,
396 'difficulty_levels': None,
397 'default_rounds': 10,
398 'engine_config': {'word_length': 4, 'round_time': 30},
399 'tags': ['word', 'easy', 'quick'],
400 'featured': False,
401 'sort_order': 400,
402 },
403 {
404 'id': 'word-scramble-5',
405 'engine': 'word_scramble',
406 'title': 'Word Scramble',
407 'category': 'word',
408 'audience': 'all',
409 'thumbnail': 'word_scramble.webp',
410 'multiplayer': True,
411 'min_players': 1, 'max_players': 6,
412 'solo_allowed': True,
413 'difficulty_levels': None,
414 'default_rounds': 10,
415 'engine_config': {'word_length': 5, 'round_time': 30},
416 'tags': ['word', 'classic'],
417 'featured': True,
418 'sort_order': 401,
419 },
420 {
421 'id': 'word-scramble-6',
422 'engine': 'word_scramble',
423 'title': 'Word Scramble (Medium)',
424 'category': 'word',
425 'audience': 'adult',
426 'thumbnail': 'word_scramble.webp',
427 'multiplayer': True,
428 'min_players': 1, 'max_players': 6,
429 'solo_allowed': True,
430 'difficulty_levels': None,
431 'default_rounds': 10,
432 'engine_config': {'word_length': 6, 'round_time': 45},
433 'tags': ['word', 'medium'],
434 'featured': False,
435 'sort_order': 402,
436 },
437 {
438 'id': 'word-scramble-7',
439 'engine': 'word_scramble',
440 'title': 'Word Scramble (Hard)',
441 'category': 'word',
442 'audience': 'adult',
443 'thumbnail': 'word_scramble.webp',
444 'multiplayer': True,
445 'min_players': 1, 'max_players': 6,
446 'solo_allowed': True,
447 'difficulty_levels': None,
448 'default_rounds': 8,
449 'engine_config': {'word_length': 7, 'round_time': 60},
450 'tags': ['word', 'hard', 'challenge'],
451 'featured': False,
452 'sort_order': 403,
453 },
454 {
455 'id': 'word-scramble-8',
456 'engine': 'word_scramble',
457 'title': 'Word Scramble (Expert)',
458 'category': 'word',
459 'audience': 'adult',
460 'thumbnail': 'word_scramble.webp',
461 'multiplayer': True,
462 'min_players': 1, 'max_players': 6,
463 'solo_allowed': True,
464 'difficulty_levels': None,
465 'default_rounds': 6,
466 'engine_config': {'word_length': 8, 'round_time': 90},
467 'tags': ['word', 'expert', 'challenge'],
468 'featured': False,
469 'sort_order': 404,
470 },
471 {
472 'id': 'word-search-animals',
473 'engine': 'word_search',
474 'title': 'Word Search: Animals',
475 'category': 'word',
476 'audience': 'all',
477 'thumbnail': 'word_search.webp',
478 'multiplayer': True,
479 'min_players': 1, 'max_players': 4,
480 'solo_allowed': True,
481 'difficulty_levels': None,
482 'default_rounds': 1,
483 'engine_config': {'grid_size': 10, 'word_count': 8, 'theme': 'animals'},
484 'tags': ['word', 'search', 'casual'],
485 'featured': False,
486 'sort_order': 410,
487 },
488 {
489 'id': 'word-search-space',
490 'engine': 'word_search',
491 'title': 'Word Search: Space',
492 'category': 'word',
493 'audience': 'all',
494 'thumbnail': 'word_search.webp',
495 'multiplayer': True,
496 'min_players': 1, 'max_players': 4,
497 'solo_allowed': True,
498 'difficulty_levels': None,
499 'default_rounds': 1,
500 'engine_config': {'grid_size': 12, 'word_count': 10, 'theme': 'space'},
501 'tags': ['word', 'search', 'science'],
502 'featured': False,
503 'sort_order': 411,
504 },
505 {
506 'id': 'word-search-food',
507 'engine': 'word_search',
508 'title': 'Word Search: Food',
509 'category': 'word',
510 'audience': 'all',
511 'thumbnail': 'word_search.webp',
512 'multiplayer': True,
513 'min_players': 1, 'max_players': 4,
514 'solo_allowed': True,
515 'difficulty_levels': None,
516 'default_rounds': 1,
517 'engine_config': {'grid_size': 10, 'word_count': 8, 'theme': 'food'},
518 'tags': ['word', 'search', 'casual'],
519 'featured': False,
520 'sort_order': 412,
521 },
522 {
523 'id': 'word-search-tech',
524 'engine': 'word_search',
525 'title': 'Word Search: Tech',
526 'category': 'word',
527 'audience': 'adult',
528 'thumbnail': 'word_search.webp',
529 'multiplayer': True,
530 'min_players': 1, 'max_players': 4,
531 'solo_allowed': True,
532 'difficulty_levels': None,
533 'default_rounds': 1,
534 'engine_config': {'grid_size': 12, 'word_count': 10, 'theme': 'tech'},
535 'tags': ['word', 'search', 'tech'],
536 'featured': False,
537 'sort_order': 413,
538 },
539 # Original word_chain from existing game_types.py
540 {
541 'id': 'word-chain',
542 'engine': 'word_chain',
543 'title': 'Word Chain',
544 'category': 'word',
545 'audience': 'all',
546 'thumbnail': 'word_chain.webp',
547 'multiplayer': True,
548 'min_players': 2, 'max_players': 6,
549 'solo_allowed': False,
550 'difficulty_levels': None,
551 'default_rounds': 10,
552 'engine_config': {},
553 'tags': ['word', 'classic', 'turn-based'],
554 'featured': False,
555 'sort_order': 420,
556 },
557]
560PUZZLE_GAMES = [
561 {
562 'id': 'puzzle-sudoku-easy',
563 'engine': 'sudoku',
564 'title': 'Sudoku (Easy)',
565 'category': 'puzzle',
566 'audience': 'all',
567 'thumbnail': 'puzzle_sudoku.webp',
568 'multiplayer': True,
569 'min_players': 1, 'max_players': 4,
570 'solo_allowed': True,
571 'difficulty_levels': None,
572 'default_rounds': 1,
573 'engine_config': {'difficulty': 'easy'},
574 'tags': ['puzzle', 'logic', 'relaxing'],
575 'featured': False,
576 'sort_order': 500,
577 },
578 {
579 'id': 'puzzle-sudoku-medium',
580 'engine': 'sudoku',
581 'title': 'Sudoku',
582 'category': 'puzzle',
583 'audience': 'adult',
584 'thumbnail': 'puzzle_sudoku.webp',
585 'multiplayer': True,
586 'min_players': 1, 'max_players': 4,
587 'solo_allowed': True,
588 'difficulty_levels': None,
589 'default_rounds': 1,
590 'engine_config': {'difficulty': 'medium'},
591 'tags': ['puzzle', 'logic', 'challenge'],
592 'featured': True,
593 'sort_order': 501,
594 },
595 {
596 'id': 'puzzle-sudoku-hard',
597 'engine': 'sudoku',
598 'title': 'Sudoku (Hard)',
599 'category': 'puzzle',
600 'audience': 'adult',
601 'thumbnail': 'puzzle_sudoku.webp',
602 'multiplayer': True,
603 'min_players': 1, 'max_players': 4,
604 'solo_allowed': True,
605 'difficulty_levels': None,
606 'default_rounds': 1,
607 'engine_config': {'difficulty': 'hard'},
608 'tags': ['puzzle', 'logic', 'expert'],
609 'featured': False,
610 'sort_order': 502,
611 },
612 # Original collab_puzzle from existing game_types.py
613 {
614 'id': 'puzzle-thought-experiment',
615 'engine': 'collab_puzzle',
616 'title': 'Thought Experiment',
617 'category': 'puzzle',
618 'audience': 'adult',
619 'thumbnail': 'puzzle_thought.webp',
620 'multiplayer': True,
621 'min_players': 2, 'max_players': 6,
622 'solo_allowed': False,
623 'difficulty_levels': None,
624 'default_rounds': 1,
625 'engine_config': {},
626 'tags': ['puzzle', 'cooperative', 'creative'],
627 'featured': False,
628 'sort_order': 510,
629 },
630]
633PARTY_GAMES = [
634 # Original trivia (uses built-in questions, not OpenTDB)
635 {
636 'id': 'party-quick-trivia',
637 'engine': 'trivia',
638 'title': 'Quick Trivia',
639 'category': 'party',
640 'audience': 'all',
641 'thumbnail': 'party_trivia.webp',
642 'multiplayer': True,
643 'min_players': 2, 'max_players': 8,
644 'solo_allowed': False,
645 'difficulty_levels': None,
646 'default_rounds': 5,
647 'engine_config': {'category': 'general'},
648 'tags': ['party', 'trivia', 'quick'],
649 'featured': False,
650 'sort_order': 600,
651 },
652 {
653 'id': 'party-tech-trivia',
654 'engine': 'trivia',
655 'title': 'Tech Trivia',
656 'category': 'party',
657 'audience': 'adult',
658 'thumbnail': 'party_tech.webp',
659 'multiplayer': True,
660 'min_players': 2, 'max_players': 8,
661 'solo_allowed': False,
662 'difficulty_levels': None,
663 'default_rounds': 5,
664 'engine_config': {'category': 'tech'},
665 'tags': ['party', 'trivia', 'tech'],
666 'featured': False,
667 'sort_order': 601,
668 },
669 {
670 'id': 'party-science-trivia',
671 'engine': 'trivia',
672 'title': 'Science Trivia',
673 'category': 'party',
674 'audience': 'all',
675 'thumbnail': 'party_science.webp',
676 'multiplayer': True,
677 'min_players': 2, 'max_players': 8,
678 'solo_allowed': False,
679 'difficulty_levels': None,
680 'default_rounds': 5,
681 'engine_config': {'category': 'science'},
682 'tags': ['party', 'trivia', 'science'],
683 'featured': False,
684 'sort_order': 602,
685 },
686 # Compute challenge (from existing game_types.py)
687 {
688 'id': 'party-compute-race',
689 'engine': 'compute_challenge',
690 'title': 'Compute Race',
691 'category': 'party',
692 'audience': 'adult',
693 'thumbnail': 'party_compute.webp',
694 'multiplayer': True,
695 'min_players': 2, 'max_players': 4,
696 'solo_allowed': False,
697 'difficulty_levels': None,
698 'default_rounds': 5,
699 'engine_config': {'target_tasks': 5},
700 'tags': ['party', 'compute', 'race'],
701 'featured': False,
702 'sort_order': 610,
703 },
704]
707# ─── Full Catalog ──────────────────────────────────────────────────
709def _build_full_catalog() -> List[Dict]:
710 """Build complete catalog: trivia variants + static entries."""
711 catalog = []
712 catalog.extend(_build_trivia_entries())
713 catalog.extend(BOARD_GAMES)
714 catalog.extend(ARCADE_GAMES)
715 catalog.extend(WORD_GAMES)
716 catalog.extend(PUZZLE_GAMES)
717 catalog.extend(PARTY_GAMES)
718 return catalog
721# Cached in-memory catalog
722_CATALOG: Optional[List[Dict]] = None
723_CATALOG_INDEX: Optional[Dict[str, Dict]] = None
726def _ensure_catalog():
727 global _CATALOG, _CATALOG_INDEX
728 if _CATALOG is None:
729 _CATALOG = _build_full_catalog()
730 _CATALOG_INDEX = {entry['id']: entry for entry in _CATALOG}
731 logger.info("Game catalog built: %d games", len(_CATALOG))
734def get_catalog_entry(game_id: str) -> Optional[Dict]:
735 """Get a single catalog entry by ID."""
736 _ensure_catalog()
737 return _CATALOG_INDEX.get(game_id)
740def list_catalog(
741 audience: str = None,
742 category: str = None,
743 multiplayer: bool = None,
744 featured: bool = None,
745 tag: str = None,
746 search: str = None,
747 limit: int = 50,
748 offset: int = 0,
749) -> Dict:
750 """List catalog entries with filters. Returns {items, total, categories}."""
751 _ensure_catalog()
753 filtered = list(_CATALOG)
755 if audience:
756 filtered = [e for e in filtered
757 if e['audience'] == audience or e['audience'] == 'all']
758 if category:
759 filtered = [e for e in filtered if e['category'] == category]
760 if multiplayer is not None:
761 filtered = [e for e in filtered if e['multiplayer'] == multiplayer]
762 if featured is not None:
763 filtered = [e for e in filtered if e.get('featured') == featured]
764 if tag:
765 tag_lower = tag.lower()
766 filtered = [e for e in filtered if tag_lower in e.get('tags', [])]
767 if search:
768 search_lower = search.lower()
769 filtered = [e for e in filtered
770 if search_lower in e['title'].lower()
771 or search_lower in e.get('category', '')
772 or any(search_lower in t for t in e.get('tags', []))]
774 filtered.sort(key=lambda e: e.get('sort_order', 999))
775 total = len(filtered)
777 # Category counts (from full filtered set before pagination)
778 categories = {}
779 for e in filtered:
780 cat = e.get('category', 'other')
781 categories[cat] = categories.get(cat, 0) + 1
783 items = filtered[offset:offset + limit]
785 return {
786 'items': items,
787 'total': total,
788 'categories': categories,
789 }
792def get_engine_for_catalog_entry(game_id: str) -> Optional[str]:
793 """Resolve catalog game ID to engine name."""
794 entry = get_catalog_entry(game_id)
795 if entry:
796 return entry['engine']
797 return None
800def get_config_for_catalog_entry(game_id: str, overrides: Dict = None) -> Dict:
801 """Get merged engine_config for a catalog entry + user overrides."""
802 entry = get_catalog_entry(game_id)
803 if not entry:
804 return overrides or {}
806 config = dict(entry.get('engine_config', {}))
807 if overrides:
808 config.update(overrides)
810 # Apply difficulty override to engine config
811 if 'difficulty' in (overrides or {}):
812 config['difficulty'] = overrides['difficulty']
814 return config