package com.craigvg.lichun_android.network import android.content.Context import android.provider.Settings import com.craigvg.lichun_android.domain.models.* import com.craigvg.lichun_android.managers.ToastManager import com.craigvg.lichun_android.managers.ToastType import com.craigvg.lichun_android.network.dto.* import com.craigvg.lichun_android.utils.Logger import com.craigvg.lichun_android.utils.WebSocketCommands import kotlinx.coroutines.cancel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.flow.* import kotlinx.serialization.json.* import okhttp3.* import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton /** * WebSocket configuration */ enum class WebSocketEnvironment(val url: String) { PRODUCTION("wss://lichun.app/wss/"), DEVELOPMENT("ws://localhost:8001") } /** * WebSocket error types */ sealed class WebSocketError : Exception() { object ConnectionLost : WebSocketError() { private fun readResolve(): Any = ConnectionLost } data class ServerError(override val message: String) : WebSocketError() data class InsufficientResources(val resource: String, val required: Int, val available: Int) : WebSocketError() data class InvalidOperation(override val message: String) : WebSocketError() object Timeout : WebSocketError() { private fun readResolve(): Any = Timeout } val userMessage: String get() = when (this) { is ConnectionLost -> "Lost connection to server. Trying to reconnect..." is ServerError -> message is InsufficientResources -> "Not enough $resource. Need $required, have $available." is InvalidOperation -> message is Timeout -> "Request timed out. Please try again." } val isRetryable: Boolean get() = when (this) { is ConnectionLost, is ServerError, is Timeout -> true is InsufficientResources, is InvalidOperation -> false } } data class AccountDeletionUpdate( val scheduled: Boolean, val message: String, val scheduledAt: String? = null, val gracePeriodDays: Int? = null ) /** * Central WebSocket manager for all real-time server communication * Ported from iOS WebSocketService.swift */ @Singleton class WebSocketManager @Inject constructor( private val context: Context, private val toastManager: ToastManager ) { private val environment = WebSocketEnvironment.PRODUCTION private val serverUrl: String get() = context .getSharedPreferences("baolife_e2e", Context.MODE_PRIVATE) .getString("ws_url", null) ?.takeIf { it.isNotBlank() } ?: environment.url private val json = Json { ignoreUnknownKeys = true isLenient = true coerceInputValues = true } private val client = OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .pingInterval(30, TimeUnit.SECONDS) .build() private var webSocket: WebSocket? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Connection state private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() private val _appLoaded = MutableStateFlow(false) val appLoaded: StateFlow = _appLoaded.asStateFlow() // Player state private val _player = MutableStateFlow(Player()) val player: StateFlow = _player.asStateFlow() private val _person = MutableStateFlow(Person()) val person: StateFlow = _person.asStateFlow() private val _swipeCharacter = MutableStateFlow(null) val swipeCharacter: StateFlow = _swipeCharacter.asStateFlow() // Question/Event queues private val _questionQueue = MutableStateFlow>(emptyList()) val questionQueue: StateFlow> = _questionQueue.asStateFlow() private val _currentQuestion = MutableStateFlow(null) val currentQuestion: StateFlow = _currentQuestion.asStateFlow() private val _currentMessageEvent = MutableStateFlow(null) val currentMessageEvent: StateFlow = _currentMessageEvent.asStateFlow() private val _currentRelationshipEvent = MutableStateFlow(null) val currentRelationshipEvent: StateFlow = _currentRelationshipEvent.asStateFlow() // Life events private val _lifeEvents = MutableStateFlow>(emptyList()) val lifeEvents: StateFlow> = _lifeEvents.asStateFlow() private val _unclaimedEventCount = MutableStateFlow(0) val unclaimedEventCount: StateFlow = _unclaimedEventCount.asStateFlow() // Extracurriculars and date ideas private val _extracurriculars = MutableStateFlow>(emptyList()) val extracurriculars: StateFlow> = _extracurriculars.asStateFlow() private val _dateIdeas = MutableStateFlow>(emptyList()) val dateIdeas: StateFlow> = _dateIdeas.asStateFlow() // Tutorial state private val _tutorialStep = MutableStateFlow(0) val tutorialStep: StateFlow = _tutorialStep.asStateFlow() private val _tutorialComplete = MutableStateFlow(false) val tutorialComplete: StateFlow = _tutorialComplete.asStateFlow() private val _tutorialMessage = MutableStateFlow(null) val tutorialMessage: StateFlow = _tutorialMessage.asStateFlow() // Error handling private val _currentError = MutableStateFlow(null) val currentError: StateFlow = _currentError.asStateFlow() // GDPR / account management private val _dataExportPayload = MutableStateFlow(null) val dataExportPayload: StateFlow = _dataExportPayload.asStateFlow() private val _accountDeletionUpdate = MutableStateFlow(null) val accountDeletionUpdate: StateFlow = _accountDeletionUpdate.asStateFlow() // Monetization private val _energyRefillTiers = MutableStateFlow>(emptyList()) val energyRefillTiers: StateFlow> = _energyRefillTiers.asStateFlow() private val _showEnergyRefillModal = MutableStateFlow(false) val showEnergyRefillModal: StateFlow = _showEnergyRefillModal.asStateFlow() private val _unlimitedEnergyUntil = MutableStateFlow(null) val unlimitedEnergyUntil: StateFlow = _unlimitedEnergyUntil.asStateFlow() private val _timeSkipTiers = MutableStateFlow>(emptyList()) val timeSkipTiers: StateFlow> = _timeSkipTiers.asStateFlow() private val _showTimeSkipModal = MutableStateFlow(false) val showTimeSkipModal: StateFlow = _showTimeSkipModal.asStateFlow() private val _lastTimeSkipSummary = MutableStateFlow(null) val lastTimeSkipSummary: StateFlow = _lastTimeSkipSummary.asStateFlow() private val _showTimeSkipSummary = MutableStateFlow(false) val showTimeSkipSummary: StateFlow = _showTimeSkipSummary.asStateFlow() private val _lastStorePurchaseItemId = MutableStateFlow(null) val lastStorePurchaseItemId: StateFlow = _lastStorePurchaseItemId.asStateFlow() fun clearLastStorePurchaseItemId() { _lastStorePurchaseItemId.value = null } // Retention private val _achievements = MutableStateFlow>(emptyList()) val achievements: StateFlow> = _achievements.asStateFlow() private val _unacknowledgedAchievements = MutableStateFlow>(emptyList()) val unacknowledgedAchievements: StateFlow> = _unacknowledgedAchievements.asStateFlow() private val _showAchievementUnlock = MutableStateFlow(false) val showAchievementUnlock: StateFlow = _showAchievementUnlock.asStateFlow() private val _dailyRewardState = MutableStateFlow(null) val dailyRewardState: StateFlow = _dailyRewardState.asStateFlow() private val _showDailyRewards = MutableStateFlow(false) val showDailyRewards: StateFlow = _showDailyRewards.asStateFlow() private val _dailyQuestsState = MutableStateFlow(null) val dailyQuestsState: StateFlow = _dailyQuestsState.asStateFlow() private val _showDailyQuests = MutableStateFlow(false) val showDailyQuests: StateFlow = _showDailyQuests.asStateFlow() // Tier 2 retention spine — death summary / legacy / welcome-back / life goals private val _lifeSummary = MutableStateFlow(null) val lifeSummary: StateFlow = _lifeSummary.asStateFlow() private val _offlineDigest = MutableStateFlow(null) val offlineDigest: StateFlow = _offlineDigest.asStateFlow() private val _lifeGoals = MutableStateFlow(null) val lifeGoals: StateFlow = _lifeGoals.asStateFlow() // ── Wave 2 fun/balance: deeper quest loop + achievement collection ────── // Quest engagement (full-clear streak + weekly challenge) rides inside the // playerObject blob (`questEngagement`). private val _questEngagement = MutableStateFlow(null) val questEngagement: StateFlow = _questEngagement.asStateFlow() // Per-category achievement collection summary; rides inside the // `achievementsList` message as `summary`. private val _achievementSummary = MutableStateFlow(null) val achievementSummary: StateFlow = _achievementSummary.asStateFlow() // ── Tier 3 polish: transient floating resource/stat deltas ────────────── // Computed from lightweight "u" updates; each delta is rendered once by the // FloatingDeltaOverlay then pruned via [pruneStatDelta]. private val _statDeltas = MutableStateFlow>(emptyList()) val statDeltas: StateFlow> = _statDeltas.asStateFlow() // Reconnection state private var connectionAttemptDelay = 100L // ms private var retryCurrentAttempt = 0 private val retryMaxAttempts = 1000 private var reconnectJob: kotlinx.coroutines.Job? = null private val deviceId: String get() = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) /** * Connect to WebSocket server */ fun connect() { Logger.d(Logger.WS_TAG, "Connecting to $serverUrl") val request = Request.Builder() .url(serverUrl) .build() webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { Logger.d(Logger.WS_TAG, "Connected successfully") connectionEstablished() sendInit() } override fun onMessage(webSocket: WebSocket, text: String) { handleMessage(text) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Logger.e(Logger.WS_TAG, "Connection failed: ${t.message}", t) handleError() } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { _isConnected.value = false } }) } /** * Disconnect from WebSocket server */ fun disconnect() { reconnectJob?.cancel() reconnectJob = null webSocket?.close(1000, "User disconnected") webSocket = null _isConnected.value = false } /** * Cleanup resources - call when WebSocketManager is no longer needed */ fun cleanup() { disconnect() scope.cancel() } private fun connectionEstablished() { _isConnected.value = true _appLoaded.value = true retryCurrentAttempt = 0 connectionAttemptDelay = 100L _currentError.value = null } private fun handleError() { _isConnected.value = false _currentError.value = WebSocketError.ConnectionLost if (retryCurrentAttempt < retryMaxAttempts) { connectionAttemptDelay = (connectionAttemptDelay * 2.0).toLong().coerceAtMost(5000L) retryCurrentAttempt++ reconnectJob = scope.launch { delay(connectionAttemptDelay) reconnect() } } } private fun reconnect() { if (!_isConnected.value) { connect() } } /** * Send message to server. * * Builds the outbound [JsonObject] from explicitly-typed putters. Unlike the * previous implementation there is NO `value.toString()` catch-all: any value * whose runtime type is not one we can faithfully serialize is logged as an * error and dropped, so unexpected types can never be silently stringified * into corrupt payloads. */ fun sendMessage(message: Map) { val jsonString = buildJsonObject { message.forEach { (key, value) -> put(key, toJsonElement(key, value)) } }.toString() webSocket?.send(jsonString) } /** * Convert a single command value to a [JsonElement] using only typed * conversions. Returns [JsonNull] for unrepresentable values after logging, * rather than stringifying them. */ private fun toJsonElement(key: String, value: Any?): JsonElement = when (value) { null -> JsonNull is String -> JsonPrimitive(value) is Boolean -> JsonPrimitive(value) is Int -> JsonPrimitive(value) is Long -> JsonPrimitive(value) is Double -> JsonPrimitive(value) is Float -> JsonPrimitive(value) is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> if (k is String) { put(k, toJsonElement(k, v)) } else { Logger.e(Logger.WS_TAG, "Dropping non-String map key '$k' under '$key' in outbound message") } } } is List<*> -> buildJsonArray { value.forEachIndexed { index, item -> add(toJsonElement("$key[$index]", item)) } } else -> { Logger.e( Logger.WS_TAG, "Unsupported outbound value type ${value::class.java.name} for key '$key'; dropping it" ) JsonNull } } private fun sendInit() { sendMessage(WebSocketCommands.init(deviceId)) } /** * Handle incoming WebSocket message */ private fun handleMessage(text: String) { try { val jsonObject = json.parseToJsonElement(text).jsonObject val type = jsonObject["type"]?.jsonPrimitive?.contentOrNull ?: return parseEventV2Envelope(jsonObject, json)?.let { envelope -> handleEventV2Envelope(envelope) return } when (type) { "u" -> handleLightweightUpdate(jsonObject) "playerObject" -> handlePlayerObject(jsonObject) "personObject" -> handlePersonObject(jsonObject) "messageEvent" -> handleMessageEvent(jsonObject) "questionEvent" -> handleQuestionEvent(jsonObject) "conversationEvent" -> handleConversationEvent(jsonObject) "extraCurriculars" -> handleExtracurriculars(jsonObject) "getSwipeCharacter" -> handleSwipeCharacter(jsonObject) "tutorialStepUpdated", "onboardingComplete", "tutorial_message" -> handleTutorialMessage(jsonObject) "relationshipEvent" -> handleRelationshipEvent(jsonObject) "error" -> handleErrorMessage(jsonObject) "dataExportComplete" -> handleDataExportComplete(jsonObject) "accountDeletionScheduled" -> handleAccountDeletionScheduled(jsonObject) "accountDeletionCancelled" -> handleAccountDeletionCancelled(jsonObject) "energyRefillTiers" -> handleEnergyRefillTiers(jsonObject) "purchaseComplete" -> handlePurchaseComplete(jsonObject) "inAppPurchaseComplete" -> handleInAppPurchaseComplete(jsonObject) "timeSkipTiers" -> handleTimeSkipTiers(jsonObject) "timeSkipComplete" -> handleTimeSkipComplete(jsonObject) "achievementsList" -> handleAchievementsList(jsonObject) "achievementUnlocked" -> handleAchievementUnlocked(jsonObject) "dailyRewardStatus" -> handleDailyRewardStatus(jsonObject) "dailyRewardClaimed" -> handleDailyRewardClaimed(jsonObject) "dailyQuestsStatus" -> handleDailyQuestsStatus(jsonObject) "questProgress" -> handleQuestProgress(jsonObject) "questRewardClaimed" -> handleQuestRewardClaimed(jsonObject) "dateIdeas" -> handleDateIdeas(jsonObject) "playerUpdate" -> handlePlayerUpdate(jsonObject) "habitQuitting", "habitQuitStopped", "jobApplied", "jobQuit", "extracurricularApplied", "extracurricularQuit", "focusUpdated", "relationshipEnded", "romanceStarted", "activityPerformed", "activityPlanned" -> handleActionConfirmation(type, jsonObject) "debugGrantComplete", "debugSetupComplete" -> handleDebugActionComplete(jsonObject) // ── Tier 2 retention spine ────────────────────────────────── "lifeSummaryEvent" -> handleLifeSummaryEvent(jsonObject) "offlineDigest" -> handleOfflineDigest(jsonObject) "lifeGoalsUpdate" -> handleLifeGoalsUpdate(jsonObject) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle incoming message", e) } } /** * Decode a message payload into a `@Serializable` DTO using the lenient * [json] config. On failure (which should be rare given the tolerant DTO * defaults) the error is logged and null returned so the caller can no-op, * matching the old swallow-and-skip behavior. */ private inline fun decode(jsonObject: JsonObject, context: String): T? = runCatching { json.decodeFromJsonElement(jsonObject) } .onFailure { Logger.e(Logger.WS_TAG, "Failed to decode $context", it) } .getOrNull() private fun handleEventV2Envelope(envelope: EventV2Envelope) { when (envelope) { is EventV2Envelope.Prompt -> handleEventV2Prompt(envelope.value) is EventV2Envelope.Resolved -> handleEventV2Resolved(envelope.value) is EventV2Envelope.Error -> handleEventV2Error(envelope.value) } } private fun handleEventV2Prompt(prompt: EventV2PromptEnvelope) { val answers = prompt.choices.map { choice -> AnswerOption( option = choice.text, id = choice.choiceId, data = null, energyCost = choice.energyCost, moneyCost = choice.moneyCost, diamondCost = choice.diamondCost ) } val question = Question( id = prompt.eventId, question = prompt.prompt, answers = answers, image = null ) val currentQueue = _questionQueue.value.toMutableList() if (currentQueue.none { it.id == question.id }) { currentQueue.add(question) _questionQueue.value = currentQueue } if (_currentQuestion.value == null) { _currentQuestion.value = _questionQueue.value.firstOrNull() } } private fun handleEventV2Resolved(resolved: EventV2ResolvedEnvelope) { removeQuestionByEventId(resolved.eventId) val categoryKey = resolved.metadata?.category val status = resolved.metadata?.status ?: "resolved" val messageEvent = MessageEvent( id = resolved.instanceId, message = resolved.resolutionText, type = resolved.type, date = _player.value.date, hour = _player.value.hourOfDay, title = "Event Resolved", image = null, status = status, categoryKey = categoryKey, category = EventCategory.fromV2Category(categoryKey), claimed = true, claimedAt = System.currentTimeMillis() ) val currentEvents = _lifeEvents.value.toMutableList() currentEvents.add(0, messageEvent) if (currentEvents.size > 50) { currentEvents.removeAt(currentEvents.lastIndex) } _lifeEvents.value = currentEvents updateUnclaimedCount() } private fun handleEventV2Error(eventError: EventV2ErrorEnvelope) { _currentError.value = WebSocketError.ServerError(eventError.message) } private fun handleLightweightUpdate(jsonObject: JsonObject) { val dto = decode(jsonObject, "lightweight update") ?: return val before = _person.value dto.date?.let { _player.value = _player.value.copy(date = it) } dto.minuteOfHour?.let { _player.value = _player.value.copy(minuteOfHour = it) } dto.hourOfDay?.let { _player.value = _player.value.copy(hourOfDay = it) } dto.gameSpeed?.let { _player.value = _player.value.copy(gameSpeed = it) } dto.money?.let { _person.value = _person.value.copy(money = it) } dto.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } dto.location?.let { _person.value = _person.value.copy(location = it) } dto.calcEnergy?.let { _person.value = _person.value.copy(calcEnergy = it) } dto.status?.let { _person.value = _person.value.copy(status = it) } dto.intraDayMessage?.let { _person.value = _person.value.copy(intraDayMessage = it) } dto.mood?.let { _person.value = _person.value.copy(mood = it) } // Tier 3 polish: emit transient floating deltas for changed resources so // gains/losses feel earned. Only fire when the prior value was non-zero // (avoids a burst of "+N" on the very first hydration). val after = _person.value if (before.calcEnergy != 0) emitStatDelta(StatDelta.Kind.ENERGY, after.calcEnergy - before.calcEnergy) if (before.money != 0.0) emitStatDelta(StatDelta.Kind.MONEY, (after.money - before.money).toInt()) if (before.diamonds != 0) emitStatDelta(StatDelta.Kind.DIAMONDS, after.diamonds - before.diamonds) } /** Queue a floating delta, capping the live list so it never grows unbounded. */ private fun emitStatDelta(kind: StatDelta.Kind, amount: Int) { if (amount == 0) return val delta = StatDelta(kind = kind, amount = amount) val current = _statDeltas.value.toMutableList() current.add(delta) while (current.size > 6) current.removeAt(0) _statDeltas.value = current } /** Remove a delta once the UI has finished animating it. */ fun pruneStatDelta(id: String) { _statDeltas.value = _statDeltas.value.filterNot { it.id == id } } private fun handlePlayerObject(jsonObject: JsonObject) { try { val scalars = decode(jsonObject, "playerObject") ?: PlayerObjectScalarsDto() val relationships = decodeList(jsonObject, "r") // ── Tier 2 generational layer (familyPrestige + familyTree) ────── val familyPrestige = jsonObject["familyPrestige"]?.jsonPrimitive?.doubleOrNull ?: _player.value.familyPrestige val familyTree = if (jsonObject["familyTree"] is JsonArray) { decodeList(jsonObject, "familyTree") } else { _player.value.familyTree } _player.value = _player.value.copy( date = scalars.date, gameSpeed = scalars.gameSpeed, status = scalars.status, season = scalars.season, hourOfDay = scalars.hourOfDay, minuteOfHour = scalars.minuteOfHour, activeConversations = decodeList(jsonObject, "conversations"), focuses = decodeList(jsonObject, "focuses"), storeItems = decodeList(jsonObject, "storeItems"), occupations = decodeList(jsonObject, "occupations"), inAppPurchases = decodeList(jsonObject, "inAppPurchases"), r = relationships, relData = decodeList(jsonObject, "relData"), familyPrestige = familyPrestige, familyTree = familyTree ) // ── Tier 2: lifeSummary embedded on the player blob (persisted by // the server; used to power the death recap + New Life chooser when // the standalone lifeSummaryEvent is missed on reconnect). ───────── jsonObject["lifeSummary"]?.let { summaryElement -> if (summaryElement is JsonObject) { decode(summaryElement, "playerObject.lifeSummary") ?.let { _lifeSummary.value = it } } } // ── Tier 2: welcome-back digest embedded in offlineStats.digest. // The server also sends a standalone offlineDigest message; surface // whichever arrives (the embedded one survives reconnect). ────────── (jsonObject["offlineStats"] as? JsonObject)?.get("digest")?.let { digestElement -> if (digestElement is JsonObject) { decode(digestElement, "playerObject.offlineStats.digest") ?.takeIf { it.generatedAt.isNotBlank() } ?.let { _offlineDigest.value = it } } } // ── Wave 2: deeper quest loop (full-clear streak + weekly // challenge). Rides inside the player blob as `questEngagement`. ── (jsonObject["questEngagement"] as? JsonObject)?.let { engagementElement -> decode(engagementElement, "playerObject.questEngagement") ?.let { _questEngagement.value = it } } // Parse person data from "c" jsonObject["c"]?.jsonObject?.let { personData -> _person.value = parsePersonFromJson(personData) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle playerObject", e) } } private fun handlePersonObject(jsonObject: JsonObject) { try { _person.value = parsePersonFromJson(jsonObject) } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle personObject", e) } } private fun parsePersonFromJson(jsonObject: JsonObject): Person { val decoded = runCatching { json.decodeFromJsonElement(jsonObject) }.getOrNull() if (decoded != null) return decoded return Person( id = jsonObject["id"]?.jsonPrimitive?.contentOrNull ?: "", image = jsonObject["image"]?.jsonPrimitive?.contentOrNull ?: "", status = jsonObject["status"]?.jsonPrimitive?.contentOrNull ?: "", sex = jsonObject["sex"]?.jsonPrimitive?.contentOrNull ?: "", firstname = jsonObject["firstname"]?.jsonPrimitive?.contentOrNull ?: "", lastname = jsonObject["lastname"]?.jsonPrimitive?.contentOrNull ?: "", ageYears = jsonObject["ageYears"]?.jsonPrimitive?.intOrNull ?: 0, birthday = jsonObject["birthday"]?.jsonPrimitive?.contentOrNull ?: "", mood = jsonObject["mood"]?.jsonPrimitive?.contentOrNull ?: "", affinity = jsonObject["affinity"]?.jsonPrimitive?.intOrNull ?: 0, money = jsonObject["money"]?.jsonPrimitive?.doubleOrNull ?: 0.0, diamonds = jsonObject["diamonds"]?.jsonPrimitive?.intOrNull ?: 0, prestige = jsonObject["prestige"]?.jsonPrimitive?.intOrNull ?: 0, happiness = jsonObject["happiness"]?.jsonPrimitive?.intOrNull ?: 0, health = jsonObject["health"]?.jsonPrimitive?.doubleOrNull ?: 0.0, calcEnergy = jsonObject["calcEnergy"]?.jsonPrimitive?.intOrNull ?: 0, intelligence = jsonObject["intelligence"]?.jsonPrimitive?.intOrNull ?: 50, bio = jsonObject["bio"]?.jsonPrimitive?.contentOrNull ?: "", location = jsonObject["location"]?.jsonPrimitive?.contentOrNull ?: "", intraDayMessage = jsonObject["intraDayMessage"]?.jsonPrimitive?.contentOrNull ?: "" ) } private inline fun decodeList(jsonObject: JsonObject, key: String): List { val jsonArray = jsonObject[key] as? JsonArray ?: return emptyList() return jsonArray.mapNotNull { element -> runCatching { json.decodeFromJsonElement(element) } .onFailure { Logger.w(Logger.WS_TAG, "Failed to parse $key item", it) } .getOrNull() } } private fun handleMessageEvent(jsonObject: JsonObject) { val dto = decode(jsonObject, "messageEvent") ?: MessageEventDto() val id = dto.id ?: java.util.UUID.randomUUID().toString() val message = dto.message val eventType = dto.type val date = dto.date val hour = dto.hour val energyCost = dto.energyCost val diamondCost = dto.diamondCost val moneyCost = dto.moneyCost val affinityChange = dto.affinityChange val title = dto.title val image = dto.image val categoryKey = dto.category val status = dto.status val category = if (!categoryKey.isNullOrBlank()) { EventCategory.fromV2Category(categoryKey) } else { determineCategory(message) } var messageEvent = MessageEvent( id = id, message = message, type = eventType, date = date, hour = hour, energyCost = energyCost, diamondCost = diamondCost, moneyCost = moneyCost, affinityChange = affinityChange, title = title, image = image, status = status, categoryKey = categoryKey, category = category ) // Auto-apply if negative if (messageEvent.isNegative || !messageEvent.isClaimable) { messageEvent = messageEvent.copy( claimed = true, claimedAt = System.currentTimeMillis() ) } // Handle events with images (show modal) if (!image.isNullOrEmpty()) { _currentMessageEvent.value = messageEvent } else { // Add to events list val currentEvents = _lifeEvents.value.toMutableList() currentEvents.add(0, messageEvent) if (currentEvents.size > 50) { currentEvents.removeAt(currentEvents.lastIndex) } _lifeEvents.value = currentEvents updateUnclaimedCount() } } private fun determineCategory(message: String): EventCategory { val lowercaseMessage = message.lowercase() return when { lowercaseMessage.contains("job") || lowercaseMessage.contains("promot") || lowercaseMessage.contains("hired") || lowercaseMessage.contains("work") -> EventCategory.CAREER lowercaseMessage.contains("date") || lowercaseMessage.contains("friend") || lowercaseMessage.contains("relationship") -> EventCategory.SOCIAL lowercaseMessage.contains("achievement") || lowercaseMessage.contains("unlock") || lowercaseMessage.contains("award") -> EventCategory.ACHIEVEMENT lowercaseMessage.contains("graduat") || lowercaseMessage.contains("school") || lowercaseMessage.contains("college") -> EventCategory.EDUCATION lowercaseMessage.contains("health") || lowercaseMessage.contains("sick") || lowercaseMessage.contains("hospital") -> EventCategory.HEALTH lowercaseMessage.contains("money") || lowercaseMessage.contains("dollar") || lowercaseMessage.contains("paid") -> EventCategory.FINANCE lowercaseMessage.contains("lost") || lowercaseMessage.contains("fired") || lowercaseMessage.contains("broke") -> EventCategory.NEGATIVE else -> EventCategory.NEUTRAL } } private fun updateUnclaimedCount() { _unclaimedEventCount.value = _lifeEvents.value.count { it.isClaimable && !it.claimed } } private fun removeQuestionByEventId(eventId: String) { val currentQueue = _questionQueue.value.toMutableList() currentQueue.removeIf { it.id == eventId } _questionQueue.value = currentQueue _currentQuestion.value = currentQueue.firstOrNull() } private fun handleQuestionEvent(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "questionEvent") ?: QuestionEventDto() val question = Question( id = dto.id, question = dto.message, answers = dto.answers, image = dto.image ) val currentQueue = _questionQueue.value.toMutableList() currentQueue.add(question) _questionQueue.value = currentQueue _currentQuestion.value = currentQueue.firstOrNull() } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle questionEvent", e) } } private fun handleConversationEvent(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "conversationEvent") ?: return val id = dto.id ?: return val messages = dto.conversation.map { msg -> ConversationMessage( id = msg.id, message = msg.message, sentiment = msg.sentiment, answerOptions = msg.answerOptions, sender = msg.sender, datetime = msg.datetime, date = msg.date, time = msg.time ) } val conversationObj = ConversationObj( id = id, type = "conversationEvent", cType = dto.cType, character = dto.character, conversation = messages, question = dto.question ) val currentConversations = _player.value.activeConversations.toMutableList() val existingIndex = currentConversations.indexOfFirst { it.id == id } if (existingIndex >= 0) { currentConversations[existingIndex] = conversationObj } else { currentConversations.add(conversationObj) } _player.value = _player.value.copy(activeConversations = currentConversations) } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle conversationEvent", e) } } private fun handleExtracurriculars(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "extraCurriculars") ?: ExtracurricularsDto() _extracurriculars.value = dto.extraCurriculars.map { item -> ExtracurricularClass( id = item.id, title = item.title, image = item.image, type = item.type, description = item.description ) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle extraCurriculars", e) } } private fun handleSwipeCharacter(jsonObject: JsonObject) { try { jsonObject["swipeCharacter"]?.jsonObject?.let { personData -> _swipeCharacter.value = parsePersonFromJson(personData) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle getSwipeCharacter", e) } } private fun handleTutorialMessage(jsonObject: JsonObject) { val dto = decode(jsonObject, "tutorial message") ?: return when (dto.type) { "tutorialStepUpdated" -> dto.step?.let { _tutorialStep.value = it } "onboardingComplete" -> _tutorialComplete.value = true "tutorial_message" -> dto.message?.let { _tutorialMessage.value = it } } } private fun handleRelationshipEvent(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "relationshipEvent") ?: return val id = dto.id ?: return val type = try { RelationshipEventType.valueOf(dto.eventType.uppercase()) } catch (e: Exception) { RelationshipEventType.ARGUMENT } val choices = dto.choices.map { choice -> EventChoice( id = choice.id, text = choice.text, affinityChange = choice.affinityChange, diamondCost = choice.diamondCost, moneyCost = choice.moneyCost, energyCost = choice.energyCost ) } _currentRelationshipEvent.value = RelationshipEvent( id = id, type = type, title = dto.title, description = dto.description, partnerName = dto.partnerName, partnerId = dto.partnerId, choices = choices ) } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle relationshipEvent", e) } } private fun handleActionConfirmation(type: String, jsonObject: JsonObject) { val dto = decode(jsonObject, "action confirmation") ?: ActionConfirmationDto() if (!dto.success) return val message = dto.message ?: defaultActionMessage(type) toastManager.show(ToastType.SUCCESS, message) } private fun defaultActionMessage(type: String): String = when (type) { "habitQuitting" -> "Started working on breaking this habit." "habitQuitStopped" -> "Stopped trying to break this habit." "jobApplied" -> "Job application submitted." "jobQuit" -> "You left your job." "extracurricularApplied" -> "Enrolled in activity." "extracurricularQuit" -> "Left activity." "focusUpdated" -> "Focus updated." "relationshipEnded" -> "Relationship ended." "romanceStarted" -> "Romance started." "activityPerformed" -> "Activity done." "activityPlanned" -> "Activity scheduled." else -> "Action completed." } private fun handleDebugActionComplete(jsonObject: JsonObject) { val dto = decode(jsonObject, "debug action complete") ?: DebugActionCompleteDto() if (!dto.success) return val message = dto.message ?: when (dto.type) { "debugSetupComplete" -> "Debug preset applied." else -> "Debug resources applied." } toastManager.show(ToastType.SUCCESS, message) } private fun handleErrorMessage(jsonObject: JsonObject) { val dto = decode(jsonObject, "error") ?: return val message = dto.message ?: return val errorCode = dto.errorCode if (errorCode == null) { toastManager.show(ToastType.ERROR, message) _currentError.value = WebSocketError.ServerError(message) return } _currentError.value = when (errorCode) { "INSUFFICIENT_ENERGY", "INSUFFICIENT_MONEY", "INSUFFICIENT_DIAMONDS" -> { val resource = errorCode.replace("INSUFFICIENT_", "").lowercase() WebSocketError.InsufficientResources(resource, dto.required, dto.available) } "SERVER_ERROR" -> WebSocketError.ServerError(message) "INVALID_OPERATION" -> WebSocketError.InvalidOperation(message) "TIMEOUT" -> WebSocketError.Timeout else -> WebSocketError.ServerError(message) } } private fun handleDataExportComplete(jsonObject: JsonObject) { val exportPayload = jsonObject["data"]?.toString() ?: jsonObject["exportData"]?.jsonPrimitive?.contentOrNull _dataExportPayload.value = exportPayload } private fun handleAccountDeletionScheduled(jsonObject: JsonObject) { val dto = decode(jsonObject, "accountDeletionScheduled") ?: AccountDeletionScheduledDto() if (dto.result?.success != true) { _currentError.value = WebSocketError.ServerError("Failed to schedule account deletion") return } _accountDeletionUpdate.value = AccountDeletionUpdate( scheduled = true, message = "Account deletion scheduled.", scheduledAt = dto.result.scheduledAt, gracePeriodDays = dto.gracePeriodDays ) } private fun handleAccountDeletionCancelled(jsonObject: JsonObject) { val dto = decode(jsonObject, "accountDeletionCancelled") ?: AccountDeletionCancelledDto() if (!dto.success) { _currentError.value = WebSocketError.ServerError("Failed to cancel account deletion") return } _accountDeletionUpdate.value = AccountDeletionUpdate( scheduled = false, message = dto.message ?: "Account deletion cancelled." ) } private fun handleEnergyRefillTiers(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "energyRefillTiers") ?: EnergyRefillTiersDto() _energyRefillTiers.value = dto.tiers .map { (type, tier) -> EnergyRefillTier(type = type, energy = tier.energy, diamonds = tier.diamonds) } .sortedBy { it.diamonds } _showEnergyRefillModal.value = true } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle energyRefillTiers", e) } } private fun handlePurchaseComplete(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "purchaseComplete") ?: PurchaseCompleteDto() dto.itemId?.let { itemId -> if (dto.success == true) { _lastStorePurchaseItemId.value = itemId } return } val newBalance = dto.newBalance newBalance?.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } newBalance?.energy?.let { _person.value = _person.value.copy(calcEnergy = it) } newBalance?.money?.let { _person.value = _person.value.copy(money = it) } newBalance?.unlimitedUntil?.let { until -> runCatching { _unlimitedEnergyUntil.value = java.time.Instant.parse(until).toEpochMilli() } } // Backward-compatible fallback for flat payloads. dto.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } dto.energy?.let { _person.value = _person.value.copy(calcEnergy = it) } dto.money?.let { _person.value = _person.value.copy(money = it) } dto.unlimitedEnergyUntil?.let { until -> runCatching { _unlimitedEnergyUntil.value = java.time.Instant.parse(until).toEpochMilli() } } _showEnergyRefillModal.value = false _showTimeSkipModal.value = false } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle purchaseComplete", e) } } private fun handleInAppPurchaseComplete(jsonObject: JsonObject) { val dto = decode(jsonObject, "inAppPurchaseComplete") ?: SuccessFlagDto() if (!dto.success) return // Server sends refreshed playerObject after successful in-app purchase. // This handler closes purchase UI and clears transient errors. _showEnergyRefillModal.value = false _showTimeSkipModal.value = false _currentError.value = null } private fun handleTimeSkipTiers(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "timeSkipTiers") ?: TimeSkipTiersDto() _timeSkipTiers.value = dto.tiers .map { (type, tier) -> TimeSkipTier( type = type, durationSeconds = tier.durationSeconds, diamonds = tier.diamonds ) } .sortedBy { it.diamonds } _showTimeSkipModal.value = true } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle timeSkipTiers", e) } } private fun handleTimeSkipComplete(jsonObject: JsonObject) { try { val summaryJson = jsonObject["summary"]?.jsonObject ?: jsonObject val dto = decode(summaryJson, "timeSkipComplete summary") ?: TimeSkipSummaryDto() val events = dto.events.map { event -> TimeSkipEvent( type = event.type, description = event.description, moneyEarned = event.moneyEarned, smartsGained = event.smartsGained ) } val sc = dto.statChanges val statChanges = TimeSkipSummary.StatChanges( money = sc?.money ?: 0.0, energy = sc?.energy ?: 0, health = sc?.health ?: 0, happiness = sc?.happiness ?: 0 ) _lastTimeSkipSummary.value = TimeSkipSummary( diamonds = dto.diamonds, newTime = dto.newTime, durationHours = dto.durationHours, events = events, statChanges = statChanges ) _showTimeSkipModal.value = false _showTimeSkipSummary.value = true } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle timeSkipComplete", e) } } private fun achievementCategoryOf(value: String): AchievementCategory = try { AchievementCategory.valueOf(value.uppercase()) } catch (e: Exception) { AchievementCategory.CAREER } private fun handleAchievementsList(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "achievementsList") ?: AchievementsListDto() _achievements.value = dto.achievements.map { a -> Achievement( id = a.id, name = a.name, description = a.description, category = achievementCategoryOf(a.category), reward = a.reward, requirement = a.requirement, unlocked = a.unlocked, unlockedAt = a.unlockedAt, progress = a.progress, progressRequired = a.progressRequired, acknowledged = a.acknowledged ) } // Wave 2: per-category collection summary rides alongside the list. dto.summary?.let { _achievementSummary.value = it } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle achievementsList", e) } } private fun handleAchievementUnlocked(jsonObject: JsonObject) { try { val obj = jsonObject["achievement"]?.jsonObject ?: jsonObject val dto = decode(obj, "achievementUnlocked") ?: AchievementDto() val achievement = Achievement( id = dto.id, name = dto.name, description = dto.description, category = achievementCategoryOf(dto.category), reward = dto.reward, requirement = dto.requirement, unlocked = true, unlockedAt = System.currentTimeMillis() ) val current = _unacknowledgedAchievements.value.toMutableList() current.add(achievement) _unacknowledgedAchievements.value = current _showAchievementUnlock.value = true // Also update in main achievements list val allAchievements = _achievements.value.toMutableList() val index = allAchievements.indexOfFirst { it.id == achievement.id } if (index >= 0) { allAchievements[index] = achievement } else { allAchievements.add(achievement) } _achievements.value = allAchievements } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle achievementUnlocked", e) } } private fun handleDailyRewardStatus(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "dailyRewardStatus") ?: DailyRewardStatusDto() val rewards = dto.rewards.map { reward -> DayReward( id = reward.id ?: reward.day ?: 0, diamonds = reward.diamonds, energy = reward.energy, money = reward.money, bonusItem = reward.bonusItem, claimed = reward.claimed ) } _dailyRewardState.value = DailyRewardState( currentStreak = dto.currentStreak, lastLoginDate = dto.lastLoginDate, nextResetDate = dto.nextResetDate, canClaim = dto.canClaim, todaysClaimed = dto.todaysClaimed, rewards = rewards ) } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle dailyRewardStatus", e) } } private fun handleDailyRewardClaimed(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "dailyRewardClaimed") ?: DailyRewardClaimedDto() if (!dto.success) return val day = dto.day val reward = dto.reward dto.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } dto.energy?.let { _person.value = _person.value.copy(calcEnergy = it) } reward?.diamonds?.takeIf { it > 0 }?.let { diamonds -> _person.value = _person.value.copy(diamonds = _person.value.diamonds + diamonds) } reward?.energy?.takeIf { it > 0 }?.let { energy -> _person.value = _person.value.copy(calcEnergy = (_person.value.calcEnergy + energy).coerceAtMost(100)) } _dailyRewardState.value?.let { state -> val updatedRewards = state.rewards.map { reward -> if (day != null && reward.id == day) reward.copy(claimed = true) else reward } _dailyRewardState.value = state.copy( todaysClaimed = true, rewards = updatedRewards ) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle dailyRewardClaimed", e) } } private fun handleDailyQuestsStatus(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "dailyQuestsStatus") ?: DailyQuestsStatusDto() val quests = dto.quests.map { quest -> val category = try { QuestCategory.valueOf(quest.category.uppercase()) } catch (e: Exception) { QuestCategory.SOCIAL } val reward = QuestReward( diamonds = quest.reward?.diamonds ?: 0, energy = quest.reward?.energy, money = quest.reward?.money ) DailyQuest( id = quest.id, name = quest.name, description = quest.description, category = category, reward = reward, progress = quest.progress, target = quest.target, completed = quest.completed, claimed = quest.claimed ) } _dailyQuestsState.value = DailyQuestsState( quests = quests, lastResetDate = dto.lastResetDate, nextResetDate = dto.nextResetDate ) } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle dailyQuestsStatus", e) } } private fun handleQuestProgress(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "questProgress") ?: return val questObj = dto.quest ?: return val questId = questObj.id ?: return val progress = questObj.progress ?: return val completed = questObj.completed _dailyQuestsState.value?.let { state -> val updatedQuests = state.quests.map { quest -> if (quest.id == questId) { val nextTarget = questObj.progressRequired ?: quest.target quest.copy(progress = progress, completed = completed, target = nextTarget) } else { quest } } _dailyQuestsState.value = state.copy(quests = updatedQuests) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle questProgress", e) } } private fun handleQuestRewardClaimed(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "questRewardClaimed") ?: QuestRewardClaimedDto() if (!dto.success) return val questId = dto.questId val reward = dto.reward dto.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } dto.energy?.let { _person.value = _person.value.copy(calcEnergy = it) } dto.money?.let { _person.value = _person.value.copy(money = it) } reward?.diamonds?.takeIf { it > 0 }?.let { diamonds -> _person.value = _person.value.copy(diamonds = _person.value.diamonds + diamonds) } reward?.energy?.takeIf { it > 0 }?.let { energy -> _person.value = _person.value.copy(calcEnergy = (_person.value.calcEnergy + energy).coerceAtMost(100)) } reward?.money?.takeIf { it > 0 }?.let { money -> _person.value = _person.value.copy(money = _person.value.money + money) } _dailyQuestsState.value?.let { state -> val updatedQuests = state.quests.map { quest -> if (questId != null && quest.id == questId) quest.copy(claimed = true) else quest } _dailyQuestsState.value = state.copy(quests = updatedQuests) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle questRewardClaimed", e) } } private fun handleDateIdeas(jsonObject: JsonObject) { try { val dto = decode(jsonObject, "dateIdeas") ?: DateIdeasDto() _dateIdeas.value = dto.dateIdeas.mapNotNull { idea -> val name = idea.name ?: return@mapNotNull null DateIdea( name = name, energy_cost = idea.energy_cost, money_cost = idea.money_cost, image = idea.image ) } } catch (e: Exception) { Logger.e(Logger.WS_TAG, "Failed to handle dateIdeas", e) } } private fun handlePlayerUpdate(jsonObject: JsonObject) { val dto = decode(jsonObject, "playerUpdate") ?: return dto.diamonds?.let { _person.value = _person.value.copy(diamonds = it) } dto.energy?.let { _person.value = _person.value.copy(calcEnergy = it) } dto.unlimitedEnergyUntil?.let { until -> runCatching { _unlimitedEnergyUntil.value = java.time.Instant.parse(until).toEpochMilli() } } } // ── Tier 2 retention spine handlers ───────────────────────────────────── /** * `lifeSummaryEvent` — the end-of-life recap (score, net worth, peak career, * relationships, children, notable events, legacy/heir/inheritance). Stored so * the death recap screen + New Life chooser can render it. The server also * persists it on `player.lifeSummary`, which is picked up in [handlePlayerObject]. */ private fun handleLifeSummaryEvent(jsonObject: JsonObject) { val dto = decode(jsonObject, "lifeSummaryEvent") ?: return dto.summary?.let { _lifeSummary.value = it } // Mid-session death: the server emits `lifeSummaryEvent` at the moment the // character dies WITHOUT a fresh playerObject, so `person.status` would not // otherwise flip to "dead". Set it here (mirrors iOS GameStateStore) so the // navigation LaunchedEffect recomposes and routes to the death screen. The // recap renders from `_lifeSummary` set above. On reconnect, playerObject's // `c` blob carries the dead status, so this is the only gap to close. if (_person.value.status != "dead") { _person.value = _person.value.copy(status = "dead") } } /** * `offlineDigest` — welcome-back summary of what happened while away. The * server sends this exactly once per offline period; the UI shows it as a * one-time card and calls [clearOfflineDigest] when dismissed. */ private fun handleOfflineDigest(jsonObject: JsonObject) { val dto = decode(jsonObject, "offlineDigest") ?: return _offlineDigest.value = OfflineDigest( minutesAway = dto.minutesAway, moneyDelta = dto.moneyDelta, ageYearsDelta = dto.ageYearsDelta, notableEvents = dto.notableEvents, generatedAt = dto.generatedAt ) } /** * `lifeGoalsUpdate` — long-arc life-goal progression + life score. Wave 1 * decodes and stores it (Wave 2 / T015b builds the dedicated goals UI). The * `justCompleted` list is preserved for a one-time celebration in Wave 2. */ private fun handleLifeGoalsUpdate(jsonObject: JsonObject) { val dto = decode(jsonObject, "lifeGoalsUpdate") ?: return _lifeGoals.value = LifeGoalsState( active = dto.active, completed = dto.completed, lifeScore = dto.lifeScore, justCompleted = dto.justCompleted ) } fun clearOfflineDigest() { _offlineDigest.value = null } fun clearLifeSummary() { _lifeSummary.value = null } // MARK: - Public API Methods fun claimEvent(event: MessageEvent) { val currentEvents = _lifeEvents.value.toMutableList() val index = currentEvents.indexOfFirst { it.id == event.id } if (index >= 0) { currentEvents[index] = currentEvents[index].copy( claimed = true, claimedAt = System.currentTimeMillis() ) _lifeEvents.value = currentEvents updateUnclaimedCount() } sendMessage( WebSocketCommands.claimEvent( eventId = event.id, timestamp = java.time.Instant.now().toString() ) ) } fun sendAnswer(answer: AnswerOption, questionId: String) { val choiceId = answer.choiceId if (choiceId == null) { _currentError.value = WebSocketError.InvalidOperation("Invalid choice. Please try again.") return } sendMessage(buildEventResponsePayload(eventId = questionId, choiceId = choiceId)) removeQuestionByEventId(questionId) } fun setSpeed(speed: Int) { sendMessage(WebSocketCommands.speed(speed)) } fun fetchAchievements() { sendMessage(WebSocketCommands.getAchievements()) } fun acknowledgeAchievement(achievementId: String) { sendMessage(WebSocketCommands.acknowledgeAchievement(achievementId)) } fun fetchDailyRewards() { sendMessage(WebSocketCommands.getDailyRewards()) } fun claimDailyReward(day: Int) { sendMessage(WebSocketCommands.claimDailyReward(day)) } fun fetchDailyQuests() { sendMessage(WebSocketCommands.getDailyQuests()) } fun claimQuestReward(questId: String) { sendMessage(WebSocketCommands.claimQuestReward(questId)) } fun fetchEnergyRefillTiers() { sendMessage(WebSocketCommands.getEnergyRefillTiers()) } fun purchaseEnergyRefill(tierType: String) { sendMessage(WebSocketCommands.purchaseEnergyRefill(tierType)) } fun fetchTimeSkipTiers() { sendMessage(WebSocketCommands.getTimeSkipTiers()) } fun purchaseTimeSkip(tierType: String) { sendMessage(WebSocketCommands.purchaseTimeSkip(tierType)) } fun quitHabit(habitName: String) { sendMessage(WebSocketCommands.quitHabit(habitName)) } fun stopQuitHabit(habitName: String) { sendMessage(WebSocketCommands.stopQuitHabit(habitName)) } fun setFocus(activityId: String, focusId: Int) { sendMessage(WebSocketCommands.focusUpdate(activityId, focusId)) } fun retrievePerson(personId: String) { sendMessage(WebSocketCommands.retrievePerson(personId)) } fun dismissCurrentError() { _currentError.value = null } fun clearDataExportPayload() { _dataExportPayload.value = null } fun clearAccountDeletionUpdate() { _accountDeletionUpdate.value = null } fun setShowEnergyRefillModal(show: Boolean) { _showEnergyRefillModal.value = show } fun setShowTimeSkipModal(show: Boolean) { _showTimeSkipModal.value = show } fun setShowTimeSkipSummary(show: Boolean) { _showTimeSkipSummary.value = show } fun setShowAchievementUnlock(show: Boolean) { _showAchievementUnlock.value = show } fun setShowDailyRewards(show: Boolean) { _showDailyRewards.value = show } fun setShowDailyQuests(show: Boolean) { _showDailyQuests.value = show } fun dismissCurrentQuestion() { val currentQueue = _questionQueue.value.toMutableList() if (currentQueue.isNotEmpty()) { currentQueue.removeAt(0) _questionQueue.value = currentQueue _currentQuestion.value = currentQueue.firstOrNull() } } fun dismissCurrentMessageEvent() { _currentMessageEvent.value = null } fun dismissCurrentRelationshipEvent() { _currentRelationshipEvent.value = null } fun respondToRelationshipEvent(eventId: String, choiceId: String) { sendMessage(WebSocketCommands.relationshipEventResponse(eventId, choiceId)) } fun breakUp(partnerId: String) { sendMessage(WebSocketCommands.breakUp(partnerId)) } fun setupCharacter(name: String, age: Int, sex: String) { sendMessage(WebSocketCommands.characterSetup(name, age, sex)) } /** * Begin a new life from the death screen. [mode] is "heir" (continue the * lineage as the eldest living child, preserving familyPrestige/familyTree * and applying inheritance) or "fresh" (clean reset that clears the * generational layer). The server falls back to "fresh" if no heir exists. * Clears the local life summary so the recap doesn't linger. */ fun startNewLife(mode: String = "fresh") { sendMessage(WebSocketCommands.startNewLife(mode)) _lifeSummary.value = null } /** * Perform a player-initiated activity. The command builder is added in Wave 1; * the activity-selection UI is built in Wave 2 (T015b). */ fun performActivity(activityId: String) { sendMessage(WebSocketCommands.performActivity(activityId)) } /** * In-life restart: ends the current life and resets to character creation. * Guarded behind a confirmation dialog in the UI (Tier 3 polish). */ fun restart() { sendMessage(WebSocketCommands.restart()) } fun requestDataExport() { sendMessage(WebSocketCommands.exportData(_person.value.id)) } fun requestAccountDeletion() { sendMessage(WebSocketCommands.deleteAccount("DELETE")) } // MARK: - Chat Methods fun sendChatMessage(characterId: String, message: String) { sendMessage(WebSocketCommands.conversationResponse(characterId, message)) } // MARK: - Dating Methods fun swipeRight(_personId: String) { sendMessage(WebSocketCommands.swipeMatch()) } fun swipeLeft(_personId: String) { // No backend call required for left swipe; UI fetches the next candidate. } fun fetchSwipeCharacter() { sendMessage(WebSocketCommands.getSwipeCharacter()) } // MARK: - Activity Methods fun enrollInActivity(activityId: String) { sendMessage(WebSocketCommands.applyForExtracurricular(activityId)) } fun dropActivity(activityId: String) { sendMessage(WebSocketCommands.quitExtracurricular(activityId)) } fun applyForJob(jobId: String) { sendMessage(WebSocketCommands.applyForJob(jobId)) } fun fetchExtracurriculars() { sendMessage(WebSocketCommands.getExtraCurriculars()) } fun fetchDateIdeas(_characterId: String) { sendMessage(WebSocketCommands.getDateIdeas()) } fun goOnDate(dateIdeaId: String, characterId: String) { sendMessage(WebSocketCommands.startDate(dateIdeaId, characterId)) } // MARK: - Relationship Action Methods fun callCharacter(characterId: String) { sendMessage(WebSocketCommands.conversationInit(characterId)) } fun giftCharacter(characterId: String) { sendMessage(WebSocketCommands.partnerGift(characterId)) } fun updateAffinity(personId: String, change: Int) { if (change == 0) return // Date mini-game affinity scoring is currently client-side; keep state in sync // without routing an unsupported command through backend fallback handlers. _player.value = _player.value.copy( r = _player.value.r.map { person -> if (person.id == personId) { person.copy(affinity = (person.affinity + change).coerceIn(-100, 100)) } else { person } } ) } // MARK: - IAP Methods fun sendPurchaseComplete(productId: String, purchaseToken: String) { sendMessage(WebSocketCommands.purchaseInAppItem(productId, purchaseToken)) } // MARK: - Push Token Methods fun registerPushToken(token: String) { sendMessage(WebSocketCommands.deviceToken(token)) } // MARK: - Store Methods fun purchaseStoreItem(itemId: String) { sendMessage(WebSocketCommands.purchaseItem(itemId)) } fun fetchStoreItems() { // Store items are delivered via playerObject; there is no dedicated getStoreItems command. } }