@rookiestar/eng-lang-tutor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @rookiestar/eng-lang-tutor might be problematic. Click here for more details.

@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Gamification - Duolingo-style achievement system for eng-lang-tutor.
4
+
5
+ Components:
6
+ 1. XP & Levels: XP accumulates, levels up at thresholds
7
+ 2. Streaks: Consecutive days of study, with freeze protection
8
+ 3. Badges: Achievement milestones
9
+ 4. Gems: Currency for streak freeze, hints
10
+
11
+ Note: Leagues removed - not applicable for single-user scenario.
12
+ """
13
+
14
+ from typing import Dict, Any, List, Tuple, Optional
15
+ from datetime import datetime, date, timedelta
16
+
17
+
18
+ class GamificationManager:
19
+ """Manages gamification elements: levels, streaks, badges, gems."""
20
+
21
+ # Level thresholds (XP needed for each level)
22
+ LEVEL_THRESHOLDS = [
23
+ 0, 50, 100, 200, 350, # 1-5 Beginner
24
+ 550, 800, 1100, 1500, 2000, # 6-10 Intermediate
25
+ 2600, 3300, 4100, 5000, 6000, # 11-15 Advanced
26
+ 7200, 8500, 10000, 12000, 15000 # 16-20 Expert
27
+ ]
28
+
29
+ # Badge definitions
30
+ BADGES = {
31
+ 'first_steps': {
32
+ 'name': 'First Steps',
33
+ 'description': 'Complete your first quiz',
34
+ 'condition': 'total_quizzes >= 1',
35
+ 'gem_reward': 10
36
+ },
37
+ 'week_warrior': {
38
+ 'name': 'Week Warrior',
39
+ 'description': '7-day streak',
40
+ 'condition': 'streak >= 7',
41
+ 'gem_reward': 25
42
+ },
43
+ 'month_master': {
44
+ 'name': 'Month Master',
45
+ 'description': '30-day streak',
46
+ 'condition': 'streak >= 30',
47
+ 'gem_reward': 100
48
+ },
49
+ 'perfect_10': {
50
+ 'name': 'Perfect 10',
51
+ 'description': '10 perfect quizzes',
52
+ 'condition': 'perfect_quizzes >= 10',
53
+ 'gem_reward': 50
54
+ },
55
+ 'vocab_hunter': {
56
+ 'name': 'Vocab Hunter',
57
+ 'description': 'Learn 100 expressions',
58
+ 'condition': 'expressions_learned >= 100',
59
+ 'gem_reward': 75
60
+ },
61
+ 'error_slayer': {
62
+ 'name': 'Error Slayer',
63
+ 'description': 'Clear 30 errors from notebook',
64
+ 'condition': 'errors_cleared >= 30',
65
+ 'gem_reward': 30
66
+ }
67
+ }
68
+
69
+ # Gem costs
70
+ STREAK_FREEZE_COST = 50
71
+ HINT_COST = 5
72
+
73
+ def __init__(self, state_manager=None):
74
+ """
75
+ Initialize the gamification manager.
76
+
77
+ Args:
78
+ state_manager: Optional StateManager instance
79
+ """
80
+ self.state_manager = state_manager
81
+
82
+ def update_streak(self, state: Dict[str, Any], study_date: str) -> Tuple[int, bool, str]:
83
+ """
84
+ Update streak based on study activity.
85
+
86
+ Args:
87
+ state: Current state dictionary
88
+ study_date: Date of study (YYYY-MM-DD)
89
+
90
+ Returns:
91
+ Tuple of (new_streak, streak_continued, message)
92
+ """
93
+ user = state.get('user', {})
94
+ progress = state.get('progress', {})
95
+
96
+ last_study = progress.get('last_study_date')
97
+ current_streak = user.get('streak', 0)
98
+
99
+ try:
100
+ today = datetime.strptime(study_date, '%Y-%m-%d').date()
101
+ except ValueError:
102
+ return (current_streak, False, "Invalid date format")
103
+
104
+ if last_study:
105
+ try:
106
+ last = datetime.strptime(last_study, '%Y-%m-%d').date()
107
+ except ValueError:
108
+ last = None
109
+
110
+ if last == today:
111
+ # Already studied today
112
+ return (current_streak, False, "Already studied today")
113
+
114
+ days_diff = (today - last).days
115
+
116
+ if days_diff == 1:
117
+ # Consecutive day - streak continues
118
+ new_streak = current_streak + 1
119
+ state['user']['streak'] = new_streak
120
+ return (new_streak, True, f"Streak continued! {new_streak} days")
121
+
122
+ elif days_diff == 2 and user.get('streak_freeze', 0) > 0:
123
+ # Use streak freeze
124
+ state['user']['streak_freeze'] -= 1
125
+ new_streak = current_streak + 1
126
+ state['user']['streak'] = new_streak
127
+ return (new_streak, True, f"Streak freeze used! {new_streak} days")
128
+
129
+ else:
130
+ # Streak broken
131
+ state['user']['streak'] = 1
132
+ return (1, False, f"Streak broken. Starting over at 1 day")
133
+
134
+ else:
135
+ # First study ever
136
+ state['user']['streak'] = 1
137
+ return (1, True, "Started your streak!")
138
+
139
+ def calculate_level(self, xp: int) -> int:
140
+ """
141
+ Calculate level from total XP.
142
+
143
+ Args:
144
+ xp: Total experience points
145
+
146
+ Returns:
147
+ Level (1-20)
148
+ """
149
+ for i in range(len(self.LEVEL_THRESHOLDS) - 1, -1, -1):
150
+ if xp >= self.LEVEL_THRESHOLDS[i]:
151
+ return i + 1
152
+ return 1
153
+
154
+ def get_level_name(self, level: int) -> str:
155
+ """Get the name for a level range (Journey Stage).
156
+
157
+ Note: This is Activity Level (活跃等级), measuring engagement depth,
158
+ NOT language ability. For language ability, see CEFR level.
159
+ """
160
+ if level <= 5:
161
+ return "Starter" # 启程者
162
+ elif level <= 10:
163
+ return "Traveler" # 行路人
164
+ elif level <= 15:
165
+ return "Explorer" # 探索者
166
+ else:
167
+ return "Pioneer" # 开拓者
168
+
169
+ def check_level_up(self, old_xp: int, new_xp: int) -> Tuple[bool, Optional[int]]:
170
+ """
171
+ Check if user leveled up.
172
+
173
+ Args:
174
+ old_xp: XP before this session
175
+ new_xp: XP after this session
176
+
177
+ Returns:
178
+ Tuple of (leveled_up, new_level)
179
+ """
180
+ old_level = self.calculate_level(old_xp)
181
+ new_level = self.calculate_level(new_xp)
182
+
183
+ if new_level > old_level:
184
+ return (True, new_level)
185
+ return (False, None)
186
+
187
+ def update_level(self, state: Dict[str, Any]) -> Tuple[int, bool]:
188
+ """
189
+ Update level based on current XP.
190
+
191
+ Args:
192
+ state: Current state (will be modified in place)
193
+
194
+ Returns:
195
+ Tuple of (level, just_leveled_up)
196
+ """
197
+ xp = state.get('user', {}).get('xp', 0)
198
+ current_level = state.get('user', {}).get('level', 1)
199
+ new_level = self.calculate_level(xp)
200
+
201
+ state['user']['level'] = new_level
202
+
203
+ leveled_up = new_level > current_level
204
+ return (new_level, leveled_up)
205
+
206
+ def check_badges(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
207
+ """
208
+ Check and award new badges.
209
+
210
+ Args:
211
+ state: Current state
212
+
213
+ Returns:
214
+ List of newly earned badges with details
215
+ """
216
+ earned = []
217
+ current_badges = state.get('user', {}).get('badges', [])
218
+ progress = state.get('progress', {})
219
+ user = state.get('user', {})
220
+
221
+ # Prepare badge check data
222
+ check_data = {
223
+ 'total_quizzes': progress.get('total_quizzes', 0),
224
+ 'streak': user.get('streak', 0),
225
+ 'perfect_quizzes': progress.get('perfect_quizzes', 0),
226
+ 'expressions_learned': progress.get('expressions_learned', 0),
227
+ 'errors_cleared': sum(1 for e in state.get('error_notebook', []) if e.get('reviewed'))
228
+ }
229
+
230
+ for badge_id, badge_info in self.BADGES.items():
231
+ if badge_id in current_badges:
232
+ continue
233
+
234
+ condition = badge_info['condition']
235
+ try:
236
+ if eval(condition, {"__builtins__": {}}, check_data):
237
+ earned.append({
238
+ 'id': badge_id,
239
+ 'name': badge_info['name'],
240
+ 'description': badge_info['description'],
241
+ 'gem_reward': badge_info['gem_reward']
242
+ })
243
+ current_badges.append(badge_id)
244
+ except:
245
+ pass
246
+
247
+ state['user']['badges'] = current_badges
248
+ return earned
249
+
250
+ def award_gems(self, state: Dict[str, Any], action: str, amount: int = 0) -> int:
251
+ """
252
+ Award gems for various actions.
253
+
254
+ Args:
255
+ state: Current state
256
+ action: Type of action
257
+ amount: Specific amount (overrides default)
258
+
259
+ Returns:
260
+ Number of gems awarded
261
+ """
262
+ GEM_REWARDS = {
263
+ 'quiz_complete': 5,
264
+ 'perfect_quiz': 10,
265
+ 'streak_milestone': 20,
266
+ 'level_up': 25,
267
+ 'badge_earned': 0, # Use badge-specific amount
268
+ 'daily_goal': 15
269
+ }
270
+
271
+ gems = amount if amount > 0 else GEM_REWARDS.get(action, 0)
272
+ state['user']['gems'] = state.get('user', {}).get('gems', 0) + gems
273
+
274
+ return gems
275
+
276
+ def spend_gems(self, state: Dict[str, Any], action: str) -> Tuple[bool, int]:
277
+ """
278
+ Spend gems on items.
279
+
280
+ Args:
281
+ state: Current state
282
+ action: What to spend gems on
283
+
284
+ Returns:
285
+ Tuple of (success, gems_spent)
286
+ """
287
+ GEM_COSTS = {
288
+ 'streak_freeze': self.STREAK_FREEZE_COST,
289
+ 'hint': self.HINT_COST
290
+ }
291
+
292
+ cost = GEM_COSTS.get(action, 0)
293
+ current_gems = state.get('user', {}).get('gems', 0)
294
+
295
+ if current_gems >= cost:
296
+ state['user']['gems'] = current_gems - cost
297
+
298
+ if action == 'streak_freeze':
299
+ state['user']['streak_freeze'] = state.get('user', {}).get('streak_freeze', 0) + 1
300
+
301
+ return (True, cost)
302
+
303
+ return (False, 0)
304
+
305
+ def get_streak_multiplier(self, streak: int) -> float:
306
+ """
307
+ Calculate XP multiplier based on streak.
308
+
309
+ Args:
310
+ streak: Current streak days
311
+
312
+ Returns:
313
+ Multiplier (1.0 to 2.0)
314
+ """
315
+ return min(1.0 + (streak * 0.05), 2.0)
316
+
317
+ def get_progress_summary(self, state: Dict[str, Any]) -> Dict[str, Any]:
318
+ """
319
+ Get a summary of user's progress.
320
+
321
+ Args:
322
+ state: Current state
323
+
324
+ Returns:
325
+ Summary dictionary
326
+ """
327
+ user = state.get('user', {})
328
+ progress = state.get('progress', {})
329
+
330
+ xp = user.get('xp', 0)
331
+ level = user.get('level', 1)
332
+
333
+ # Calculate XP to next level
334
+ next_threshold = self.LEVEL_THRESHOLDS[min(level, 19)]
335
+ current_threshold = self.LEVEL_THRESHOLDS[level - 1]
336
+ xp_in_level = xp - current_threshold
337
+ xp_to_next = next_threshold - xp if level < 20 else 0
338
+ level_progress = (xp_in_level / (next_threshold - current_threshold)) * 100 if level < 20 else 100
339
+
340
+ return {
341
+ 'xp': xp,
342
+ 'level': level,
343
+ 'level_name': self.get_level_name(level),
344
+ 'xp_in_level': xp_in_level,
345
+ 'xp_to_next_level': xp_to_next,
346
+ 'level_progress': round(level_progress, 1),
347
+ 'streak': user.get('streak', 0),
348
+ 'streak_multiplier': self.get_streak_multiplier(user.get('streak', 0)),
349
+ 'gems': user.get('gems', 0),
350
+ 'badges': len(user.get('badges', [])),
351
+ 'total_badges': len(self.BADGES)
352
+ }
353
+
354
+
355
+ # CLI interface for testing
356
+ if __name__ == "__main__":
357
+ import argparse
358
+ import json
359
+
360
+ parser = argparse.ArgumentParser(description="Gamification for eng-lang-tutor")
361
+ parser.add_argument('--demo', action='store_true', help='Run demo')
362
+ parser.add_argument('--state', type=str, help='Path to state JSON file')
363
+
364
+ args = parser.parse_args()
365
+
366
+ gm = GamificationManager()
367
+
368
+ if args.demo:
369
+ state = {
370
+ "user": {
371
+ "xp": 1250,
372
+ "level": 7,
373
+ "streak": 5,
374
+ "streak_freeze": 2,
375
+ "gems": 100,
376
+ "badges": ["first_steps"]
377
+ },
378
+ "progress": {
379
+ "total_quizzes": 15,
380
+ "perfect_quizzes": 3,
381
+ "expressions_learned": 45,
382
+ "last_study_date": "2026-02-19"
383
+ },
384
+ "error_notebook": [
385
+ {"reviewed": True}, {"reviewed": True}, {"reviewed": False}
386
+ ]
387
+ }
388
+
389
+ print("=== Progress Summary ===")
390
+ summary = gm.get_progress_summary(state)
391
+ print(json.dumps(summary, indent=2))
392
+
393
+ print("\n=== Update Streak (today: 2026-02-20) ===")
394
+ streak, continued, msg = gm.update_streak(state, "2026-02-20")
395
+ print(f"Streak: {streak}, Continued: {continued}, Message: {msg}")
396
+
397
+ print("\n=== Check Badges ===")
398
+ new_badges = gm.check_badges(state)
399
+ print(f"New badges earned: {new_badges}")
400
+
401
+ elif args.state:
402
+ with open(args.state) as f:
403
+ state = json.load(f)
404
+
405
+ summary = gm.get_progress_summary(state)
406
+ print(json.dumps(summary, indent=2))