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

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 

8 

9logger = logging.getLogger('hevolve_social') 

10 

11 

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} 

23 

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} 

29 

30# ─── Catalog Data ────────────────────────────────────────────────── 

31 

32def _build_trivia_entries(): 

33 """Generate 72 trivia variants from 24 categories x 3 modes.""" 

34 entries = [] 

35 sort_base = 100 

36 

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']}" 

41 

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 

65 

66 return entries 

67 

68 

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] 

207 

208 

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] 

383 

384 

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] 

558 

559 

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] 

631 

632 

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] 

705 

706 

707# ─── Full Catalog ────────────────────────────────────────────────── 

708 

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 

719 

720 

721# Cached in-memory catalog 

722_CATALOG: Optional[List[Dict]] = None 

723_CATALOG_INDEX: Optional[Dict[str, Dict]] = None 

724 

725 

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)) 

732 

733 

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) 

738 

739 

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() 

752 

753 filtered = list(_CATALOG) 

754 

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', []))] 

773 

774 filtered.sort(key=lambda e: e.get('sort_order', 999)) 

775 total = len(filtered) 

776 

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 

782 

783 items = filtered[offset:offset + limit] 

784 

785 return { 

786 'items': items, 

787 'total': total, 

788 'categories': categories, 

789 } 

790 

791 

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 

798 

799 

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 {} 

805 

806 config = dict(entry.get('engine_config', {})) 

807 if overrides: 

808 config.update(overrides) 

809 

810 # Apply difficulty override to engine config 

811 if 'difficulty' in (overrides or {}): 

812 config['difficulty'] = overrides['difficulty'] 

813 

814 return config