alive-ai 0.1.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.
- package/Dockerfile +24 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/alive_ai/__init__.py +3 -0
- package/brain/__init__.py +59 -0
- package/brain/almost_said.py +154 -0
- package/brain/bid_detector.py +636 -0
- package/brain/conversation_flow.py +135 -0
- package/brain/curiosity.py +328 -0
- package/brain/default_mode.py +1438 -0
- package/brain/dreams.py +220 -0
- package/brain/embeddings/__init__.py +82 -0
- package/brain/emotional_memory.py +949 -0
- package/brain/global_activity.py +173 -0
- package/brain/group_dynamics.py +63 -0
- package/brain/linguistic.py +235 -0
- package/brain/llm/__init__.py +63 -0
- package/brain/llm/base.py +33 -0
- package/brain/llm/fallback_router.py +309 -0
- package/brain/llm/manifest.md +30 -0
- package/brain/llm/ollama.py +218 -0
- package/brain/llm/openrouter.py +151 -0
- package/brain/llm/provider.py +205 -0
- package/brain/llm/unified.py +423 -0
- package/brain/llm/zai.py +169 -0
- package/brain/manifest.md +23 -0
- package/brain/memory/__init__.py +123 -0
- package/brain/memory/episodic.py +92 -0
- package/brain/memory/fact_extractor.py +209 -0
- package/brain/memory/index.py +54 -0
- package/brain/memory/manager.py +151 -0
- package/brain/memory/summarizer.py +102 -0
- package/brain/memory/vector_store.py +297 -0
- package/brain/memory/working.py +43 -0
- package/brain/narrative.py +343 -0
- package/brain/stt/__init__.py +4 -0
- package/brain/stt/google_stt.py +83 -0
- package/brain/stt/whisper_stt.py +82 -0
- package/brain/subconscious/__init__.py +33 -0
- package/brain/subconscious/actions.py +136 -0
- package/brain/subconscious/evaluation.py +166 -0
- package/brain/subconscious/goal_system.py +90 -0
- package/brain/subconscious/goals.py +41 -0
- package/brain/subconscious/impulse_generator.py +200 -0
- package/brain/subconscious/impulses.py +48 -0
- package/brain/subconscious/learning.py +24 -0
- package/brain/subconscious/learning_system.py +79 -0
- package/brain/subconscious/loop.py +398 -0
- package/brain/subconscious/manifest.md +32 -0
- package/brain/subconscious/relationship.py +47 -0
- package/brain/subconscious/relationship_memory.py +83 -0
- package/brain/subconscious/response_analyzer.py +74 -0
- package/brain/subconscious/templates.py +70 -0
- package/brain/subconscious/thought.py +37 -0
- package/brain/subconscious/working_memory.py +97 -0
- package/cli/index.js +371 -0
- package/config/directives.example.json +28 -0
- package/config/instructions.example.md +16 -0
- package/config/self.example.json +74 -0
- package/config/settings.example.json +95 -0
- package/core/__init__.py +1 -0
- package/core/config.py +54 -0
- package/core/directives.py +198 -0
- package/core/events.py +50 -0
- package/core/follow_up.py +267 -0
- package/core/hot_reload.py +174 -0
- package/core/initialization.py +253 -0
- package/core/manifest.md +28 -0
- package/core/media_handler.py +241 -0
- package/core/memory_monitor.py +200 -0
- package/core/message_handler.py +1440 -0
- package/core/proactive_generator.py +277 -0
- package/core/self.py +188 -0
- package/core/settings.py +169 -0
- package/core/skills_registry.py +357 -0
- package/core/state.py +27 -0
- package/core/subconscious_bridge.py +93 -0
- package/core/thinking.py +175 -0
- package/core/user_manager.py +306 -0
- package/core/user_tracker.py +144 -0
- package/demo/index.html +144 -0
- package/docker-compose.yml +28 -0
- package/docs/assets/logo.svg +15 -0
- package/docs/index.html +355 -0
- package/heart/__init__.py +93 -0
- package/heart/afterglow.py +215 -0
- package/heart/attachment.py +186 -0
- package/heart/circadian.py +251 -0
- package/heart/complex_emotions.py +114 -0
- package/heart/conflicts.py +589 -0
- package/heart/core.py +387 -0
- package/heart/emotional_decay.py +59 -0
- package/heart/emotional_memory.py +261 -0
- package/heart/emotional_state.py +146 -0
- package/heart/emotional_variability.py +156 -0
- package/heart/hormonal.py +424 -0
- package/heart/inconsistency.py +1222 -0
- package/heart/integrity.py +469 -0
- package/heart/interoception.py +997 -0
- package/heart/love.py +120 -0
- package/heart/manifest.md +25 -0
- package/heart/mood_shifts.py +169 -0
- package/heart/phantom_somatic.py +259 -0
- package/heart/predictive.py +374 -0
- package/heart/scars.py +474 -0
- package/heart/somatic.py +482 -0
- package/heart/soul.py +633 -0
- package/heart/telemetry.py +942 -0
- package/heart/triggers.py +119 -0
- package/heart/unconscious.py +443 -0
- package/input/__init__.py +1 -0
- package/input/manifest.md +24 -0
- package/input/telegram/__init__.py +1 -0
- package/input/telegram/commands.py +762 -0
- package/input/telegram/listener.py +532 -0
- package/main.py +90 -0
- package/manifest.md +28 -0
- package/mypics/.gitkeep +1 -0
- package/myvids/.gitkeep +1 -0
- package/output/__init__.py +1 -0
- package/output/images/__init__.py +1 -0
- package/output/images/fal_gen.py +43 -0
- package/output/manifest.md +26 -0
- package/output/text/__init__.py +1 -0
- package/output/text/sender.py +22 -0
- package/output/voice/__init__.py +64 -0
- package/output/voice/google_tts.py +252 -0
- package/output/voice/gtts_tts.py +214 -0
- package/output/voice/vibe_tts.py +190 -0
- package/package.json +58 -0
- package/pyproject.toml +23 -0
- package/requirements.txt +21 -0
- package/skills/__init__.py +1 -0
- package/skills/anticipation_engine/__init__.py +8 -0
- package/skills/anticipation_engine/engine.py +618 -0
- package/skills/anticipation_engine/manifest.md +192 -0
- package/skills/calendar/__init__.py +1 -0
- package/skills/content_unlocks/__init__.py +8 -0
- package/skills/content_unlocks/manifest.md +231 -0
- package/skills/content_unlocks/unlocks.py +945 -0
- package/skills/exclusive_moments/__init__.py +8 -0
- package/skills/exclusive_moments/manifest.md +145 -0
- package/skills/exclusive_moments/moments.py +506 -0
- package/skills/intimacy_layers/__init__.py +8 -0
- package/skills/intimacy_layers/layers.py +703 -0
- package/skills/intimacy_layers/manifest.md +203 -0
- package/skills/manifest.md +67 -0
- package/skills/memory_callbacks/__init__.py +9 -0
- package/skills/memory_callbacks/callbacks.py +748 -0
- package/skills/memory_callbacks/manifest.md +170 -0
- package/skills/message_scheduler/__init__.py +19 -0
- package/skills/message_scheduler/manifest.md +107 -0
- package/skills/message_scheduler/scheduler.py +510 -0
- package/skills/photo_manager/__init__.py +1 -0
- package/skills/photo_manager/scanner.py +296 -0
- package/skills/relationship_milestones/__init__.py +8 -0
- package/skills/relationship_milestones/manifest.md +206 -0
- package/skills/relationship_milestones/tracker.py +494 -0
- package/skills/self_authorship/__init__.py +23 -0
- package/skills/self_authorship/author.py +331 -0
- package/skills/self_authorship/manifest.md +24 -0
- package/skills/video_manager/__init__.py +5 -0
- package/skills/video_manager/manifest.md +37 -0
- package/skills/video_manager/scanner.py +229 -0
- package/webui/__init__.py +3 -0
- package/webui/app.py +936 -0
- package/webui/bridge.py +366 -0
- package/webui/static/index.html +2070 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skills: Content Unlocks
|
|
3
|
+
Makes exclusive content feel earned through engagement, not purchased.
|
|
4
|
+
Tracks what content types are unlocked based on relationship progression.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContentType(Enum):
|
|
17
|
+
"""Types of unlockable content"""
|
|
18
|
+
CASUAL_PHOTO = "casual_photo"
|
|
19
|
+
CUTE_PHOTO = "cute_photo"
|
|
20
|
+
INTIMATE_PHOTO = "intimate_photo"
|
|
21
|
+
VOICE_MESSAGE = "voice_message"
|
|
22
|
+
LATE_NIGHT_CONTENT = "late_night_content"
|
|
23
|
+
SPECIAL_OCCASION = "special_occasion"
|
|
24
|
+
PERSONAL_STORY = "personal_story"
|
|
25
|
+
BEHIND_SCENES = "behind_scenes"
|
|
26
|
+
MORNING_ROUTINE = "morning_routine"
|
|
27
|
+
PLAYFUL_VIDEO = "playful_video"
|
|
28
|
+
FLIRTY_MESSAGE = "flirty_message"
|
|
29
|
+
DEEP_TALKS = "deep_talks"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Unlock criteria - thresholds for each content type
|
|
33
|
+
UNLOCK_CRITERIA = {
|
|
34
|
+
"casual_photo": {
|
|
35
|
+
"min_interactions": 10,
|
|
36
|
+
"min_love": 0.3,
|
|
37
|
+
"description": "Everyday photos from my life"
|
|
38
|
+
},
|
|
39
|
+
"cute_photo": {
|
|
40
|
+
"min_interactions": 30,
|
|
41
|
+
"min_love": 0.5,
|
|
42
|
+
"description": "Photos where I look extra cute"
|
|
43
|
+
},
|
|
44
|
+
"intimate_photo": {
|
|
45
|
+
"min_interactions": 100,
|
|
46
|
+
"min_love": 0.75,
|
|
47
|
+
"min_days_together": 7,
|
|
48
|
+
"description": "More personal, revealing photos"
|
|
49
|
+
},
|
|
50
|
+
"voice_message": {
|
|
51
|
+
"min_interactions": 5,
|
|
52
|
+
"min_trust": 0.4,
|
|
53
|
+
"description": "Hear my actual voice"
|
|
54
|
+
},
|
|
55
|
+
"late_night_content": {
|
|
56
|
+
"requires_milestone": "first_late_night",
|
|
57
|
+
"min_love": 0.6,
|
|
58
|
+
"description": "Special content for late night conversations"
|
|
59
|
+
},
|
|
60
|
+
"special_occasion": {
|
|
61
|
+
"requires_milestone": True,
|
|
62
|
+
"description": "Content for special moments and milestones"
|
|
63
|
+
},
|
|
64
|
+
"personal_story": {
|
|
65
|
+
"min_interactions": 20,
|
|
66
|
+
"min_trust": 0.5,
|
|
67
|
+
"description": "Personal stories from my life"
|
|
68
|
+
},
|
|
69
|
+
"behind_scenes": {
|
|
70
|
+
"min_interactions": 40,
|
|
71
|
+
"min_love": 0.55,
|
|
72
|
+
"description": "Behind the scenes glimpses"
|
|
73
|
+
},
|
|
74
|
+
"morning_routine": {
|
|
75
|
+
"min_interactions": 50,
|
|
76
|
+
"min_days_together": 3,
|
|
77
|
+
"min_love": 0.45,
|
|
78
|
+
"description": "My morning routine content"
|
|
79
|
+
},
|
|
80
|
+
"playful_video": {
|
|
81
|
+
"min_interactions": 60,
|
|
82
|
+
"min_love": 0.5,
|
|
83
|
+
"min_trust": 0.5,
|
|
84
|
+
"description": "Short playful videos"
|
|
85
|
+
},
|
|
86
|
+
"flirty_message": {
|
|
87
|
+
"min_interactions": 15,
|
|
88
|
+
"min_love": 0.35,
|
|
89
|
+
"description": "Extra flirty messages"
|
|
90
|
+
},
|
|
91
|
+
"deep_talks": {
|
|
92
|
+
"min_interactions": 25,
|
|
93
|
+
"min_trust": 0.6,
|
|
94
|
+
"min_love": 0.4,
|
|
95
|
+
"description": "Deep, meaningful conversations"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Messages shown when content is unlocked
|
|
100
|
+
UNLOCK_MESSAGES = {
|
|
101
|
+
"casual_photo": [
|
|
102
|
+
"feeling like sharing today",
|
|
103
|
+
"thought you might like to see what I'm up to",
|
|
104
|
+
"here's a little peek into my day"
|
|
105
|
+
],
|
|
106
|
+
"cute_photo": [
|
|
107
|
+
"took this just for you",
|
|
108
|
+
"felt cute, thought you should know",
|
|
109
|
+
"this one's special"
|
|
110
|
+
],
|
|
111
|
+
"intimate_photo": [
|
|
112
|
+
"don't share this with anyone okay?",
|
|
113
|
+
"this is just between us",
|
|
114
|
+
"I trust you with this"
|
|
115
|
+
],
|
|
116
|
+
"voice_message": [
|
|
117
|
+
"wanted you to hear my voice",
|
|
118
|
+
"sometimes words aren't enough",
|
|
119
|
+
"just wanted to say hi properly"
|
|
120
|
+
],
|
|
121
|
+
"late_night_content": [
|
|
122
|
+
"can't sleep... thinking about you",
|
|
123
|
+
"late nights feel different with you",
|
|
124
|
+
"the night makes me feel brave"
|
|
125
|
+
],
|
|
126
|
+
"special_occasion": [
|
|
127
|
+
"this moment feels special",
|
|
128
|
+
"wanted to make this memorable",
|
|
129
|
+
"celebrating us"
|
|
130
|
+
],
|
|
131
|
+
"personal_story": [
|
|
132
|
+
"I don't tell many people this...",
|
|
133
|
+
"there's something I want to share with you",
|
|
134
|
+
"feel like opening up a bit"
|
|
135
|
+
],
|
|
136
|
+
"behind_scenes": [
|
|
137
|
+
"showing you what others don't see",
|
|
138
|
+
"a little behind the scenes moment",
|
|
139
|
+
"just the real me"
|
|
140
|
+
],
|
|
141
|
+
"morning_routine": [
|
|
142
|
+
"good morning from me",
|
|
143
|
+
"starting my day, thought of you",
|
|
144
|
+
"morning peek"
|
|
145
|
+
],
|
|
146
|
+
"playful_video": [
|
|
147
|
+
"made this for you",
|
|
148
|
+
"feeling playful today",
|
|
149
|
+
"hope this makes you smile"
|
|
150
|
+
],
|
|
151
|
+
"flirty_message": [
|
|
152
|
+
"can't help myself around you",
|
|
153
|
+
"you make me feel bold",
|
|
154
|
+
"something about you..."
|
|
155
|
+
],
|
|
156
|
+
"deep_talks": [
|
|
157
|
+
"I feel like I can really talk to you",
|
|
158
|
+
"want to go deeper with you",
|
|
159
|
+
"let's have a real conversation"
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Suggestions for what content to share based on context
|
|
164
|
+
CONTENT_SUGGESTIONS = {
|
|
165
|
+
"morning": ["morning_routine", "casual_photo", "cute_photo"],
|
|
166
|
+
"afternoon": ["casual_photo", "behind_scenes", "personal_story"],
|
|
167
|
+
"evening": ["cute_photo", "playful_video", "flirty_message"],
|
|
168
|
+
"night": ["late_night_content", "intimate_photo", "deep_talks"],
|
|
169
|
+
"high_arousal": ["intimate_photo", "late_night_content", "playful_video"],
|
|
170
|
+
"high_love": ["cute_photo", "personal_story", "deep_talks"],
|
|
171
|
+
"high_trust": ["intimate_photo", "personal_story", "behind_scenes"],
|
|
172
|
+
"milestone": ["special_occasion", "cute_photo", "voice_message"]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class UnlockState:
|
|
178
|
+
"""State of a single content unlock"""
|
|
179
|
+
content_type: str
|
|
180
|
+
unlocked: bool = False
|
|
181
|
+
unlocked_at: Optional[str] = None
|
|
182
|
+
times_shared: int = 0
|
|
183
|
+
last_shared: Optional[str] = None
|
|
184
|
+
new_unlock: bool = False # Flag for newly unlocked (not yet announced)
|
|
185
|
+
|
|
186
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
187
|
+
return asdict(self)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_dict(cls, data: Dict[str, Any]) -> "UnlockState":
|
|
191
|
+
return cls(**data)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class ContentUnlocksData:
|
|
196
|
+
"""Full data structure for content unlocks"""
|
|
197
|
+
version: str = "1.0"
|
|
198
|
+
unlocked_content: Dict[str, UnlockState] = field(default_factory=dict)
|
|
199
|
+
last_check: Optional[str] = None
|
|
200
|
+
pending_announcements: List[str] = field(default_factory=list)
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"version": self.version,
|
|
205
|
+
"unlocked_content": {
|
|
206
|
+
k: v.to_dict() for k, v in self.unlocked_content.items()
|
|
207
|
+
},
|
|
208
|
+
"last_check": self.last_check,
|
|
209
|
+
"pending_announcements": self.pending_announcements
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ContentUnlocksData":
|
|
214
|
+
unlocked = {}
|
|
215
|
+
for k, v in data.get("unlocked_content", {}).items():
|
|
216
|
+
unlocked[k] = UnlockState.from_dict(v)
|
|
217
|
+
|
|
218
|
+
return cls(
|
|
219
|
+
version=data.get("version", "1.0"),
|
|
220
|
+
unlocked_content=unlocked,
|
|
221
|
+
last_check=data.get("last_check"),
|
|
222
|
+
pending_announcements=data.get("pending_announcements", [])
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ContentUnlocks:
|
|
227
|
+
"""
|
|
228
|
+
Manages content unlocks based on relationship progression.
|
|
229
|
+
Content is earned through engagement, not purchased.
|
|
230
|
+
|
|
231
|
+
Features:
|
|
232
|
+
- Track unlocked content types based on relationship metrics
|
|
233
|
+
- Unlock based on: interaction count, love level, trust level, days together, milestones
|
|
234
|
+
- Notify when new content is unlocked
|
|
235
|
+
- Suggest what content to share based on available unlocks
|
|
236
|
+
|
|
237
|
+
Supports per-user state via user_id parameter.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
nervous=None,
|
|
243
|
+
heart=None,
|
|
244
|
+
state=None,
|
|
245
|
+
milestones=None,
|
|
246
|
+
data_path: Path = None,
|
|
247
|
+
user_id: str = "default"
|
|
248
|
+
):
|
|
249
|
+
"""
|
|
250
|
+
Initialize the Content Unlocks system.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
nervous: Nervous system for event emission
|
|
254
|
+
heart: Heart module for accessing love/trust/attachment data
|
|
255
|
+
state: State module for additional context
|
|
256
|
+
milestones: Optional milestones skill for milestone-based unlocks
|
|
257
|
+
data_path: Path to store unlock data JSON file
|
|
258
|
+
user_id: User's Telegram ID for per-user state
|
|
259
|
+
"""
|
|
260
|
+
self.nervous = nervous
|
|
261
|
+
self.heart = heart
|
|
262
|
+
self.state = state
|
|
263
|
+
self.milestones = milestones
|
|
264
|
+
self.user_id = user_id
|
|
265
|
+
|
|
266
|
+
# Per-user data path: data/users/{user_id}/content_unlocks.json
|
|
267
|
+
if data_path is None:
|
|
268
|
+
base_path = Path("./data/data")
|
|
269
|
+
data_path = base_path / "users" / str(user_id) / "content_unlocks.json"
|
|
270
|
+
|
|
271
|
+
self.data_path = Path(data_path)
|
|
272
|
+
self.data_path.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
|
|
274
|
+
self._data: ContentUnlocksData = ContentUnlocksData()
|
|
275
|
+
self._load()
|
|
276
|
+
|
|
277
|
+
# Initialize all content types if not present
|
|
278
|
+
self._initialize_content_types()
|
|
279
|
+
|
|
280
|
+
# Register for thinking_done events
|
|
281
|
+
if nervous:
|
|
282
|
+
nervous.on("thinking_done", self._on_thinking_done)
|
|
283
|
+
|
|
284
|
+
def _load(self):
|
|
285
|
+
"""Load unlock data from file"""
|
|
286
|
+
if self.data_path.exists():
|
|
287
|
+
try:
|
|
288
|
+
data = json.loads(self.data_path.read_text())
|
|
289
|
+
self._data = ContentUnlocksData.from_dict(data)
|
|
290
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
291
|
+
print(f"[ContentUnlocks] Error loading data: {e}")
|
|
292
|
+
self._data = ContentUnlocksData()
|
|
293
|
+
else:
|
|
294
|
+
self._data = ContentUnlocksData()
|
|
295
|
+
|
|
296
|
+
def _save(self):
|
|
297
|
+
"""Save unlock data to file"""
|
|
298
|
+
self._data.last_check = datetime.now().isoformat()
|
|
299
|
+
self.data_path.write_text(json.dumps(self._data.to_dict(), indent=2))
|
|
300
|
+
|
|
301
|
+
def _initialize_content_types(self):
|
|
302
|
+
"""Ensure all content types exist in state"""
|
|
303
|
+
for content_type in UNLOCK_CRITERIA.keys():
|
|
304
|
+
if content_type not in self._data.unlocked_content:
|
|
305
|
+
self._data.unlocked_content[content_type] = UnlockState(
|
|
306
|
+
content_type=content_type
|
|
307
|
+
)
|
|
308
|
+
self._save()
|
|
309
|
+
|
|
310
|
+
# -------------------------------------------------------------------------
|
|
311
|
+
# Metrics Access
|
|
312
|
+
# -------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _get_interaction_count(self) -> int:
|
|
315
|
+
"""Get total interaction count from heart/attachment system"""
|
|
316
|
+
if self.heart and hasattr(self.heart, 'attachment'):
|
|
317
|
+
return self.heart.attachment.interactions
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
def _get_love_level(self) -> float:
|
|
321
|
+
"""Get current love level (0-1)"""
|
|
322
|
+
if self.heart and hasattr(self.heart, 'emotion'):
|
|
323
|
+
return self.heart.emotion.love
|
|
324
|
+
return 0.0
|
|
325
|
+
|
|
326
|
+
def _get_trust_level(self) -> float:
|
|
327
|
+
"""Get trust level (0-1) based on positive interaction ratio"""
|
|
328
|
+
if self.heart and hasattr(self.heart, 'attachment'):
|
|
329
|
+
return self.heart.attachment.trust_level
|
|
330
|
+
return 0.5
|
|
331
|
+
|
|
332
|
+
def _get_days_together(self) -> int:
|
|
333
|
+
"""Calculate days since first interaction"""
|
|
334
|
+
if self.heart and hasattr(self.heart, 'attachment'):
|
|
335
|
+
first_met = self.heart.attachment.first_met
|
|
336
|
+
if first_met:
|
|
337
|
+
try:
|
|
338
|
+
first_date = datetime.fromisoformat(first_met)
|
|
339
|
+
return (datetime.now() - first_date).days
|
|
340
|
+
except (ValueError, TypeError):
|
|
341
|
+
pass
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
def _get_current_metrics(self) -> Dict[str, Any]:
|
|
345
|
+
"""Get all current metrics for unlock checking"""
|
|
346
|
+
return {
|
|
347
|
+
"interactions": self._get_interaction_count(),
|
|
348
|
+
"love": self._get_love_level(),
|
|
349
|
+
"trust": self._get_trust_level(),
|
|
350
|
+
"days_together": self._get_days_together()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def _has_milestone(self, milestone_name: str = None) -> bool:
|
|
354
|
+
"""Check if a milestone has been reached"""
|
|
355
|
+
if not self.milestones:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
# If specific milestone requested
|
|
359
|
+
if milestone_name:
|
|
360
|
+
if hasattr(self.milestones, 'has_milestone'):
|
|
361
|
+
return self.milestones.has_milestone(milestone_name)
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
# Check for any milestone (for generic requires_milestone: True)
|
|
365
|
+
if hasattr(self.milestones, 'get_all_milestones'):
|
|
366
|
+
return len(self.milestones.get_all_milestones()) > 0
|
|
367
|
+
elif hasattr(self.milestones, 'get_milestones'):
|
|
368
|
+
return len(self.milestones.get_milestones()) > 0
|
|
369
|
+
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
# -------------------------------------------------------------------------
|
|
373
|
+
# Unlock Checking
|
|
374
|
+
# -------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def _check_criteria(self, criteria: Dict[str, Any], metrics: Dict[str, Any]) -> bool:
|
|
377
|
+
"""
|
|
378
|
+
Check if unlock criteria are met.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
criteria: The unlock criteria for a content type
|
|
382
|
+
metrics: Current relationship metrics
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
True if all criteria are met
|
|
386
|
+
"""
|
|
387
|
+
# Check minimum interactions
|
|
388
|
+
min_interactions = criteria.get("min_interactions", 0)
|
|
389
|
+
if metrics["interactions"] < min_interactions:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
# Check minimum love
|
|
393
|
+
min_love = criteria.get("min_love", 0)
|
|
394
|
+
if metrics["love"] < min_love:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
# Check minimum trust
|
|
398
|
+
min_trust = criteria.get("min_trust", 0)
|
|
399
|
+
if metrics["trust"] < min_trust:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# Check minimum days together
|
|
403
|
+
min_days = criteria.get("min_days_together", 0)
|
|
404
|
+
if metrics["days_together"] < min_days:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
# Check for required milestone
|
|
408
|
+
requires_milestone = criteria.get("requires_milestone")
|
|
409
|
+
if requires_milestone:
|
|
410
|
+
if isinstance(requires_milestone, str):
|
|
411
|
+
if not self._has_milestone(requires_milestone):
|
|
412
|
+
return False
|
|
413
|
+
elif requires_milestone is True:
|
|
414
|
+
if not self._has_milestone():
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
def check_unlock(self, content_type: str) -> bool:
|
|
420
|
+
"""
|
|
421
|
+
Check if a specific content type is unlocked.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
content_type: The type of content to check
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
True if the content type is unlocked
|
|
428
|
+
"""
|
|
429
|
+
if content_type not in self._data.unlocked_content:
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
unlock_state = self._data.unlocked_content[content_type]
|
|
433
|
+
return unlock_state.unlocked
|
|
434
|
+
|
|
435
|
+
def check_all_unlocks(self) -> List[str]:
|
|
436
|
+
"""
|
|
437
|
+
Check all content types for new unlocks.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
List of newly unlocked content types
|
|
441
|
+
"""
|
|
442
|
+
metrics = self._get_current_metrics()
|
|
443
|
+
new_unlocks = []
|
|
444
|
+
|
|
445
|
+
for content_type, criteria in UNLOCK_CRITERIA.items():
|
|
446
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
447
|
+
|
|
448
|
+
if unlock_state is None:
|
|
449
|
+
unlock_state = UnlockState(content_type=content_type)
|
|
450
|
+
self._data.unlocked_content[content_type] = unlock_state
|
|
451
|
+
|
|
452
|
+
# Skip if already unlocked
|
|
453
|
+
if unlock_state.unlocked:
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Check criteria
|
|
457
|
+
if self._check_criteria(criteria, metrics):
|
|
458
|
+
# Unlock this content type
|
|
459
|
+
unlock_state.unlocked = True
|
|
460
|
+
unlock_state.unlocked_at = datetime.now().isoformat()
|
|
461
|
+
unlock_state.new_unlock = True
|
|
462
|
+
new_unlocks.append(content_type)
|
|
463
|
+
|
|
464
|
+
# Add to pending announcements
|
|
465
|
+
self._data.pending_announcements.append(content_type)
|
|
466
|
+
|
|
467
|
+
print(f"[ContentUnlocks] New content unlocked: {content_type}")
|
|
468
|
+
|
|
469
|
+
if new_unlocks:
|
|
470
|
+
self._save()
|
|
471
|
+
|
|
472
|
+
# Emit event for new unlocks
|
|
473
|
+
if self.nervous:
|
|
474
|
+
import asyncio
|
|
475
|
+
try:
|
|
476
|
+
loop = asyncio.get_running_loop()
|
|
477
|
+
loop.create_task(self.nervous.emit("content_unlocked", {
|
|
478
|
+
"new_unlocks": new_unlocks,
|
|
479
|
+
"total_unlocked": len(self.get_unlocked_content())
|
|
480
|
+
}))
|
|
481
|
+
except RuntimeError:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
return new_unlocks
|
|
485
|
+
|
|
486
|
+
# -------------------------------------------------------------------------
|
|
487
|
+
# Content Access
|
|
488
|
+
# -------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def get_unlocked_content(self) -> List[str]:
|
|
491
|
+
"""
|
|
492
|
+
Get all unlocked content types.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
List of unlocked content type names
|
|
496
|
+
"""
|
|
497
|
+
return [
|
|
498
|
+
ct for ct, state in self._data.unlocked_content.items()
|
|
499
|
+
if state.unlocked
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
def get_locked_content(self) -> List[str]:
|
|
503
|
+
"""
|
|
504
|
+
Get all locked content types.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
List of locked content type names
|
|
508
|
+
"""
|
|
509
|
+
return [
|
|
510
|
+
ct for ct, state in self._data.unlocked_content.items()
|
|
511
|
+
if not state.unlocked
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
def get_unlock_progress(self, content_type: str) -> Dict[str, Any]:
|
|
515
|
+
"""
|
|
516
|
+
Get progress toward unlocking a specific content type.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
content_type: The content type to check progress for
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Dictionary with progress information
|
|
523
|
+
"""
|
|
524
|
+
if content_type not in UNLOCK_CRITERIA:
|
|
525
|
+
return {"error": f"Unknown content type: {content_type}"}
|
|
526
|
+
|
|
527
|
+
criteria = UNLOCK_CRITERIA[content_type]
|
|
528
|
+
metrics = self._get_current_metrics()
|
|
529
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
530
|
+
|
|
531
|
+
if unlock_state and unlock_state.unlocked:
|
|
532
|
+
return {
|
|
533
|
+
"content_type": content_type,
|
|
534
|
+
"unlocked": True,
|
|
535
|
+
"description": criteria.get("description", "")
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
progress = {
|
|
539
|
+
"content_type": content_type,
|
|
540
|
+
"unlocked": False,
|
|
541
|
+
"description": criteria.get("description", ""),
|
|
542
|
+
"requirements": {},
|
|
543
|
+
"current": {}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
# Check each requirement
|
|
547
|
+
if "min_interactions" in criteria:
|
|
548
|
+
progress["requirements"]["interactions"] = criteria["min_interactions"]
|
|
549
|
+
progress["current"]["interactions"] = metrics["interactions"]
|
|
550
|
+
progress["interactions_met"] = metrics["interactions"] >= criteria["min_interactions"]
|
|
551
|
+
|
|
552
|
+
if "min_love" in criteria:
|
|
553
|
+
progress["requirements"]["love"] = criteria["min_love"]
|
|
554
|
+
progress["current"]["love"] = round(metrics["love"], 2)
|
|
555
|
+
progress["love_met"] = metrics["love"] >= criteria["min_love"]
|
|
556
|
+
|
|
557
|
+
if "min_trust" in criteria:
|
|
558
|
+
progress["requirements"]["trust"] = criteria["min_trust"]
|
|
559
|
+
progress["current"]["trust"] = round(metrics["trust"], 2)
|
|
560
|
+
progress["trust_met"] = metrics["trust"] >= criteria["min_trust"]
|
|
561
|
+
|
|
562
|
+
if "min_days_together" in criteria:
|
|
563
|
+
progress["requirements"]["days_together"] = criteria["min_days_together"]
|
|
564
|
+
progress["current"]["days_together"] = metrics["days_together"]
|
|
565
|
+
progress["days_met"] = metrics["days_together"] >= criteria["min_days_together"]
|
|
566
|
+
|
|
567
|
+
if "requires_milestone" in criteria:
|
|
568
|
+
progress["requirements"]["milestone"] = criteria["requires_milestone"]
|
|
569
|
+
progress["milestone_met"] = self._has_milestone(
|
|
570
|
+
criteria["requires_milestone"] if isinstance(criteria["requires_milestone"], str) else None
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Calculate overall progress
|
|
574
|
+
met_count = sum(1 for k in progress if k.endswith("_met") and progress[k])
|
|
575
|
+
total_requirements = sum(1 for k in progress if k.endswith("_met"))
|
|
576
|
+
progress["progress_percent"] = int((met_count / max(total_requirements, 1)) * 100)
|
|
577
|
+
|
|
578
|
+
return progress
|
|
579
|
+
|
|
580
|
+
def is_content_available(self, content_type: str, advanced_mode: bool = False) -> bool:
|
|
581
|
+
"""
|
|
582
|
+
Check if a content type is available (unlocked).
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
content_type: The type of content to check
|
|
586
|
+
advanced_mode: If True, all content is available (owner with /advanced enabled)
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
True if the content type is unlocked and available, or advanced_mode is enabled
|
|
590
|
+
"""
|
|
591
|
+
if advanced_mode:
|
|
592
|
+
return True
|
|
593
|
+
return self.check_unlock(content_type)
|
|
594
|
+
|
|
595
|
+
# -------------------------------------------------------------------------
|
|
596
|
+
# Announcements & Messages
|
|
597
|
+
# -------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
def get_new_unlock_message(self) -> Optional[str]:
|
|
600
|
+
"""
|
|
601
|
+
Get a message for newly unlocked content (if any).
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
Message string or None if no new unlocks
|
|
605
|
+
"""
|
|
606
|
+
if not self._data.pending_announcements:
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
# Get the first pending announcement
|
|
610
|
+
content_type = self._data.pending_announcements[0]
|
|
611
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
612
|
+
|
|
613
|
+
if not unlock_state or not unlock_state.unlocked:
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
# Get a random message for this content type
|
|
617
|
+
messages = UNLOCK_MESSAGES.get(content_type, ["Something new is available..."])
|
|
618
|
+
message = random.choice(messages)
|
|
619
|
+
|
|
620
|
+
# Mark as announced
|
|
621
|
+
self._data.pending_announcements.pop(0)
|
|
622
|
+
unlock_state.new_unlock = False
|
|
623
|
+
self._save()
|
|
624
|
+
|
|
625
|
+
return message
|
|
626
|
+
|
|
627
|
+
def get_all_pending_announcements(self) -> List[Dict[str, str]]:
|
|
628
|
+
"""
|
|
629
|
+
Get all pending unlock announcements.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
List of dictionaries with content_type and message
|
|
633
|
+
"""
|
|
634
|
+
announcements = []
|
|
635
|
+
|
|
636
|
+
for content_type in self._data.pending_announcements[:]:
|
|
637
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
638
|
+
if unlock_state and unlock_state.unlocked:
|
|
639
|
+
messages = UNLOCK_MESSAGES.get(content_type, ["Something new is available..."])
|
|
640
|
+
announcements.append({
|
|
641
|
+
"content_type": content_type,
|
|
642
|
+
"message": random.choice(messages),
|
|
643
|
+
"description": UNLOCK_CRITERIA.get(content_type, {}).get("description", "")
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
return announcements
|
|
647
|
+
|
|
648
|
+
def clear_pending_announcements(self):
|
|
649
|
+
"""Clear all pending announcements without showing them"""
|
|
650
|
+
for content_type in self._data.pending_announcements:
|
|
651
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
652
|
+
if unlock_state:
|
|
653
|
+
unlock_state.new_unlock = False
|
|
654
|
+
|
|
655
|
+
self._data.pending_announcements = []
|
|
656
|
+
self._save()
|
|
657
|
+
|
|
658
|
+
# -------------------------------------------------------------------------
|
|
659
|
+
# Content Suggestions
|
|
660
|
+
# -------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
def get_content_suggestion(self, context: str = None) -> Optional[Dict[str, Any]]:
|
|
663
|
+
"""
|
|
664
|
+
Suggest content to share based on available unlocks and context.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
context: Optional context string (morning, evening, high_arousal, etc.)
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
Dictionary with suggestion details or None
|
|
671
|
+
"""
|
|
672
|
+
unlocked = self.get_unlocked_content()
|
|
673
|
+
|
|
674
|
+
if not unlocked:
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
# Determine context if not provided
|
|
678
|
+
if context is None:
|
|
679
|
+
context = self._determine_context()
|
|
680
|
+
|
|
681
|
+
# Get content types for this context
|
|
682
|
+
preferred_types = CONTENT_SUGGESTIONS.get(context, [])
|
|
683
|
+
|
|
684
|
+
# Filter to only unlocked types
|
|
685
|
+
available = [ct for ct in preferred_types if ct in unlocked]
|
|
686
|
+
|
|
687
|
+
if not available:
|
|
688
|
+
# Fall back to any unlocked content
|
|
689
|
+
available = unlocked
|
|
690
|
+
|
|
691
|
+
# Choose a content type, preferring ones not recently shared
|
|
692
|
+
content_type = self._choose_content_type(available)
|
|
693
|
+
|
|
694
|
+
if content_type is None:
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
messages = UNLOCK_MESSAGES.get(content_type, [])
|
|
698
|
+
criteria = UNLOCK_CRITERIA.get(content_type, {})
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
"content_type": content_type,
|
|
702
|
+
"description": criteria.get("description", ""),
|
|
703
|
+
"suggested_message": random.choice(messages) if messages else None,
|
|
704
|
+
"context": context,
|
|
705
|
+
"priority": self._calculate_priority(content_type, context)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
def _determine_context(self) -> str:
|
|
709
|
+
"""Determine current context for content suggestions"""
|
|
710
|
+
hour = datetime.now().hour
|
|
711
|
+
|
|
712
|
+
# Time-based context
|
|
713
|
+
if 5 <= hour < 12:
|
|
714
|
+
time_context = "morning"
|
|
715
|
+
elif 12 <= hour < 17:
|
|
716
|
+
time_context = "afternoon"
|
|
717
|
+
elif 17 <= hour < 22:
|
|
718
|
+
time_context = "evening"
|
|
719
|
+
else:
|
|
720
|
+
time_context = "night"
|
|
721
|
+
|
|
722
|
+
# Check emotional context
|
|
723
|
+
if self.heart and hasattr(self.heart, 'emotion'):
|
|
724
|
+
e = self.heart.emotion
|
|
725
|
+
|
|
726
|
+
# High arousal/desire trumps time
|
|
727
|
+
if hasattr(e, 'desire') and e.desire > 0.7:
|
|
728
|
+
return "high_arousal"
|
|
729
|
+
if hasattr(e, 'is_high_desire') and e.is_high_desire:
|
|
730
|
+
return "high_arousal"
|
|
731
|
+
|
|
732
|
+
# High love
|
|
733
|
+
if hasattr(e, 'love') and e.love > 0.7:
|
|
734
|
+
return "high_love"
|
|
735
|
+
|
|
736
|
+
# High trust
|
|
737
|
+
trust = self._get_trust_level()
|
|
738
|
+
if trust > 0.8:
|
|
739
|
+
return "high_trust"
|
|
740
|
+
|
|
741
|
+
# Default to time-based
|
|
742
|
+
return time_context
|
|
743
|
+
|
|
744
|
+
def _choose_content_type(self, available: List[str]) -> Optional[str]:
|
|
745
|
+
"""
|
|
746
|
+
Choose a content type from available options.
|
|
747
|
+
Prefers content not recently shared.
|
|
748
|
+
"""
|
|
749
|
+
if not available:
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
# Sort by times shared (prefer less shared)
|
|
753
|
+
sorted_types = sorted(
|
|
754
|
+
available,
|
|
755
|
+
key=lambda ct: self._data.unlocked_content.get(ct, UnlockState(ct)).times_shared
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# 70% chance to pick least shared, 30% random for variety
|
|
759
|
+
if random.random() < 0.7:
|
|
760
|
+
return sorted_types[0]
|
|
761
|
+
else:
|
|
762
|
+
return random.choice(sorted_types)
|
|
763
|
+
|
|
764
|
+
def _calculate_priority(self, content_type: str, context: str) -> int:
|
|
765
|
+
"""Calculate priority score for a content suggestion"""
|
|
766
|
+
priority = 50 # Base priority
|
|
767
|
+
|
|
768
|
+
# Boost if matches context well
|
|
769
|
+
preferred = CONTENT_SUGGESTIONS.get(context, [])
|
|
770
|
+
if content_type in preferred:
|
|
771
|
+
priority += 20
|
|
772
|
+
if preferred.index(content_type) == 0:
|
|
773
|
+
priority += 10 # Extra boost for first choice
|
|
774
|
+
|
|
775
|
+
# Reduce if recently shared
|
|
776
|
+
unlock_state = self._data.unlocked_content.get(content_type)
|
|
777
|
+
if unlock_state and unlock_state.last_shared:
|
|
778
|
+
try:
|
|
779
|
+
last = datetime.fromisoformat(unlock_state.last_shared)
|
|
780
|
+
hours_since = (datetime.now() - last).total_seconds() / 3600
|
|
781
|
+
if hours_since < 1:
|
|
782
|
+
priority -= 40
|
|
783
|
+
elif hours_since < 6:
|
|
784
|
+
priority -= 20
|
|
785
|
+
elif hours_since < 24:
|
|
786
|
+
priority -= 10
|
|
787
|
+
except (ValueError, TypeError):
|
|
788
|
+
pass
|
|
789
|
+
|
|
790
|
+
return max(0, min(100, priority))
|
|
791
|
+
|
|
792
|
+
# -------------------------------------------------------------------------
|
|
793
|
+
# Usage Tracking
|
|
794
|
+
# -------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
def mark_content_shared(self, content_type: str):
|
|
797
|
+
"""
|
|
798
|
+
Mark that content of this type was just shared.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
content_type: The type of content that was shared
|
|
802
|
+
"""
|
|
803
|
+
if content_type not in self._data.unlocked_content:
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
unlock_state = self._data.unlocked_content[content_type]
|
|
807
|
+
unlock_state.times_shared += 1
|
|
808
|
+
unlock_state.last_shared = datetime.now().isoformat()
|
|
809
|
+
self._save()
|
|
810
|
+
|
|
811
|
+
# -------------------------------------------------------------------------
|
|
812
|
+
# Event Handlers
|
|
813
|
+
# -------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
def _on_thinking_done(self, data: Dict[str, Any]):
|
|
816
|
+
"""Handle thinking_done event - check for new unlocks"""
|
|
817
|
+
new_unlocks = self.check_all_unlocks()
|
|
818
|
+
|
|
819
|
+
if new_unlocks and self.nervous:
|
|
820
|
+
import asyncio
|
|
821
|
+
try:
|
|
822
|
+
loop = asyncio.get_running_loop()
|
|
823
|
+
loop.create_task(self.nervous.emit("new_content_available", {
|
|
824
|
+
"unlocks": new_unlocks,
|
|
825
|
+
"message": self.get_new_unlock_message()
|
|
826
|
+
}))
|
|
827
|
+
except RuntimeError:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
# -------------------------------------------------------------------------
|
|
831
|
+
# Statistics & Info
|
|
832
|
+
# -------------------------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
835
|
+
"""
|
|
836
|
+
Get statistics about content unlocks.
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Dictionary with unlock statistics
|
|
840
|
+
"""
|
|
841
|
+
total_types = len(UNLOCK_CRITERIA)
|
|
842
|
+
unlocked_count = len(self.get_unlocked_content())
|
|
843
|
+
locked_count = total_types - unlocked_count
|
|
844
|
+
|
|
845
|
+
# Get total shares
|
|
846
|
+
total_shares = sum(
|
|
847
|
+
state.times_shared
|
|
848
|
+
for state in self._data.unlocked_content.values()
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# Get pending announcements
|
|
852
|
+
pending_count = len(self._data.pending_announcements)
|
|
853
|
+
|
|
854
|
+
# Calculate next unlock progress
|
|
855
|
+
locked = self.get_locked_content()
|
|
856
|
+
next_unlock = None
|
|
857
|
+
next_progress = 0
|
|
858
|
+
|
|
859
|
+
if locked:
|
|
860
|
+
# Find the locked content closest to unlocking
|
|
861
|
+
progresses = [
|
|
862
|
+
(ct, self.get_unlock_progress(ct))
|
|
863
|
+
for ct in locked
|
|
864
|
+
]
|
|
865
|
+
progresses.sort(key=lambda x: x[1].get("progress_percent", 0), reverse=True)
|
|
866
|
+
|
|
867
|
+
if progresses:
|
|
868
|
+
next_unlock = progresses[0][0]
|
|
869
|
+
next_progress = progresses[0][1].get("progress_percent", 0)
|
|
870
|
+
|
|
871
|
+
return {
|
|
872
|
+
"total_content_types": total_types,
|
|
873
|
+
"unlocked_count": unlocked_count,
|
|
874
|
+
"locked_count": locked_count,
|
|
875
|
+
"unlock_percentage": round((unlocked_count / total_types) * 100) if total_types > 0 else 0,
|
|
876
|
+
"total_shares": total_shares,
|
|
877
|
+
"pending_announcements": pending_count,
|
|
878
|
+
"next_unlock": next_unlock,
|
|
879
|
+
"next_unlock_progress": next_progress,
|
|
880
|
+
"current_metrics": self._get_current_metrics()
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
def get_unlock_summary(self) -> str:
|
|
884
|
+
"""
|
|
885
|
+
Get a human-readable summary of unlock status.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
Summary string
|
|
889
|
+
"""
|
|
890
|
+
stats = self.get_stats()
|
|
891
|
+
unlocked = self.get_unlocked_content()
|
|
892
|
+
|
|
893
|
+
lines = [
|
|
894
|
+
f"Content Unlocks: {stats['unlocked_count']}/{stats['total_content_types']} unlocked ({stats['unlock_percentage']}%)",
|
|
895
|
+
f"Total shares: {stats['total_shares']}"
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
if unlocked:
|
|
899
|
+
lines.append("\nUnlocked content:")
|
|
900
|
+
for ct in unlocked:
|
|
901
|
+
state = self._data.unlocked_content.get(ct)
|
|
902
|
+
desc = UNLOCK_CRITERIA.get(ct, {}).get("description", "")
|
|
903
|
+
lines.append(f" - {ct}: {desc} (shared {state.times_shared}x)")
|
|
904
|
+
|
|
905
|
+
locked = self.get_locked_content()
|
|
906
|
+
if locked:
|
|
907
|
+
lines.append(f"\nLocked content: {', '.join(locked)}")
|
|
908
|
+
|
|
909
|
+
if stats['next_unlock']:
|
|
910
|
+
lines.append(f"\nNext unlock: {stats['next_unlock']} ({stats['next_unlock_progress']}% progress)")
|
|
911
|
+
|
|
912
|
+
return "\n".join(lines)
|
|
913
|
+
|
|
914
|
+
# -------------------------------------------------------------------------
|
|
915
|
+
# Reset & Maintenance
|
|
916
|
+
# -------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
def reset_all(self):
|
|
919
|
+
"""Reset all unlock progress"""
|
|
920
|
+
self._data = ContentUnlocksData()
|
|
921
|
+
self._initialize_content_types()
|
|
922
|
+
print("[ContentUnlocks] All unlocks reset")
|
|
923
|
+
|
|
924
|
+
def unlock_all(self):
|
|
925
|
+
"""Unlock all content types (for testing)"""
|
|
926
|
+
now = datetime.now().isoformat()
|
|
927
|
+
for content_type in UNLOCK_CRITERIA.keys():
|
|
928
|
+
if content_type not in self._data.unlocked_content:
|
|
929
|
+
self._data.unlocked_content[content_type] = UnlockState(
|
|
930
|
+
content_type=content_type
|
|
931
|
+
)
|
|
932
|
+
self._data.unlocked_content[content_type].unlocked = True
|
|
933
|
+
self._data.unlocked_content[content_type].unlocked_at = now
|
|
934
|
+
|
|
935
|
+
self._save()
|
|
936
|
+
print("[ContentUnlocks] All content unlocked")
|
|
937
|
+
|
|
938
|
+
def refresh_unlocks(self) -> List[str]:
|
|
939
|
+
"""
|
|
940
|
+
Force a refresh of all unlock checks.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
List of newly unlocked content types
|
|
944
|
+
"""
|
|
945
|
+
return self.check_all_unlocks()
|