Coverage for integrations / social / game_ai.py: 92.2%

103 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial — Server-side AI move generators for solo game play. 

3 

4When a user plays solo against the computer, the AI opponent needs to 

5produce a move. For engines where the **server holds the answer key** 

6(trivia, word_scramble, word_search, sudoku), generating a move is 

7cheap: read the ground truth, optionally inject difficulty-scaled 

8mistakes, return a validated move shape. 

9 

10For **board games** (the 'boardgame' engine family — tictactoe, 

11connect4, checkers, reversi, mancala, nim, dots_and_boxes, battleship) 

12the rules live client-side in the boardgame.io Game definitions 

13(Nunba: landing-page/src/components/Social/Games/board-games/*; 

14RN: components/Social/Games/board-games/*). Re-implementing those 

15rules here would be a DRY violation — so this module **explicitly 

16rejects** board-game AI requests and defers to the client's 

17boardgame.io MCTSBot/RandomBot. See Nunba `utils/gameAI.js`. 

18 

19For **phaser** (arcade score-chasing games) there is no move to make — 

20the player plays against the scene itself. No server-side AI. 

21 

22Difficulty model 

23──────────────── 

24All AI classes accept `difficulty` ∈ {'easy', 'medium', 'hard'}. This 

25scales a single `error_rate` parameter that determines how often the AI 

26chooses a non-optimal move. Each subclass maps error_rate to its own 

27domain (wrong answer, wrong guess, wrong sudoku cell, etc.). 

28 

29 difficulty error_rate interpretation 

30 easy 0.45 ≈ half wrong (beatable by anyone) 

31 medium 0.18 ≈ 1/5 wrong (decent challenge) 

32 hard 0.04 ≈ rarely wrong (near-perfect) 

33 

34Usage 

35───── 

36 from integrations.social.game_ai import generate_ai_move 

37 

38 move = generate_ai_move( 

39 game_state=session.game_state, 

40 game_type=session.game_type, 

41 ai_user_id='ai', 

42 difficulty='medium', 

43 ) 

44 # move is the same shape expected by the corresponding game 

45 # type's apply_move() — the caller can feed it straight into 

46 # GameService.submit_move(). 

47 

48The caller (api_games.ai_move endpoint) is responsible for: 

49 - Verifying the session is active 

50 - Authorizing the request (host only, to prevent spam) 

51 - Submitting the returned move via the normal submit_move path 

52 

53This module does NOT mutate session state. It is a pure move generator. 

54 

55Phase 1 limitation — "host plays both sides" 

56───────────────────────────────────────────── 

57Until a dedicated AI GameParticipant + GameService.submit_move_as_ai 

58path lands, the /ai_move endpoint returns a move dict ready for 

59`POST /games/<id>/move`, BUT when that second POST runs, the move is 

60authenticated as the host (via g.user_id) and credited to the host's 

61score — not to a distinct AI opponent. For solo trivia/word-scramble/ 

62word-search/sudoku this means "the host plays both sides": the AI's 

63answer is submitted under the host's identity and the host's score 

64rises regardless of whether the host personally answered correctly. 

65 

66Phase 1 use cases where this is acceptable: 

67 - "Suggest-a-move" hints during a stuck human game 

68 - Automated smoke tests / CI for game session flows 

69 - Future cross-device agents previewing AI moves before display 

70 

71Phase 1 use cases where this is NOT acceptable (and callers should 

72avoid /ai_move for now): 

73 - Solo ghost-opponent with a live scoreboard 

74 - Multiplayer fill-dropped-seat 

75 

76Phase 2 work: add `ai_opponent: True` to create_session config, create 

77an AI GameParticipant row, add `GameService.submit_move_as_ai(ai_uid)` 

78that bypasses the g.user_id-must-be-participant check. The /ai_move 

79endpoint then moves from read-only generator to one-step submit. 

80""" 

81 

82import logging 

83import random 

84from typing import Dict, List, Optional, Tuple 

85 

86logger = logging.getLogger('hevolve_social') 

87 

88 

89# ─── Difficulty → error rate map ──────────────────────────────────── 

90 

91DIFFICULTY_ERROR_RATE = { 

92 'easy': 0.45, 

93 'medium': 0.18, 

94 'hard': 0.04, 

95} 

96 

97 

98def _error_rate(difficulty: str) -> float: 

99 """Resolve difficulty string to an error rate in [0, 1].""" 

100 return DIFFICULTY_ERROR_RATE.get(difficulty, DIFFICULTY_ERROR_RATE['medium']) 

101 

102 

103# ─── Base class ───────────────────────────────────────────────────── 

104 

105class GameAI: 

106 """Base class for server-side AI move generators. 

107 

108 Subclasses implement generate_move(game_state, ai_user_id, difficulty) 

109 and return a move_data dict matching the schema their corresponding 

110 BaseGameType.apply_move() expects. 

111 """ 

112 

113 def generate_move(self, game_state: Dict, ai_user_id: str, 

114 difficulty: str = 'medium') -> Dict: 

115 raise NotImplementedError 

116 

117 def _roll_error(self, difficulty: str) -> bool: 

118 """Return True iff the AI should make a mistake this turn.""" 

119 return random.random() < _error_rate(difficulty) 

120 

121 

122# ─── Multiple-choice trivia (shared by TriviaGame + OpenTDBTriviaGame) ── 

123 

124class MultipleChoiceTriviaAI(GameAI): 

125 """AI for both TriviaGame and OpenTDBTriviaGame. 

126 

127 Both engines normalize their questions to the same internal shape 

128 before storing in game_state: 

129 {'q': str, 'a': str, 'options': [str, ...], ...} 

130 (see game_types.TriviaGame.CATEGORIES and 

131 game_types_extended.OpenTDBTriviaGame._fetch_questions lines 75-89, 

132 which do `html.unescape` + shuffle and emit the same 'a'/'options' 

133 keys as TriviaGame). 

134 

135 The server stores the correct answer at 

136 game_state['questions'][current_question_idx]['a']. On error, the 

137 AI picks a different option from the question's `options` list. 

138 """ 

139 

140 def generate_move(self, game_state, ai_user_id, difficulty='medium'): 

141 idx = game_state.get('current_question_idx', 0) 

142 questions = game_state.get('questions', []) 

143 if idx >= len(questions): 

144 raise ValueError("No active question to answer") 

145 

146 question = questions[idx] 

147 correct = question.get('a', '') 

148 options = question.get('options', []) or [] 

149 

150 if self._roll_error(difficulty) and len(options) > 1: 

151 wrong = [o for o in options if o != correct] 

152 if wrong: 

153 return {'answer': random.choice(wrong)} 

154 

155 return {'answer': correct} 

156 

157 

158# Backwards-compatible aliases so callers can import the engine-specific 

159# name if they prefer — both resolve to the same implementation. 

160TriviaAI = MultipleChoiceTriviaAI 

161OpenTDBTriviaAI = MultipleChoiceTriviaAI 

162 

163 

164# ─── Word Scramble ────────────────────────────────────────────────── 

165 

166class WordScrambleAI(GameAI): 

167 """AI for WordScrambleGame. 

168 

169 The server stores the unscrambled word at 

170 game_state['rounds'][current_round_idx]['word']. On error, the AI 

171 emits a deliberately-wrong guess (permutation of the scrambled 

172 letters that is not the correct word). 

173 """ 

174 

175 def generate_move(self, game_state, ai_user_id, difficulty='medium'): 

176 idx = game_state.get('current_round_idx', 0) 

177 rounds = game_state.get('rounds', []) 

178 if idx >= len(rounds): 

179 raise ValueError("No active round") 

180 

181 round_data = rounds[idx] 

182 if round_data.get('solved_by'): 

183 raise ValueError("Round already solved") 

184 

185 correct = round_data.get('word', '') 

186 scrambled = round_data.get('scrambled', correct) 

187 

188 move = {'word': correct, 'time_ms': self._response_time_ms(difficulty)} 

189 

190 if self._roll_error(difficulty) and len(scrambled) > 1: 

191 letters = list(scrambled) 

192 for _ in range(10): 

193 random.shuffle(letters) 

194 candidate = ''.join(letters) 

195 if candidate.lower() != correct.lower(): 

196 move['word'] = candidate 

197 break 

198 

199 return move 

200 

201 def _response_time_ms(self, difficulty: str) -> int: 

202 """Simulate AI 'thinking time'. Harder AIs respond faster.""" 

203 return { 

204 'easy': random.randint(8000, 15000), 

205 'medium': random.randint(4000, 9000), 

206 'hard': random.randint(1500, 5000), 

207 }.get(difficulty, 5000) 

208 

209 

210# ─── Word Search ──────────────────────────────────────────────────── 

211 

212class WordSearchAI(GameAI): 

213 """AI for WordSearchGame. 

214 

215 All hidden words are in game_state['words_to_find']. The AI picks 

216 an as-yet-unfound word. On error (easy mode) it occasionally picks 

217 an already-found word, which the engine treats as a no-op / score 0. 

218 """ 

219 

220 def generate_move(self, game_state, ai_user_id, difficulty='medium'): 

221 words_to_find = game_state.get('words_to_find', []) or [] 

222 found_words = game_state.get('found_words', {}) or {} 

223 

224 unfound = [w for w in words_to_find if w.lower() not in found_words] 

225 if not unfound: 

226 raise ValueError("No unfound words left") 

227 

228 # On error, occasionally emit a word that has already been found 

229 # (engine returns 0 score — AI effectively wastes a turn). 

230 if self._roll_error(difficulty) and found_words: 

231 return {'word': random.choice(list(found_words.keys()))} 

232 

233 return {'word': random.choice(unfound)} 

234 

235 

236# ─── Sudoku ───────────────────────────────────────────────────────── 

237 

238class SudokuAI(GameAI): 

239 """AI for SudokuGame. 

240 

241 The server stores the solved grid at game_state['solution']. The 

242 AI picks an empty cell (puzzle[r][c] == 0) and fills it with the 

243 correct value. On error it fills with a deliberately-wrong digit. 

244 """ 

245 

246 def generate_move(self, game_state, ai_user_id, difficulty='medium'): 

247 puzzle = game_state.get('puzzle', []) 

248 solution = game_state.get('solution', []) 

249 if not puzzle or not solution: 

250 raise ValueError("Sudoku state missing puzzle or solution") 

251 

252 empty_cells: List[Tuple[int, int]] = [ 

253 (r, c) 

254 for r in range(len(puzzle)) 

255 for c in range(len(puzzle[r])) 

256 if puzzle[r][c] == 0 

257 ] 

258 if not empty_cells: 

259 raise ValueError("No empty cells remaining") 

260 

261 r, c = random.choice(empty_cells) 

262 correct = solution[r][c] 

263 

264 value = correct 

265 if self._roll_error(difficulty): 

266 wrong_values = [v for v in range(1, 10) if v != correct] 

267 if wrong_values: 

268 value = random.choice(wrong_values) 

269 

270 return {'row': r, 'col': c, 'value': value} 

271 

272 

273# ─── Registry ─────────────────────────────────────────────────────── 

274 

275# Single shared instance for trivia-family engines — both TriviaGame 

276# and OpenTDBTriviaGame normalize to the same question shape, so the 

277# same handler works for both. 

278_TRIVIA_AI = MultipleChoiceTriviaAI() 

279 

280_AI_REGISTRY: Dict[str, GameAI] = { 

281 'trivia': _TRIVIA_AI, 

282 'quick_match': _TRIVIA_AI, # quick_match defaults to trivia 

283 'opentdb_trivia': _TRIVIA_AI, # same normalized shape as TriviaGame 

284 'word_scramble': WordScrambleAI(), 

285 'word_search': WordSearchAI(), 

286 'sudoku': SudokuAI(), 

287 # boardgame, phaser intentionally absent — see module docstring. 

288 # word_chain, collab_puzzle, compute_challenge: no AI — caller 

289 # gets "No server-side AI registered" (distinct from 

290 # client-authoritative rejection). 

291} 

292 

293 

294# Explicit "client-authoritative" set so the API endpoint can return a 

295# clear error pointing callers at the client-side path instead of a 

296# generic 400. Matches the engines with client-side Game definitions. 

297CLIENT_AUTHORITATIVE_ENGINES = frozenset({ 

298 'boardgame', # use boardgame.io MCTSBot on client 

299 'phaser', # arcade — no AI move concept 

300}) 

301 

302 

303def _resolve_catalog_engine(game_type: str) -> Optional[str]: 

304 """Resolve a catalog ID to its underlying engine name, or None. 

305 

306 Narrow exception catching: only ImportError is tolerated (optional 

307 dependency). Other exceptions (malformed catalog entries, type 

308 errors) propagate so mis-routing is visible rather than hidden 

309 behind a False fallback. 

310 """ 

311 try: 

312 from .game_catalog import get_engine_for_catalog_entry 

313 except ImportError: 

314 return None 

315 return get_engine_for_catalog_entry(game_type) 

316 

317 

318def get_game_ai(game_type: str) -> Optional[GameAI]: 

319 """Resolve a game_type (engine name or catalog id) to an AI handler. 

320 

321 Returns None when no server-side AI exists. Callers should check 

322 for None and either emit an error or fall back to the client-side 

323 path. 

324 """ 

325 # Direct engine-name match 

326 if game_type in _AI_REGISTRY: 

327 return _AI_REGISTRY[game_type] 

328 

329 # Catalog-id lookup — resolve to the underlying engine name 

330 engine = _resolve_catalog_engine(game_type) 

331 if engine and engine in _AI_REGISTRY: 

332 return _AI_REGISTRY[engine] 

333 

334 return None 

335 

336 

337def is_client_authoritative(game_type: str) -> bool: 

338 """True if this engine's AI must run client-side (not here).""" 

339 if game_type in CLIENT_AUTHORITATIVE_ENGINES: 

340 return True 

341 engine = _resolve_catalog_engine(game_type) 

342 return bool(engine and engine in CLIENT_AUTHORITATIVE_ENGINES) 

343 

344 

345def generate_ai_move(game_state: Dict, game_type: str, 

346 ai_user_id: str = 'ai', 

347 difficulty: str = 'medium') -> Dict: 

348 """Generate an AI move for the given session state. 

349 

350 Args: 

351 game_state: The session's current game_state dict (from 

352 GameSession.game_state). Not mutated. 

353 game_type: The session's game_type (engine name or catalog id). 

354 ai_user_id: The user_id string the AI will play as. Passed 

355 through to the game's move (handlers need it for 

356 turn-ownership checks). 

357 difficulty: 'easy' | 'medium' | 'hard'. 

358 

359 Returns: 

360 A move_data dict ready to feed into GameService.submit_move() 

361 or the matching game type's apply_move(). 

362 

363 Raises: 

364 ValueError: No server-side AI exists for this game type (the 

365 caller should route to the client-side path), OR the game 

366 state is in a position where no move is possible. 

367 """ 

368 if is_client_authoritative(game_type): 

369 raise ValueError( 

370 f"Game type '{game_type}' is client-authoritative — " 

371 "use the client-side AI (boardgame.io MCTSBot on Nunba / RN)." 

372 ) 

373 

374 ai = get_game_ai(game_type) 

375 if ai is None: 

376 raise ValueError(f"No server-side AI registered for game type '{game_type}'") 

377 

378 return ai.generate_move(game_state, ai_user_id, difficulty)