package com.craigvg.lichun_android.domain.models import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachMoney import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.DirectionsRun import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.People import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import com.craigvg.lichun_android.ui.theme.AppColors import kotlinx.serialization.Serializable /** * Wave 2 fun/balance models — Android parity with iOS Engagement.swift: * - deeper quest-loop engagement (full-clear streak + weekly challenge), * - the achievement collection summary (per-category locked + unlocked entries * with hints), and * - the static player-initiated activity catalog (performActivity action UI). * * Wire shapes (match server EXACTLY): * - questEngagement (server/src/services/retention/dailyQuests.ts * PersistedQuestEngagement + ActiveWeeklyChallenge) — rides in playerObject. * - achievement `summary` (server/src/services/retention/achievements.ts * AchievementSummary / CategorySummary / CollectionEntry) — rides inside the * `achievementsList` message as `summary`. * - PLAYER_ACTIVITIES (server intradayActivity.ts) — mirrored client-side. * * All fields default so older / partial payloads still decode under the lenient * Json config used by WebSocketManager. */ // --------------------------------------------------------------------------- // Quest Engagement (deeper quest loop) // --------------------------------------------------------------------------- @Serializable data class QuestEngagementSnapshot( val fullClearStreak: Int = 0, val lastFullClearDate: String? = null, val lastStreakBonusDate: String? = null, val weekly: WeeklyChallenge? = null ) { /** Days remaining until the next streak bonus fires (1..threshold). */ val daysToNextBonus: Int get() { val t = STREAK_BONUS_THRESHOLD if (t <= 0) return 0 val remainder = fullClearStreak % t return if (remainder == 0) t else t - remainder } companion object { /** Mirrors STREAK_BONUS_THRESHOLD / STREAK_BONUS_REWARD on the server. */ const val STREAK_BONUS_THRESHOLD = 3 const val STREAK_BONUS_REWARD = 10 } } @Serializable data class WeeklyChallenge( val id: String = "", val questType: String = "", val description: String = "", val progress: Int = 0, val progressRequired: Int = 0, val diamondReward: Int = 0, val completed: Boolean = false, val claimed: Boolean = false, val weekKey: String = "", val iconName: String = "" ) { val progressFraction: Float get() { if (progressRequired <= 0) return if (completed) 1f else 0f return (progress.toFloat() / progressRequired.toFloat()).coerceIn(0f, 1f) } val progressText: String get() = "${minOf(progress, progressRequired)}/$progressRequired" val canClaim: Boolean get() = completed && !claimed } // --------------------------------------------------------------------------- // Achievement Collection Summary // --------------------------------------------------------------------------- @Serializable data class AchievementSummary( val total: Int = 0, val unlocked: Int = 0, val progressPercent: Int = 0, /** Category key -> summary. Keys: life_milestone, career, relationship, collection, secret. */ val byCategory: Map = emptyMap() ) { /** Categories in display order, filtered to those the payload carries. */ val orderedCategories: List> get() { val order = listOf("life_milestone", "career", "relationship", "collection", "secret") val known = order.mapNotNull { key -> byCategory[key]?.let { key to it } } // Surface any extra/unknown categories the server may add later. val extras = byCategory.filterKeys { it !in order }.map { it.key to it.value } return known + extras } } @Serializable data class CategorySummary( val total: Int = 0, val unlocked: Int = 0, val progressPercent: Int = 0, val entries: List = emptyList() ) { val progressFraction: Float get() = (progressPercent.coerceIn(0, 100)).toFloat() / 100f } @Serializable data class CollectionEntry( val key: String = "", val name: String = "", val description: String = "", val icon: String = "", val reward: Int = 0, val category: String = "", val unlocked: Boolean = false, val hidden: Boolean = false, val hint: String = "", val progressPercent: Int = 0 ) { /** Hidden + locked achievements stay a mystery. */ val displayName: String get() = if (hidden && !unlocked) "???" else name /** Hint for locked, description for unlocked. */ val displaySubtitle: String get() = if (unlocked) description else hint.ifEmpty { description } val progressFraction: Float get() = (progressPercent.coerceIn(0, 100)).toFloat() / 100f } /** Friendly display labels for the server's achievement category keys. */ object AchievementCategoryDisplay { fun title(key: String): String = when (key) { "life_milestone" -> "Life Milestones" "career" -> "Career" "relationship" -> "Relationships" "collection" -> "Collection" "secret" -> "Secret" else -> key.replace("_", " ").replaceFirstChar { it.uppercase() } } fun color(key: String): Color = when (key) { "life_milestone" -> AppColors.prestige "career" -> AppColors.secondary "relationship" -> AppColors.primary "collection" -> AppColors.accent "secret" -> AppColors.intelligence else -> AppColors.money } } // --------------------------------------------------------------------------- // Player Activity Catalog (performActivity command) // --------------------------------------------------------------------------- /** * Client-side mirror of the server PLAYER_ACTIVITIES catalog. Drives the * performActivity action UI. [id] is sent in the `performActivity` payload. */ data class PlayerActivityInfo( val id: String, val title: String, val blurb: String, val energyCost: Int, val minAge: Int, val icon: ImageVector, val color: Color ) { companion object { val all: List = listOf( PlayerActivityInfo( id = "study", title = "Study", blurb = "Sharpen your mind. +Intelligence, a little more stress.", energyCost = 10, minAge = 0, icon = Icons.Default.MenuBook, color = AppColors.intelligence ), PlayerActivityInfo( id = "exercise", title = "Exercise", blurb = "Get moving. +Health, -Stress.", energyCost = 15, minAge = 0, icon = Icons.Default.DirectionsRun, color = AppColors.health ), PlayerActivityInfo( id = "socialize", title = "Socialize", blurb = "Call a friend. +Social, +Happiness, -Stress.", energyCost = 8, minAge = 0, icon = Icons.Default.People, color = AppColors.friend ), PlayerActivityInfo( id = "sideHustle", title = "Side Hustle", blurb = "Earn extra cash. +Money, a little more stress. (Age 14+)", energyCost = 18, minAge = 14, icon = Icons.Default.AttachMoney, color = AppColors.money ), PlayerActivityInfo( id = "hobby", title = "Hobby", blurb = "Make something. +Creativity, +Happiness, -Stress.", energyCost = 6, minAge = 0, icon = Icons.Default.Brush, color = AppColors.happiness ) ) } } // --------------------------------------------------------------------------- // Floating Resource/Stat Delta (Tier 3 polish: animated gains/losses) // --------------------------------------------------------------------------- /** * A single transient stat/resource change emitted when a lightweight "u" update * changes a tracked value. Rendered once by FloatingDeltaOverlay, then pruned. */ data class StatDelta( val id: String = java.util.UUID.randomUUID().toString(), val kind: Kind, val amount: Int ) { enum class Kind(val emoji: String, val tint: Color) { ENERGY("⚡", AppColors.energy), MONEY("💰", AppColors.money), DIAMONDS("💎", AppColors.diamond), HEALTH("❤️", AppColors.health), HAPPINESS("😊", AppColors.happiness) } /** Always sign-prefixed so direction reads at a glance. */ val label: String get() = if (amount > 0) "+$amount" else "$amount" } // --------------------------------------------------------------------------- // Unified Game Speed Scale (Tier 3 polish: one named vocabulary everywhere) // --------------------------------------------------------------------------- /** * Single source of truth for the named speed vocabulary used across the UI * (Home controls, status). The six raw server tick values map onto four * player-facing names: Slow / Normal / Fast / Instant. Mirrors iOS * Constants.GameSpeed (T014). Lower raw tick value = faster simulation. */ object GameSpeedScale { const val SLOWEST = 10000 const val SLOW = 1000 const val NORMAL = 500 const val FAST = 50 const val FASTEST = 20 const val INSTANT = 1 /** Ordered slow -> fast. Index drives the pip meter + step up/down. */ val allLevels = listOf(SLOWEST, SLOW, NORMAL, FAST, FASTEST, INSTANT) /** How many of the 6 pips are lit for a given raw speed (0 when paused/unknown). */ fun pipLevel(rawValue: Int): Int { val idx = allLevels.indexOf(rawValue) if (idx >= 0) return idx + 1 // Approximate by proximity so a non-canonical value still lights pips. return when { rawValue >= SLOW -> 2 rawValue >= NORMAL -> 3 rawValue >= FASTEST -> 5 else -> 6 } } /** The four player-facing names. */ fun label(rawValue: Int): String = when (rawValue) { SLOWEST, SLOW -> "Slow" NORMAL -> "Normal" FAST, FASTEST -> "Fast" INSTANT -> "Instant" else -> when { rawValue >= SLOW -> "Slow" rawValue >= NORMAL -> "Normal" rawValue >= FASTEST -> "Fast" else -> "Instant" } } /** The raw value one step faster (lower tick), clamped to INSTANT. */ fun faster(rawValue: Int): Int { val idx = allLevels.indexOf(rawValue).let { if (it < 0) nearestIndex(rawValue) else it } return allLevels[(idx + 1).coerceAtMost(allLevels.lastIndex)] } /** The raw value one step slower (higher tick), clamped to SLOWEST. */ fun slower(rawValue: Int): Int { val idx = allLevels.indexOf(rawValue).let { if (it < 0) nearestIndex(rawValue) else it } return allLevels[(idx - 1).coerceAtLeast(0)] } private fun nearestIndex(rawValue: Int): Int { var best = 0 var bestDist = Int.MAX_VALUE allLevels.forEachIndexed { i, v -> val d = kotlin.math.abs(v - rawValue) if (d < bestDist) { bestDist = d; best = i } } return best } }