local _, ns = ...
local MM = ns.MM

local HATCH_SOUND_INDEX = 114
local PET_NAME_MAX_CHARS = 20

local FOOD_ITEMS = {
    {
        key = "kelpSnack",
        label = "Kelp Snack",
        icon = "Interface\\Icons\\INV_Misc_Herb_SeaStalk",
        price = 16,
        hunger = 16,
        joy = 1,
        hatch = 8,
    },
    {
        key = "tideWorm",
        label = "Tide Worm",
        icon = "Interface\\Icons\\INV_Fishingpole_02",
        price = 30,
        hunger = 24,
        joy = 2,
        hatch = 12,
    },
    {
        key = "bubbleBiscuit",
        label = "Bubble Biscuit",
        icon = "Interface\\Icons\\INV_Misc_Food_19",
        price = 50,
        hunger = 30,
        joy = 4,
        hatch = 18,
    },
    {
        key = "reefJerky",
        label = "Reef Jerky",
        icon = "Interface\\Icons\\INV_Misc_Food_142_Fish",
        price = 72,
        hunger = 38,
        joy = 5,
        hatch = 22,
    },
    {
        key = "tideFeast",
        label = "Tide Feast",
        icon = "Interface\\Icons\\INV_Misc_Food_65",
        price = 104,
        hunger = 48,
        joy = 6,
        hatch = 28,
    },
}

local FOOD_ITEM_BY_KEY = {}
for _, item in ipairs(FOOD_ITEMS) do
    FOOD_ITEM_BY_KEY[item.key] = item
end

local STORE_CATALOG = {
    {
        key = "feedingBowl",
        label = "Feeding Bowl",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Bowl_01",
        price = 280,
        maxOwned = 1,
        description = "Upgrade: food effects are 10% stronger.",
    },
    {
        key = "tideLadle",
        label = "Tide Ladle",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Bag_10_Blue",
        price = 240,
        maxOwned = 1,
        description = "Upgrade: Permanent +4 hunger from all food.",
    },
    {
        key = "snackTongs",
        label = "Snack Tongs",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Weapon_ShortBlade_17",
        price = 200,
        maxOwned = 1,
        description = "Upgrade: Permanent +2 joy from all food.",
    },
    {
        key = "rationLedger",
        label = "Ration Ledger",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Note_01",
        price = 310,
        maxOwned = 1,
        description = "Upgrade: Permanent 12% chance feeding does not consume food.",
    },
    {
        key = "brineSpiceRack",
        label = "Brine Spice Rack",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Food_15",
        price = 340,
        maxOwned = 1,
        description = "Upgrade: food effects are another 8% stronger.",
    },
    {
        key = "toyBall",
        label = "Bouncy Ball",
        category = "play",
        icon = "Interface\\Icons\\INV_Misc_SoccerBall",
        price = 400,
        maxOwned = 1,
        description = "Toy: +18 joy. Reusable, but can be lost over time.",
        joyBonus = 18,
        riskChance = 0.05,
        riskIncreasePerUse = 0.008,
        riskMaxChance = 0.90,
        breakText = "The bouncy ball rolled away and was lost.",
    },
    {
        key = "stickFigure",
        label = "Stick Figure",
        category = "play",
        icon = "Interface\\Icons\\INV_Misc_Primitive_Toy05",
        price = 50,
        maxOwned = 1,
        description = "Toy: +8 joy. Reusable, but can break over time.",
        joyBonus = 8,
        riskChance = 0.10,
        riskIncreasePerUse = 0.010,
        riskMaxChance = 0.90,
        breakText = "The stick figure snapped during play.",
    },
    {
        key = "shellKite",
        label = "Shell Kite",
        category = "play",
        icon = "Interface\\Icons\\INV_Misc_Necklace_Shell05",
        price = 750,
        maxOwned = 1,
        description = "Toy: +24 joy. Reusable, but can be lost over time.",
        joyBonus = 24,
        riskChance = 0.08,
        riskIncreasePerUse = 0.009,
        riskMaxChance = 0.90,
        breakText = "The shell kite tore free and drifted away.",
    },
    {
        key = "squeakyCrab",
        label = "Squeaky Crab",
        category = "play",
        icon = "Interface\\Icons\\INV_Crab2_Orange",
        price = 240,
        maxOwned = 1,
        description = "Toy: +12 joy. Reusable, but can break over time.",
        joyBonus = 12,
        riskChance = 0.04,
        riskIncreasePerUse = 0.007,
        riskMaxChance = 0.90,
        breakText = "The squeaky crab popped during rough play.",
    },
    {
        key = "splashDrum",
        label = "Splash Drum",
        category = "play",
        icon = "Interface\\Icons\\INV_OffHand_1H_DraenorQuest_B_01",
        price = 1000,
        maxOwned = 1,
        description = "Toy: +30 joy. Reusable, but can break over time.",
        joyBonus = 30,
        riskChance = 0.06,
        riskIncreasePerUse = 0.008,
        riskMaxChance = 0.90,
        breakText = "The splash drum split after a loud jam session.",
    },
    {
        key = "shellPillow",
        label = "Moss Pillow",
        category = "rest",
        icon = "Interface\\Icons\\INV_Cape_Special_TurtleShell_C_01",
        price = 50,
        maxOwned = 1,
        description = "Rest item: +8 energy. Reusable, but can wear out.",
        restBonus = 8,
        riskChance = 0.03,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The shell pillow flattened out and finally gave up.",
    },
    {
        key = "tideBlanket",
        label = "Tide Blanket",
        category = "rest",
        icon = "Interface\\Icons\\INV_Fabric_Wool_03",
        price = 240,
        maxOwned = 1,
        description = "Rest item: +12 energy. Reusable, but can wear out.",
        restBonus = 12,
        riskChance = 0.04,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The tide blanket frayed into wet strands.",
    },
    {
        key = "kelpHammock",
        label = "Kelp Hammock",
        category = "rest",
        icon = "Interface\\Icons\\INV_Drink_Waterskin_04",
        price = 1000,
        maxOwned = 1,
        description = "Rest item: +30 energy. Reusable, but can wear out.",
        restBonus = 30,
        riskChance = 0.05,
        riskIncreasePerUse = 0.007,
        riskMaxChance = 0.90,
        breakText = "The kelp hammock snapped at the knots.",
    },
    {
        key = "moonLantern",
        label = "Moon Lantern",
        category = "rest",
        icon = "Interface\\Icons\\INV_Potion_20",
        price = 400,
        maxOwned = 1,
        description = "Rest item: +18 energy. Reusable, but can burn out.",
        restBonus = 18,
        riskChance = 0.04,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The moon lantern flickered out for good.",
    },
    {
        key = "driftMattress",
        label = "Drift Mattress",
        category = "rest",
        icon = "Interface\\Icons\\INV_Misc_ArmorKit_30",
        price = 750,
        maxOwned = 1,
        description = "Rest item: +24 energy. Reusable, but can wear out.",
        restBonus = 24,
        riskChance = 0.06,
        riskIncreasePerUse = 0.007,
        riskMaxChance = 0.90,
        breakText = "The drift mattress split at the seams.",
    },
    {
        key = "bubbleSponge",
        label = "Bubble Sponge",
        category = "clean",
        icon = "Interface\\Icons\\INV_Misc_Fish_19",
        price = 50,
        maxOwned = 1,
        description = "Cleaning item: +8 cleanliness. Reusable, but can wear out.",
        cleanBonus = 8,
        riskChance = 0.03,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The bubble sponge dissolved into suds.",
    },
    {
        key = "coralBrush",
        label = "Coral Brush",
        category = "clean",
        icon = "Interface\\Icons\\INV_Misc_Herb_19",
        price = 400,
        maxOwned = 1,
        description = "Cleaning item: +18 cleanliness. Reusable, but can wear out.",
        cleanBonus = 18,
        riskChance = 0.04,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The coral brush bristles cracked away.",
    },
    {
        key = "tideSoap",
        label = "Tide Soap",
        category = "clean",
        icon = "Interface\\Icons\\INV_Misc_Dust_02",
        price = 240,
        maxOwned = 1,
        description = "Cleaning item: +12 cleanliness. Reusable, but can wear out.",
        cleanBonus = 12,
        riskChance = 0.035,
        riskIncreasePerUse = 0.006,
        riskMaxChance = 0.90,
        breakText = "The tide soap bar finally crumbled away.",
    },
    {
        key = "sandScraper",
        label = "Sand Scraper",
        category = "clean",
        icon = "Interface\\Icons\\INV_Misc_ArmorKit_05",
        price = 750,
        maxOwned = 1,
        description = "Cleaning item: +24 cleanliness. Reusable, but can wear out.",
        cleanBonus = 24,
        riskChance = 0.05,
        riskIncreasePerUse = 0.007,
        riskMaxChance = 0.90,
        breakText = "The sand scraper edge wore down to nothing.",
    },
    {
        key = "pearlRinse",
        label = "Pearl Rinse",
        category = "clean",
        icon = "Interface\\Icons\\INV_Potion_22",
        price = 1000,
        maxOwned = 1,
        description = "Cleaning item: +30 cleanliness. Reusable, but can wear out.",
        cleanBonus = 30,
        riskChance = 0.06,
        riskIncreasePerUse = 0.007,
        riskMaxChance = 0.90,
        breakText = "The pearl rinse bottle emptied and cracked.",
    },
    {
        key = "shellCollar",
        label = "Shell Collar",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Jewelry_Necklace_17",
        price = 2400,
        maxOwned = 1,
        description = "Trinket: 2x shells from scavenging ticks.",
    },
    {
        key = "luckyCharm",
        label = "Lucky Charm",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_LuckyMoneyEnvelope",
        price = 1000,
        maxOwned = 1,
        description = "Trinket: +10% shells from challenge rewards.",
    },
    {
        key = "tinySatchel",
        label = "Tiny Satchel",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Bag_08",
        price = 900,
        maxOwned = 1,
        description = "Trinket: +1 flat shell on each paid scavenging tick.",
    },
    {
        key = "napWhistle",
        label = "Nap Whistle",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Flute_01",
        price = 840,
        maxOwned = 1,
        description = "Trinket: rest gains are 10% stronger.",
    },
    {
        key = "playBell",
        label = "Play Bell",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Bell_01",
        price = 840,
        maxOwned = 1,
        description = "Trinket: +10% joy gained from play.",
    },
    {
        key = "nameTag",
        label = "Nametag",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Note_01",
        price = 1600,
        description = "Use item: rename your murloc.",
    },
    {
        key = "traitGem",
        label = "Crackling Trait Gem",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Gem_Diamond_02",
        price = 5000,
        description = "Break: reroll your secondary trait.",
    },
    {
        key = "traitLamp",
        label = "Whispering Trait Lamp",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Misc_Lantern_01",
        price = 6000,
        description = "Rub: reroll your primary trait.",
    },
    {
        key = "transmuteOrb",
        label = "Abyssal Transmutation Orb",
        category = "trinket",
        icon = "Interface\\Icons\\INV_Alchemy_Elixir_04",
        price = 100000,
        description = "Shatter: reroll your murloc form to Rare or better while keeping name and inventory.",
    },
    {
        key = "toyChest",
        label = "Toy Chest",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Crate_03",
        price = 360,
        maxOwned = 1,
        description = "Habitat upgrade: lowers toy break/loss chance.",
    },
    {
        key = "cozyNest",
        label = "Cozy Nest",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Pelt_Bear_03",
        price = 320,
        maxOwned = 1,
        description = "Habitat upgrade: rest items recover 20% more energy.",
    },
    {
        key = "washStation",
        label = "Wash Station",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_ArmorKit_05",
        price = 320,
        maxOwned = 1,
        description = "Habitat upgrade: cleaning items restore 20% more cleanliness.",
    },
    {
        key = "tidepoolFilter",
        label = "Tidepool Filter",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Gizmo_02",
        price = 420,
        maxOwned = 1,
        description = "Habitat upgrade: cleanliness drains 12% slower over time.",
    },
    {
        key = "coralFountain",
        label = "Coral Fountain",
        category = "habitat",
        icon = "Interface\\Icons\\INV_Misc_Gem_Pearl_05",
        price = 440,
        maxOwned = 1,
        description = "Habitat upgrade: joy drains 10% slower over time.",
    },
}

local BESPOKE_ITEM_DESCRIPTIONS = {
    kelpSnack = "Soft sea-stalk strips with a salty crunch. A cheap starter snack for quick hunger relief.",
    tideWorm = "Wriggly tide worms packed with protein. Reliable for steady growth between big meals.",
    bubbleBiscuit = "Airy shell-baked biscuits that pop with brine. A balanced meal for day-to-day care.",
    reefJerky = "Sun-dried reef meat with dense flavor. Keeps hunger down during longer scavenging stretches.",
    tideFeast = "A lavish platter of deepwater cuts and kelp garnish. Maximum comfort food for serious recovery.",

    stickFigure = "A carved driftwood doll that squeaks when tossed; simple, sturdy, and always funny.",
    squeakyCrab = "A bright crab toy with a rude squeak that keeps even grumpy murlocs entertained.",
    toyBall = "A springy tideball that ricochets off coral walls for fast, energetic play.",
    shellKite = "A shell-stitched glider that rides indoor currents and turns playtime into chase drills.",
    splashDrum = "A thunderous little drum that starts full-volume mrrgl concerts on demand.",

    shellPillow = "A damp moss pillow that molds to the shell and knocks the edge off fatigue.",
    tideBlanket = "A warm kelp-thread blanket that settles restless wiggles before sleep.",
    moonLantern = "A soft blue lantern that bathes the habitat in sleepy moonlight.",
    driftMattress = "A buoyant mattress woven from drift fibers for deep, uninterrupted naps.",
    kelpHammock = "A suspended kelp cradle that rocks gently with every tidepool ripple.",

    bubbleSponge = "A foamy sponge that lifts everyday grime without rough scrubbing.",
    tideSoap = "Mineral-rich soap that cuts through muck and leaves scales slick and bright.",
    coralBrush = "A fine coral-bristle brush for detail cleaning between scales and fins.",
    sandScraper = "A rigid scraper for stubborn barnacle grit and dried tide crust.",
    pearlRinse = "A premium rinse blend that restores a polished, pearl-like sheen.",
}

local CARE_ITEM_RISK_WARNING = "Chance to be lost or broken over time."

local function AppendCareRiskWarning(text)
    local desc = tostring(text or ""):gsub("%s+$", "")
    if desc == "" then
        return CARE_ITEM_RISK_WARNING
    end
    local lower = string.lower(desc)
    if lower:find("lost or broken over time", 1, true) then
        return desc
    end
    return string.format("%s %s", desc, CARE_ITEM_RISK_WARNING)
end

for _, item in ipairs(FOOD_ITEMS) do
    local bespoke = BESPOKE_ITEM_DESCRIPTIONS[item.key]
    if bespoke and bespoke ~= "" then
        item.description = bespoke
    end
end
for _, entry in ipairs(STORE_CATALOG) do
    if entry.category == "play" or entry.category == "rest" or entry.category == "clean" then
        local bespoke = BESPOKE_ITEM_DESCRIPTIONS[entry.key]
        if bespoke and bespoke ~= "" then
            entry.description = bespoke
        end
        entry.description = AppendCareRiskWarning(entry.description)
    end
end

local STORE_SECTIONS = {
    { key = "food", label = "Food" },
    { key = "play", label = "Play Items" },
    { key = "rest", label = "Rest Items" },
    { key = "clean", label = "Clean Items" },
    { key = "trinket", label = "Trinkets" },
    { key = "habitat", label = "Habitat Upgrades" },
}

local HABITAT_CUSTOMIZATION_CATALOG = {
    {
        key = "auto",
        label = "Default Habitat",
        price = 0,
        description = "Default habitat background (free).",
    },
    {
        key = "elwynn_housing",
        label = "Elwynn",
        price = 2000,
        description = "A calm Elwynn backdrop for your habitat.",
    },
    {
        key = "twin_peaks",
        label = "Twin Peaks",
        price = 3000,
        description = "PvP valley cliffs and banners.",
    },
    {
        key = "murloc_shop",
        label = "Murloc Habitat",
        price = 4000,
        description = "A cozy murloc den backdrop.",
    },
    {
        key = "exiles_reach",
        label = "Exile's Reach",
        price = 5000,
        description = "Training-island shoreline ambience.",
    },
    {
        key = "scene_plunderstorm",
        label = "Plunderstorm Harbor",
        price = 10000,
        description = "A stormy harbor scene from Plunderstorm.",
    },
}
local HABITAT_CUSTOMIZATION_BY_KEY = {}
for _, entry in ipairs(HABITAT_CUSTOMIZATION_CATALOG) do
    HABITAT_CUSTOMIZATION_BY_KEY[entry.key] = entry
end

local TITLE_REWARD_CATALOG = {
    { key = "betrayer", achievementID = "MM-GEN-RAR-005", label = "The Betrayer", format = "NAME <The Betrayer>" },
    { key = "tidekeeper", achievementID = "MM-CARE-TOTAL-005", label = "Tidekeeper", format = "<Tidekeeper> NAME" },
    { key = "cardbreaker", achievementID = "MM-CHAL-COMP-005", label = "Cardbreaker", format = "NAME <Cardbreaker>" },
    { key = "waveforged", achievementID = "MM-SCAV-TICK-005", label = "Waveforged", format = "<Waveforged> NAME" },
    { key = "tycoon", achievementID = "MM-ECO-EARN-005", label = "Tycoon", format = "<Tycoon> NAME" },
    { key = "vaultborn", achievementID = "MM-ECO-BAL-005", label = "Vaultborn", format = "<Vaultborn> NAME" },
    { key = "overflow", achievementID = "MM-ECO-NET-005", label = "of the Overflow", format = "NAME <of the Overflow>" },
    { key = "dissolver", achievementID = "MM-ECO-SPEND-005", label = "the Dissolver", format = "NAME <the Dissolver>" },
    { key = "hoarder", achievementID = "MM-COLL-PURCH-TOTAL-005", label = "Hoarder", format = "<Hoarder> NAME" },
    { key = "wellfed", achievementID = "MM-COLL-PURCH-FOOD-005", label = "the Well-Fed", format = "NAME <the Well-Fed>" },
    { key = "calamity", achievementID = "MM-LOSS-TOTAL-005", label = "Calamity Magnet", format = "<Calamity Magnet> NAME" },
    { key = "shellsea_baron", achievementID = "MM-SCAV-EARN-005", label = "Shellsea Baron", format = "<Shellsea Baron> NAME" },
    { key = "tireless", achievementID = "MM-SCAV-START-005", label = "the Tireless", format = "NAME <the Tireless>" },
    { key = "taskmaster", achievementID = "MM-SCAV-RECALL-005", label = "Taskmaster", format = "<Taskmaster> NAME" },
    { key = "reroller", achievementID = "MM-CHAL-RELOAD-005", label = "the Reroller", format = "NAME <the Reroller>" },
    { key = "elder", achievementID = "MM-GEN-AGE-005", label = "Elder", format = "<Elder> NAME" },
}
local TITLE_REWARD_BY_KEY = {}
local TITLE_REWARD_BY_ACHIEVEMENT = {}
for _, entry in ipairs(TITLE_REWARD_CATALOG) do
    TITLE_REWARD_BY_KEY[tostring(entry.key or "")] = entry
    TITLE_REWARD_BY_ACHIEVEMENT[tostring(entry.achievementID or "")] = entry
end

local PERSONALITIES = {
    "Curious",
    "Playful",
    "Brash",
    "Calm",
    "Mischievous",
    "Responsible",
    "Lazy",
    "Loyal",
    "Greedy",
    "Shy",
    "Bold",
    "Neat",
    "Gluttonous",
    "Dreamy",
    "Stubborn",
    "Optimistic",
    "Resourceful",
}
local SECONDARY_TRAIT_WEIGHT = 0.75
local MURLOC_MODEL_RARITY_ORDER = { "common", "uncommon", "rare", "epic", "legendary", "rarest" }
local MURLOC_MODEL_RARITY_RANK = {}
for index, rarity in ipairs(MURLOC_MODEL_RARITY_ORDER) do
    MURLOC_MODEL_RARITY_RANK[rarity] = index
end
local MURLOC_MODEL_RARITY_LABELS = {
    common = "Common",
    uncommon = "Uncommon",
    rare = "Rare",
    epic = "Epic",
    legendary = "Legendary",
    rarest = "Rarest",
}
local MURLOC_MODEL_RARITY_COLORS = {
    common = { 1.0, 1.0, 1.0 },
    uncommon = { 0.12, 1.0, 0.0 },
    rare = { 0.0, 0.44, 0.87 },
    epic = { 0.64, 0.21, 0.93 },
    legendary = { 1.0, 0.50, 0.0 },
    rarest = { 1.0, 0.50, 0.0 },
}
local MURLOC_MODEL_RARITY_WEIGHTS = {
    common = 70.0,
    uncommon = 20.0,
    rare = 6.0,
    epic = 3.0,
    legendary = 0.75,
    rarest = 0.25,
}
local MURLOC_MODEL_POOL = {
    { displayID = 31, rarity = "common", label = "Green Murloc" },
    { displayID = 617, rarity = "common", label = "Blue Murloc" },
    { displayID = 1755, rarity = "common", label = "Orange Murloc" },

    { displayID = 3619, rarity = "uncommon", label = "White/Red Murloc" },
    { displayID = 91234, rarity = "uncommon", label = "Red Murloc" },
    { displayID = 125385, rarity = "uncommon", label = "Pale Yellow/Brown Murloc" },

    { displayID = 5243, rarity = "rare", label = "Pale Purple Murloc" },
    { displayID = 96258, rarity = "rare", label = "Purple/Teal Murloc" },
    { displayID = 110413, rarity = "rare", label = "Deep Blue/Yellow Murloc" },
    { displayID = 52277, rarity = "rare", label = "Deep Purple/Orange Murloc" },

    { displayID = 117049, rarity = "epic", label = "Sharkbait" },
    { displayID = 60604, rarity = "epic", label = "Deep Purple/White Murloc" },
    { displayID = 86464, rarity = "epic", label = "Undead Deep Murloc" },

    { displayID = 105017, rarity = "legendary", label = "Green Murloc Hulk" },
    { displayID = 113798, rarity = "legendary", label = "Purple Murloc Hulk" },
    { displayID = 23323, rarity = "legendary", label = "Odd Hatch Form 23323" },
    { displayID = 22607, rarity = "legendary", label = "Odd Hatch Form 22607" },
    { displayID = 140107, rarity = "legendary", label = "Odd Hatch Form 140107" },
    { displayID = 86468, rarity = "legendary", label = "Odd Hatch Form 86468" },
    { displayID = 121741, rarity = "legendary", label = "Odd Hatch Form 121741" },

    { displayID = 69326, rarity = "rarest", label = "You Are Not Prepared" },
}

local WORK_SHELLS_PER_TICK = 3
local EGG_NATURAL_HATCH_PER_TICK = 0.11
local NEEDS_DECAY_TICK_SECONDS = 10
local NEED_POINTS_SCALE = 10
local NEED_POINTS_MAX = 1000
local BASE_NEED_DECAY_PER_TICK = {
    hunger = 7.0,
    joy = 2.75,
    energy = 2.25,
    cleanliness = 2.0,
}
local MIN_BASE_NEED_DRAIN_PER_TICK = 1.0
local SCAVENGE_DRAIN_BONUS_MULT = 0.50
local CHALLENGE_REPLACE_FADE_SECONDS = 0.6
local EGG_CHALLENGE_HATCH_BONUS_MIN = 2
local EGG_CHALLENGE_HATCH_BONUS_MAX = 6
local EGG_CARE_COOLDOWN = 30
local EGG_CARE_PROGRESS_CAP = 20
local CHALLENGE_REROLL_COOLDOWN = 30
local CHALLENGE_REROLL_COST_ADOLESCENT = 20
local CHALLENGE_RELOAD_COST = 20
local CHALLENGE_RELOAD_COOLDOWN = 30
local ADOLESCENT_ACTION_COOLDOWN = 5
local FREE_ACTION_KEY = "__free__"
local BASIC_ACTION_VALUE = 5
local GATHERING_SCAVENGE_BONUS_DURATION = 300
local GATHERING_SCAVENGE_BONUS_FLAT_PER_TICK = 5
local WORK_RESTART_COOLDOWN = 60
local AUTON_ACTION_TYPES = { "play", "clean", "rest", "work", "eat" }
local AUTON_BUCKET_NO_ACTION = 0.55
local AUTON_BUCKET_BENIGN = 0.35

local function RollEggChallengeHatchReward()
    return math.random(EGG_CHALLENGE_HATCH_BONUS_MIN, EGG_CHALLENGE_HATCH_BONUS_MAX)
end
local AUTON_BUCKET_IMPACTFUL = 0.10
local AUTONOMOUS_MESSAGE_OVERRIDES = ns.AutonomousMessageOverrides or _G.MurlocMinderAutonomousMessageOverrides or {}
local AUTONOMOUS_PAIR_EFFECT_OVERRIDES = AUTONOMOUS_MESSAGE_OVERRIDES.pairEffect or {}
local AUTONOMOUS_BENIGN_PAIR_OVERRIDES = AUTONOMOUS_MESSAGE_OVERRIDES.benignPair or {}
local CARE_ITEM_CATEGORIES = {
    play = true,
    rest = true,
    clean = true,
}
local FIND_ITEM_BUCKET_WEIGHTS = {
    { pool = "food", tier_min = 1, tier_max = 1, weight = 44.0 },
    { pool = "food", tier_min = 2, tier_max = 2, weight = 34.0 },
    { pool = "food", tier_min = 3, tier_max = 3, weight = 10.0 },
    { pool = "food", tier_min = 4, tier_max = 4, weight = 3.0 },
    { pool = "food", tier_min = 5, tier_max = 5, weight = 1.0 },
    { pool = "care", tier_min = 1, tier_max = 1, weight = 4.0 },
    { pool = "care", tier_min = 2, tier_max = 2, weight = 3.0 },
    { pool = "care", tier_min = 3, tier_max = 3, weight = 0.9 },
    { pool = "care", tier_min = 4, tier_max = 4, weight = 0.5 },
    { pool = "care", tier_min = 5, tier_max = 5, weight = 0.3 },
}
local FIND_FOOD_ONLY_BUCKET_WEIGHTS = {
    { pool = "food", tier_min = 1, tier_max = 1, weight = 60.0 },
    { pool = "food", tier_min = 2, tier_max = 2, weight = 30.0 },
    { pool = "food", tier_min = 3, tier_max = 3, weight = 8.0 },
    { pool = "food", tier_min = 4, tier_max = 4, weight = 1.5 },
    { pool = "food", tier_min = 5, tier_max = 5, weight = 0.5 },
}
local TRAIT_NEED_KEYS = { "hunger", "joy", "energy", "cleanliness" }
local TRAIT_TRIGGER_NEED_EFFECTS = {
    feed = {
        curious = { joy = 10 },
        responsible = { cleanliness = 10 },
        greedy = { joy = 10 },
        gluttonous = { joy = 20, cleanliness = -20 },
        stubborn = { hunger = 10 },
        resourceful = { hunger = 10, joy = 10 },
    },
    play = {
        playful = { joy = 20 },
        brash = { joy = 10, cleanliness = -10 },
        mischievous = { joy = 20, cleanliness = -10 },
        bold = { joy = 20, energy = -10 },
        optimistic = { joy = 10 },
    },
    rest = {
        calm = { joy = 10, energy = 10 },
        lazy = { energy = 20, cleanliness = -10 },
        shy = { joy = 10, energy = 10 },
        dreamy = { energy = 20 },
        stubborn = { joy = 10, energy = 10 },
    },
    clean = {
        calm = { joy = 10, energy = 10 },
        responsible = { joy = 20, cleanliness = 10 },
        loyal = { joy = 20, cleanliness = 10 },
        neat = { joy = 20 },
    },
    shell_milestone = {
        greedy = { joy = 20 },
    },
}
local TRAIT_NEED_DRAIN_FLAT = {
    curious = { hunger = 0.75, joy = 0.75 },
    playful = { joy = 1.25, energy = 0.50 },
    brash = { energy = 1.25, cleanliness = 0.75 },
    calm = { energy = -1.00, cleanliness = -1.00 },
    mischievous = { joy = -0.50, cleanliness = 1.00 },
    responsible = { joy = -0.50, cleanliness = -1.00 },
    lazy = { energy = -1.00, cleanliness = 0.75 },
    loyal = { joy = -0.50, cleanliness = -0.50 },
    greedy = { hunger = 0.75, joy = 1.50 },
    shy = { joy = 0.75, energy = -0.75 },
    bold = { hunger = 0.50, energy = 1.50 },
    neat = { joy = -0.25, cleanliness = -1.00 },
    gluttonous = { hunger = 1.50, energy = 0.50 },
    dreamy = { energy = -1.00, cleanliness = 0.50 },
    stubborn = { energy = 0.50, cleanliness = -0.50 },
    optimistic = { joy = -0.75, energy = -0.25 },
    resourceful = { hunger = -0.75, joy = -0.25 },
}
local TRAIT_ACTION_POLARITY_BIAS = {
    playful = {
        positive = { play = 1.28, eat = 1.08 },
        negative = { play = 0.90, rest = 0.94 },
    },
    responsible = {
        positive = { clean = 1.34, work = 1.12 },
        negative = { clean = 0.88, work = 0.94 },
    },
    mischievous = {
        positive = { play = 0.92, clean = 0.90 },
        negative = { play = 1.30, clean = 1.18 },
    },
    curious = {
        positive = { eat = 1.18, play = 1.10 },
        negative = { play = 1.08, work = 0.92 },
    },
    calm = {
        positive = { rest = 1.26, clean = 1.10 },
        negative = { rest = 0.84, play = 0.90 },
    },
    brash = {
        positive = { work = 1.16, play = 1.08 },
        negative = { play = 1.18, clean = 1.10 },
    },
    lazy = {
        positive = { rest = 1.30, play = 1.06 },
        negative = { work = 1.20, clean = 1.08 },
    },
    loyal = {
        positive = { work = 1.22, clean = 1.08 },
        negative = { play = 0.92, work = 0.92 },
    },
    greedy = {
        positive = { work = 1.30, eat = 1.08 },
        negative = { work = 1.18, clean = 1.12 },
    },
    shy = {
        positive = { rest = 1.18, clean = 1.14 },
        negative = { play = 1.18, work = 0.90 },
    },
    bold = {
        positive = { play = 1.22, work = 1.16 },
        negative = { play = 1.20, rest = 0.90 },
    },
    neat = {
        positive = { clean = 1.36, rest = 1.06 },
        negative = { clean = 0.86, play = 0.94 },
    },
    gluttonous = {
        positive = { eat = 1.34, rest = 1.04 },
        negative = { eat = 1.16, work = 1.08 },
    },
    dreamy = {
        positive = { rest = 1.34, play = 1.06 },
        negative = { work = 1.22, clean = 1.06 },
    },
    stubborn = {
        positive = { work = 1.12, eat = 1.08 },
        negative = { rest = 1.10, play = 1.08 },
    },
    optimistic = {
        positive = { play = 1.20, rest = 1.12 },
        negative = { play = 0.90, work = 0.94 },
    },
    resourceful = {
        positive = { eat = 1.22, work = 1.16 },
        negative = { eat = 0.92, clean = 0.94 },
    },
}
local NEED_THRESHOLD_MESSAGES = {
    hunger = {
        maxed = {
            "%s is absolutely stuffed and refuses another bite.",
        },
        empty = {
            "%s is starving and barely shuffles through the tidepool.",
        },
    },
    joy = {
        maxed = {
            "%s is bursting with joy and can barely stay still.",
        },
        empty = {
            "%s seems deeply gloomy and uninterested in play.",
        },
    },
    energy = {
        maxed = {
            "%s is absolutely buzzing with energy.",
        },
        empty = {
            "%s is exhausted and can barely keep upright.",
        },
    },
    cleanliness = {
        maxed = {
            "%s is pristine and sparkling from fin to tail.",
        },
        empty = {
            "%s is caked in grime and really needs cleaning.",
        },
    },
}
local BENIGN_ACTIVITY_META = { source = "benign", tone = "benign" }
local TRAIT_CHALLENGE_REACTIONS = {
    curious = {
        "%s looks up at you like, 'what's next?' and waits for your call.",
        "%s circles your feet, eager for the next thing you do.",
    },
    playful = {
        "%s splashes around you in celebration and chirps for an encore.",
        "%s bumps your leg with a happy fin and wants to keep going.",
    },
    brash = {
        "%s puffs up and gives you a fierce little 'told you we'd win.'",
        "%s struts around you like your personal champion.",
    },
    calm = {
        "%s gives you a slow, steady nod like everything is under control.",
        "%s settles beside you with a quiet approving mrrgl.",
    },
    mischievous = {
        "%s gives you a cheeky grin like they already have a prank planned.",
        "%s throws a rude little emote your way, then laughs.",
    },
    responsible = {
        "%s gives you a proud worker nod and stands ready for the next task.",
        "%s straightens up beside you like a tiny foreman awaiting orders.",
    },
    lazy = {
        "%s flops at your feet with a smug 'job done' vibe.",
        "%s gives you a sleepy blink that says, 'we earned a break.'",
    },
    loyal = {
        "%s stays close to your side, clearly proud to do this with you.",
        "%s gives you a dependable little salute and waits for your lead.",
    },
    greedy = {
        "%s looks straight at you and pats their shell pouch for the payout.",
        "%s grins at you like, 'good work, now where are the shells?'",
    },
    shy = {
        "%s peeks up at you and offers a tiny proud smile.",
        "%s gives you a quiet nod, then scoots a little closer.",
    },
    bold = {
        "%s throws both fins up at you and celebrates like a hero.",
        "%s beams at you and dares you to queue up something harder.",
    },
    neat = {
        "%s gives you a satisfied look like everything finally feels in order.",
        "%s brushes themself off and gives you a tidy little nod.",
    },
    gluttonous = {
        "%s looks at you with bright eyes, clearly hoping this means snacks.",
        "%s celebrates with you, then sniffs around for a reward bite.",
    },
    dreamy = {
        "%s drifts in a soft little victory sway and smiles at you.",
        "%s gazes at you like this win was part of a nice daydream.",
    },
    stubborn = {
        "%s plants their feet and gives you a hard 'knew we could do it' stare.",
        "%s nods once at you like this outcome was inevitable.",
    },
    optimistic = {
        "%s lights up at you like this is the start of an even better streak.",
        "%s chirps happily at you and points toward whatever comes next.",
    },
    resourceful = {
        "%s gives you a crafty grin like your team solved it the smart way.",
        "%s nods at you with a clever little 'we've got this' confidence.",
    },
}
local TRAIT_NEED_ALERTS = {
    curious = {
        need = "joy",
        threshold = 30,
        reset_threshold = 42,
        lines = {
            "%s keeps poking around for novelty; boredom is setting in.",
            "%s scans the habitat for something new to investigate.",
        },
    },
    playful = {
        need = "joy",
        threshold = 34,
        reset_threshold = 46,
        lines = {
            "%s looks deprived of fun and keeps begging for play.",
            "%s does a tiny 'please play?' wiggle around the tidepool.",
        },
    },
    brash = {
        need = "energy",
        threshold = 28,
        reset_threshold = 40,
        lines = {
            "%s talks big but is clearly running low on steam.",
            "%s tries to act tough while wobbling from fatigue.",
        },
    },
    calm = {
        need = "energy",
        threshold = 24,
        reset_threshold = 36,
        lines = {
            "%s stays composed, but even this calm murloc needs rest.",
            "%s takes a long exhale; their calm focus is fading.",
        },
    },
    mischievous = {
        need = "joy",
        threshold = 32,
        reset_threshold = 44,
        lines = {
            "%s is so bored they start planning trouble.",
            "%s looks one prank away from chaos.",
        },
    },
    responsible = {
        need = "cleanliness",
        threshold = 34,
        reset_threshold = 46,
        lines = {
            "%s notices every messy spot and is visibly bothered.",
            "%s starts fussing at the grime like a tiny supervisor.",
        },
    },
    lazy = {
        need = "energy",
        threshold = 22,
        reset_threshold = 34,
        lines = {
            "%s is one yawn away from a full collapse.",
            "%s looks ready to call it and nap immediately.",
        },
    },
    loyal = {
        need = "joy",
        threshold = 30,
        reset_threshold = 42,
        lines = {
            "%s stays close, but their mood is slipping.",
            "%s keeps watch quietly, needing a little encouragement.",
        },
    },
    greedy = {
        need = "hunger",
        threshold = 34,
        reset_threshold = 46,
        lines = {
            "%s is hangry and eyeing every stash like it owes them.",
            "%s starts guarding snack spots with shell-hoarder intensity.",
        },
    },
    shy = {
        need = "joy",
        threshold = 28,
        reset_threshold = 40,
        lines = {
            "%s retreats into their shell when the mood dips this low.",
            "%s gets very quiet and avoids attention when joy is low.",
        },
    },
    bold = {
        need = "energy",
        threshold = 30,
        reset_threshold = 42,
        lines = {
            "%s still postures heroically, but the energy tank is near empty.",
            "%s charges forward on fumes and needs a break soon.",
        },
    },
    neat = {
        need = "cleanliness",
        threshold = 45,
        reset_threshold = 58,
        lines = {
            "%s is distressed by the mess and keeps trying to tidy everything.",
            "%s looks offended by the current cleanliness level.",
        },
    },
    gluttonous = {
        need = "hunger",
        threshold = 45,
        reset_threshold = 58,
        lines = {
            "%s is snack-hunting aggressively with full glutton mode active.",
            "%s keeps sniffing for food and getting grumpier by the second.",
        },
    },
    dreamy = {
        need = "energy",
        threshold = 30,
        reset_threshold = 42,
        lines = {
            "%s drifts hard into daydream mode and needs real rest.",
            "%s stares into the distance, clearly running low on energy.",
        },
    },
    stubborn = {
        need = "energy",
        threshold = 24,
        reset_threshold = 36,
        lines = {
            "%s refuses to admit it, but they're exhausted.",
            "%s digs in harder even though they badly need rest.",
        },
    },
    optimistic = {
        need = "joy",
        threshold = 24,
        reset_threshold = 36,
        lines = {
            "%s keeps trying to stay cheerful, but the spark is fading.",
            "%s still smiles through it, though joy is running thin.",
        },
    },
    resourceful = {
        need = "hunger",
        threshold = 30,
        reset_threshold = 42,
        lines = {
            "%s starts improvising weird snack plans out of sheer hunger.",
            "%s is plotting a clever food workaround before hunger gets worse.",
        },
    },
}
local TRAIT_EMOTE_ACTIONS = {
    cheer = { type = "emote_cheer", display = "/cheer", keywords = { "cheer" }, requiresResting = false },
    lean = { type = "emote_lean", display = "/lean", keywords = { "lean" }, requiresResting = false },
    sleep_inn = { type = "sleep_rested", display = "/sleep", keywords = { "sleep" }, requiresResting = true },
    rude = { type = "emote_rude", display = "/rude", keywords = { "rude" }, requiresResting = false },
    taunt = { type = "emote_taunt", display = "/taunt", keywords = { "taunt", "mock" }, requiresResting = false },
    dance = { type = "emote_dance", display = "/dance", keywords = { "dance" }, requiresResting = false },
    salute = { type = "emote_salute", display = "/salute", keywords = { "salute" }, requiresResting = false },
    bow = { type = "emote_bow", display = "/bow", keywords = { "bow" }, requiresResting = false },
    wave = { type = "emote_wave", display = "/wave", keywords = { "wave" }, requiresResting = false },
    laugh = { type = "emote_laugh", display = "/laugh", keywords = { "laugh" }, requiresResting = false },
    roar = { type = "emote_roar", display = "/roar", keywords = { "roar" }, requiresResting = false },
    sleep = { type = "emote_sleep", display = "/sleep", keywords = { "sleep" }, requiresResting = false },
    fart_inn = { type = "emote_fart_rested", display = "/fart", keywords = { "fart" }, requiresResting = true },
}
local CHALLENGE_EMOTE_MATCH_RULES = {}
for _, action in pairs(TRAIT_EMOTE_ACTIONS) do
    CHALLENGE_EMOTE_MATCH_RULES[action.type] = {
        keywords = action.keywords,
        requiresResting = action.requiresResting == true,
    }
end
local TRAIT_CHALLENGE_ACTION_PROFILE = {
    default = { random = "cheer", joy = "cheer", energy = "lean", inn = "sleep_inn" },
    curious = { random = "wave", joy = "cheer", energy = "lean", inn = "sleep_inn" },
    playful = { random = "dance", joy = "dance", energy = "lean", inn = "sleep_inn" },
    brash = { random = "roar", joy = "roar", energy = "taunt", inn = "sleep_inn" },
    calm = { random = "bow", joy = "wave", energy = "bow", inn = "sleep_inn" },
    mischievous = { random = "rude", joy = "rude", energy = "taunt", inn = "fart_inn" },
    responsible = { random = "salute", joy = "salute", energy = "bow", inn = "sleep_inn" },
    lazy = { random = "laugh", joy = "laugh", energy = "sleep", inn = "sleep_inn" },
    loyal = { random = "salute", joy = "salute", energy = "bow", inn = "sleep_inn" },
    greedy = { random = "laugh", joy = "laugh", energy = "taunt", inn = "sleep_inn" },
    shy = { random = "wave", joy = "wave", energy = "bow", inn = "sleep_inn" },
    bold = { random = "roar", joy = "roar", energy = "taunt", inn = "sleep_inn" },
    neat = { random = "bow", joy = "salute", energy = "bow", inn = "sleep_inn" },
    gluttonous = { random = "laugh", joy = "laugh", energy = "lean", inn = "sleep_inn" },
    dreamy = { random = "wave", joy = "wave", energy = "sleep", inn = "sleep_inn" },
    stubborn = { random = "taunt", joy = "taunt", energy = "taunt", inn = "sleep_inn" },
    optimistic = { random = "cheer", joy = "cheer", energy = "wave", inn = "sleep_inn" },
    resourceful = { random = "salute", joy = "wave", energy = "lean", inn = "sleep_inn" },
}
local TRAIT_CHALLENGE_VARIANT_SLOT_BY_ID = {
    emote_random_need_5 = "random",
    joy_cheer_5 = "joy",
    energy_lean_5 = "energy",
    energy_sleep_10 = "inn",
}

local function GetNeedDrainMultiplier(currentValue)
    if currentValue > 900 then
        return 0.45
    end
    if currentValue < 400 then
        return 1.2
    end
    return 1.0
end

local function RandomChoiceFromTable(tbl)
    if not tbl or #tbl == 0 then
        return nil
    end
    return tbl[math.random(1, #tbl)]
end

local function WeightedRandomPick(candidates)
    local totalWeight = 0
    for _, candidate in ipairs(candidates) do
        if candidate.weight and candidate.weight > 0 then
            totalWeight = totalWeight + candidate.weight
        end
    end

    if totalWeight <= 0 then
        return nil
    end

    local roll = math.random() * totalWeight
    local running = 0
    for _, candidate in ipairs(candidates) do
        local w = candidate.weight or 0
        if w > 0 then
            running = running + w
            if roll <= running then
                return candidate
            end
        end
    end

    return candidates[#candidates]
end

local function SafeFormatMessage(formatString, ...)
    local template = tostring(formatString or "")
    local ok, result = pcall(string.format, template, ...)
    if ok and result then
        return result
    end
    return template
end

local CARE_ACTION_MESSAGES = {
    feed = {
        [FREE_ACTION_KEY] = "%s digs for worms in the mud.",
        kelpSnack = "%s munches a Kelp Snack and hums through the bubbles.",
        tideWorm = "%s snaps up a Tide Worm and wriggles with satisfaction.",
        bubbleBiscuit = "%s crunches a Bubble Biscuit and sprays crumbs everywhere.",
        reefJerky = "%s chews Reef Jerky with fierce little determination.",
        tideFeast = "%s devours a Tide Feast and looks absolutely triumphant.",
    },
    play = {
        [FREE_ACTION_KEY] = "You play splash-tag with %s.",
        stickFigure = "You stage a tiny stick-figure drama and %s is fully invested.",
        squeakyCrab = "You squeak the crab toy until %s flops over laughing.",
        toyBall = "You bounce the ball off coral and %s chases it at full speed.",
        shellKite = "You launch the shell kite and %s darts after every drift.",
        splashDrum = "You beat the splash drum and %s starts a full mrrgl concert.",
    },
    clean = {
        [FREE_ACTION_KEY] = "You rinse %s and give the tidepool a quick shell polish.",
        bubbleSponge = "You scrub %s with the Bubble Sponge until the scales shine.",
        tideSoap = "You lather Tide Soap through the muck and %s comes out gleaming.",
        coralBrush = "You work the Coral Brush between scales for a deep clean.",
        sandScraper = "You scrape away stubborn grit with the Sand Scraper.",
        pearlRinse = "You finish with a Pearl Rinse and %s sparkles in the light.",
    },
    rest = {
        [FREE_ACTION_KEY] = "You settle %s in a quiet corner for a short tidepool nap.",
        shellPillow = "You fluff the Moss Pillow and %s drifts off quickly.",
        tideBlanket = "You wrap %s in the Tide Blanket for a cozy recharge.",
        kelpHammock = "You rock the Kelp Hammock while %s dozes in calm sways.",
        moonLantern = "You dim the Moon Lantern and %s relaxes almost instantly.",
        driftMattress = "You lay out the Drift Mattress and %s sinks into deep rest.",
    },
}

local function GetCareActionMessage(actionKey, itemKey, petName)
    local action = CARE_ACTION_MESSAGES[actionKey]
    if not action then
        return nil
    end
    local template = action[itemKey or FREE_ACTION_KEY]
    if not template or template == "" then
        return nil
    end
    local name = petName or "Your murloc"
    return SafeFormatMessage(template, name)
end

local AUTON_TRAIT_DIALOGUE = {
    curious = "curious spark",
    playful = "playful streak",
    brash = "brash impulse",
    calm = "calm focus",
    mischievous = "mischievous grin",
    responsible = "responsible instinct",
    lazy = "laid-back mood",
    loyal = "loyal heart",
    greedy = "shiny-shell obsession",
    shy = "shy caution",
    bold = "bold swagger",
    neat = "tidy instinct",
    gluttonous = "snack-chasing appetite",
    dreamy = "dreamy drift",
    stubborn = "stubborn streak",
    optimistic = "optimistic bounce",
    resourceful = "resourceful knack",
}

local function GetAutonTraitDialogue(traitKey)
    local key = tostring(traitKey or ""):lower()
    return AUTON_TRAIT_DIALOGUE[key] or "strange little mood"
end

local function SanitizeAutonomousTraitLanguage(line)
    local text = tostring(line or "")
    if text == "" then
        return text
    end

    -- Convert explicit "Trait/OtherTrait" pair mentions into natural descriptors.
    local pairPattern = "([A-Z][a-z]+)/([A-Z][a-z]+)"
    text = text:gsub(pairPattern, function(a, b)
        return string.format("%s and %s", GetAutonTraitDialogue(a), GetAutonTraitDialogue(b))
    end)

    -- Replace raw trait labels with softer dialogue descriptors.
    for traitKey, dialogue in pairs(AUTON_TRAIT_DIALOGUE) do
        local cap = traitKey:sub(1, 1):upper() .. traitKey:sub(2)
        text = text:gsub("%f[%a]" .. cap .. "%f[%A]", dialogue)
        text = text:gsub("%f[%a]" .. traitKey .. "%f[%A]", dialogue)
    end
    return text
end

local function GetPercentTokens(formatString)
    local tokens = {}
    local s = tostring(formatString or "")
    local i = 1
    while i <= #s do
        local ch = s:sub(i, i)
        if ch == "%" and i < #s then
            local nxt = s:sub(i + 1, i + 1)
            if nxt == "s" or nxt == "d" then
                tokens[#tokens + 1] = "%" .. nxt
                i = i + 2
            else
                i = i + 1
            end
        else
            i = i + 1
        end
    end
    return tokens
end

local function FormatPairEffectMessage(template, petName, shellValue, itemText)
    local tokens = GetPercentTokens(template)
    local rendered
    if #tokens == 3 and tokens[1] == "%s" and tokens[2] == "%d" and tokens[3] == "%s" then
        rendered = SafeFormatMessage(template, petName, shellValue, itemText)
        return SanitizeAutonomousTraitLanguage(rendered)
    end
    if #tokens == 5 and tokens[1] == "%s" and tokens[2] == "%s" and tokens[3] == "%s" and tokens[4] == "%d" and tokens[5] == "%s" then
        rendered = SafeFormatMessage(template, petName, "", "", shellValue, itemText)
        return SanitizeAutonomousTraitLanguage(rendered)
    end
    rendered = SafeFormatMessage(template, petName, shellValue, itemText)
    return SanitizeAutonomousTraitLanguage(rendered)
end

local function Clamp01(value)
    if value < 0 then
        return 0
    end
    if value > 1 then
        return 1
    end
    return value
end

local function NeedPercentToPoints(value)
    return (tonumber(value) or 0) * NEED_POINTS_SCALE
end

local function NeedPointsToPercent(value)
    return (tonumber(value) or 0) / NEED_POINTS_SCALE
end

local function ClampNeedValue(addon, value)
    if addon and addon.ClampNeedValue then
        return addon:ClampNeedValue(value)
    end
    return math.max(0, math.min(NEED_POINTS_MAX, tonumber(value) or 0))
end

local function IsLegacyNeedValue(value)
    local n = tonumber(value)
    if not n then
        return true
    end
    return n <= 100.5
end

local function NeedDeficit(value)
    local percent = NeedPointsToPercent(value)
    return Clamp01((100 - percent) / 100)
end

local function NeedExcess(value)
    local percent = NeedPointsToPercent(value)
    return Clamp01((percent - 70) / 30)
end

local function NeedUrgencyBelow(value, cutoff)
    local percent = NeedPointsToPercent(value)
    if percent >= cutoff then
        return 0
    end
    local span = math.max(1, cutoff)
    return Clamp01((cutoff - percent) / span)
end

local function TrimString(str)
    return str and str:match("^%s*(.-)%s*$") or ""
end

local function CoerceOwnedCount(value)
    local num = nil
    local valueType = type(value)
    if valueType == "boolean" then
        num = value and 1 or 0
    elseif valueType == "string" then
        local normalized = string.lower(TrimString(value))
        if normalized == "true" or normalized == "yes" or normalized == "on" then
            num = 1
        elseif normalized == "false" or normalized == "no" or normalized == "off" or normalized == "" then
            num = 0
        else
            num = tonumber(normalized)
        end
    else
        num = tonumber(value)
    end
    num = math.floor((num or 0) + 0.0001)
    if num < 0 then
        num = 0
    end
    return num
end

local function CoerceOwnedFlag(value)
    if value == true then
        return true
    end
    if type(value) == "string" then
        local normalized = string.lower(TrimString(value))
        if normalized == "true" or normalized == "yes" or normalized == "on" then
            return true
        end
    end
    return CoerceOwnedCount(value) > 0
end

local function GetActiveTraitKeys(pet)
    local keys = {}
    local seen = {}
    local primary = string.lower(TrimString(tostring((pet and pet.personality) or "")))
    local secondary = string.lower(TrimString(tostring((pet and pet.personalitySecondary) or "")))

    if primary ~= "" and not seen[primary] then
        seen[primary] = true
        keys[#keys + 1] = primary
    end
    if secondary ~= "" and not seen[secondary] then
        seen[secondary] = true
        keys[#keys + 1] = secondary
    end
    return keys
end

local function GetTraitChallengeProfileKey(pet)
    local activeTraitKeys = GetActiveTraitKeys(pet)
    for _, traitKey in ipairs(activeTraitKeys) do
        if traitKey == "mischievous" and TRAIT_CHALLENGE_ACTION_PROFILE[traitKey] then
            return traitKey
        end
    end
    for _, traitKey in ipairs(activeTraitKeys) do
        if TRAIT_CHALLENGE_ACTION_PROFILE[traitKey] then
            return traitKey
        end
    end
    return "default"
end

local function BuildTraitChallengeLabel(action, slot)
    if not action then
        return nil
    end
    if slot == "inn" then
        return string.format("Use %s at an inn", action.display)
    end
    if slot == "random" then
        return string.format("Express yourself with %s", action.display)
    end
    if slot == "energy" then
        return string.format("Use %s", action.display)
    end
    return string.format("Use %s!", action.display)
end

local function ApplyTraitChallengeVariant(challenge, profileKey)
    if type(challenge) ~= "table" then
        return
    end

    local slot = TRAIT_CHALLENGE_VARIANT_SLOT_BY_ID[tostring(challenge.id or "")]
    if not slot then
        return
    end

    local profile = TRAIT_CHALLENGE_ACTION_PROFILE[profileKey] or TRAIT_CHALLENGE_ACTION_PROFILE.default or {}
    local defaultProfile = TRAIT_CHALLENGE_ACTION_PROFILE.default or {}
    local actionKey = profile[slot] or defaultProfile[slot]
    local action = actionKey and TRAIT_EMOTE_ACTIONS[actionKey]
    if not action then
        return
    end

    challenge.type = action.type
    challenge.label = BuildTraitChallengeLabel(action, slot) or challenge.label
    if slot == "joy" then
        challenge.completionFlavor = string.format("%%s lit up from your %s (+50 Joy).", action.display)
    elseif slot == "energy" then
        challenge.completionFlavor = string.format("%%s settled after your %s break (+50 Energy).", action.display)
    elseif slot == "inn" then
        challenge.completionFlavor = string.format("%%s reset at the inn after your %s (+100 Energy).", action.display)
    end
end

local function GetTraitNeedDrainFlatDelta(traits, needKey)
    local adjustment = 0
    for traitKey, traitWeight in pairs(traits or {}) do
        if traitWeight and traitWeight > 0 then
            local profile = TRAIT_NEED_DRAIN_FLAT[traitKey]
            local needDelta = profile and tonumber(profile[needKey]) or 0
            if needDelta ~= 0 then
                adjustment = adjustment + (needDelta * traitWeight)
            end
        end
    end
    return adjustment
end

local function GetTraitActionPolarityScale(traits, actionType, polarity)
    local safeAction = actionType or "play"
    local safePolarity = (polarity == "negative") and "negative" or "positive"
    local adjustment = 0
    for traitKey, traitWeight in pairs(traits or {}) do
        if traitWeight and traitWeight > 0 then
            local profile = TRAIT_ACTION_POLARITY_BIAS[traitKey]
            local polarityBias = profile and profile[safePolarity]
            local actionBias = polarityBias and polarityBias[safeAction]
            if actionBias and actionBias > 0 then
                adjustment = adjustment + ((actionBias - 1) * traitWeight)
            end
        end
    end
    return math.max(0.30, math.min(3.10, 1 + (adjustment * 1.80)))
end

local function IsGatheringChallengeType(challengeType)
    return challengeType == "loot_any" or challengeType == "fish_zone"
end

function MM:GetEconomy()
    self.db = self.db or {}
    self.db.profile = self.db.profile or {}
    self.db.profile.economy = self.db.profile.economy or {}
    return self.db.profile.economy
end

function MM:GetInteractionStats()
    local economy = self:GetEconomy()
    economy.stats = economy.stats or {}
    local stats = economy.stats
    stats.actions = stats.actions or {}
    stats.actions.feed = tonumber(stats.actions.feed) or 0
    stats.actions.play = tonumber(stats.actions.play) or 0
    stats.actions.rest = tonumber(stats.actions.rest) or 0
    stats.actions.clean = tonumber(stats.actions.clean) or 0
    stats.actions.eggCare = tonumber(stats.actions.eggCare) or 0
    stats.actions.autonomous = tonumber(stats.actions.autonomous) or 0
    stats.actions.find = tonumber(stats.actions.find) or 0

    stats.scavenge = stats.scavenge or {}
    stats.scavenge.starts = tonumber(stats.scavenge.starts) or 0
    stats.scavenge.manualRecalls = tonumber(stats.scavenge.manualRecalls) or 0
    stats.scavenge.autoPauses = tonumber(stats.scavenge.autoPauses) or 0
    stats.scavenge.autoResumes = tonumber(stats.scavenge.autoResumes) or 0

    stats.challenges = stats.challenges or {}
    stats.challenges.reloads = tonumber(stats.challenges.reloads) or 0
    stats.petAgeMinutesTotal = tonumber(stats.petAgeMinutesTotal) or 0

    stats.purchasesTotal = tonumber(stats.purchasesTotal) or 0
    stats.purchasesByKey = stats.purchasesByKey or {}
    stats.lossesTotal = tonumber(stats.lossesTotal) or 0
    stats.lossesByKey = stats.lossesByKey or {}
    return stats
end

function MM:RecordActionStat(actionKey, amount)
    if not actionKey or actionKey == "" then
        return
    end
    local stats = self:GetInteractionStats()
    local actions = stats.actions
    actions[actionKey] = (tonumber(actions[actionKey]) or 0) + (tonumber(amount) or 1)
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "stat_action", action = actionKey })
    end
end

function MM:RecordPurchaseStat(itemKey, amount)
    if not itemKey or itemKey == "" then
        return
    end
    amount = tonumber(amount) or 1
    local stats = self:GetInteractionStats()
    stats.purchasesTotal = (tonumber(stats.purchasesTotal) or 0) + amount
    stats.purchasesByKey[itemKey] = (tonumber(stats.purchasesByKey[itemKey]) or 0) + amount
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "stat_purchase", itemKey = itemKey })
    end
end

function MM:RecordLossStat(itemKey, amount)
    if not itemKey or itemKey == "" then
        return
    end
    amount = tonumber(amount) or 1
    local stats = self:GetInteractionStats()
    stats.lossesTotal = (tonumber(stats.lossesTotal) or 0) + amount
    stats.lossesByKey[itemKey] = (tonumber(stats.lossesByKey[itemKey]) or 0) + amount
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "stat_loss", itemKey = itemKey })
    end
end

function MM:InitializeEconomyState()
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local function normalizeNeedRewardValue(raw)
        local value = tonumber(raw) or 0
        if value <= 0 then
            return 0
        end
        if value <= 25 then
            value = NeedPercentToPoints(value)
        end
        return math.max(0, math.floor(value + 0.5))
    end
    local function normalizeNeedRewardFlavor(text)
        local out = tostring(text or "")
        if out == "" then
            return out
        end
        out = out:gsub("%+(%d+)%%%%%s*([Hh]unger)", function(v, label) return string.format("+%d %s", NeedPercentToPoints(v), label) end)
        out = out:gsub("%+(%d+)%%%%%s*([Jj]oy)", function(v, label) return string.format("+%d %s", NeedPercentToPoints(v), label) end)
        out = out:gsub("%+(%d+)%%%%%s*([Ee]nergy)", function(v, label) return string.format("+%d %s", NeedPercentToPoints(v), label) end)
        out = out:gsub("%+(%d+)%%%%%s*([Cc]leanliness)", function(v, label) return string.format("+%d %s", NeedPercentToPoints(v), label) end)
        out = out:gsub("%+(%d+)%%%%%s*([Aa]ll needs)", function(v, label) return string.format("+%d %s", NeedPercentToPoints(v), label) end)
        return out
    end

    economy.shellsEarned = tonumber(economy.shellsEarned) or 0
    economy.shellsSpent = tonumber(economy.shellsSpent) or 0

    economy.work = economy.work or {}
    economy.work.active = economy.work.active == true
    economy.work.totalTicks = tonumber(economy.work.totalTicks) or 0
    economy.work.totalEarned = tonumber(economy.work.totalEarned) or 0
    economy.work.currentShiftTicks = tonumber(economy.work.currentShiftTicks) or 0
    economy.work.pendingSkippedPayoutTicks = tonumber(economy.work.pendingSkippedPayoutTicks) or 0
    economy.work.nextStartAllowedAt = tonumber(economy.work.nextStartAllowedAt) or 0
    economy.work.gatheringBonusExpiresAt = tonumber(economy.work.gatheringBonusExpiresAt) or 0
    economy.work.pausedForSafeZone = economy.work.pausedForSafeZone == true
    do
        local now = GetTime() or 0
        if economy.work.nextStartAllowedAt > 0 and (economy.work.nextStartAllowedAt > (now + WORK_RESTART_COOLDOWN) or economy.work.nextStartAllowedAt < (now - 3600)) then
            economy.work.nextStartAllowedAt = 0
        end
    end

    economy.challenges = economy.challenges or {}
    economy.challenges.active = economy.challenges.active or {}
    economy.challenges.completed = tonumber(economy.challenges.completed) or 0
    economy.challenges.nextRerollAt = tonumber(economy.challenges.nextRerollAt) or 0
    if type(economy.challenges.reloadReadyAt) == "table" then
        local legacyMax = 0
        for _, v in pairs(economy.challenges.reloadReadyAt) do
            legacyMax = math.max(legacyMax, tonumber(v) or 0)
        end
        economy.challenges.reloadReadyAt = legacyMax
    end
    economy.challenges.reloadReadyAt = tonumber(economy.challenges.reloadReadyAt) or 0
    economy.challenges.eggTutorialShown = economy.challenges.eggTutorialShown == true
    do
        local now = GetTime() or 0
        if economy.challenges.nextRerollAt > 0 and (economy.challenges.nextRerollAt > (now + CHALLENGE_REROLL_COOLDOWN) or economy.challenges.nextRerollAt < (now - 3600)) then
            economy.challenges.nextRerollAt = 0
        end
        if economy.challenges.reloadReadyAt > 0 and (economy.challenges.reloadReadyAt > (now + CHALLENGE_RELOAD_COOLDOWN) or economy.challenges.reloadReadyAt < (now - 3600)) then
            economy.challenges.reloadReadyAt = 0
        end
    end

    for _, challenge in ipairs(economy.challenges.active) do
        if type(challenge) == "table" and challenge.type == "zone_activity" then
            local zoneName = tostring(challenge.zone or ""):match("^%s*(.-)%s*$") or ""
            local normalizedZone = string.lower(zoneName)
            if normalizedZone == "" or normalizedZone == "your current waters" or normalizedZone == "your current zone" then
                challenge.zone = nil
                local goal = math.max(1, math.floor(tonumber(challenge.goal) or 0))
                challenge.label = string.format("Do %d caretaker actions", goal)
            end
        end
        if type(challenge) == "table" then
            if challenge.rewardAllNeeds ~= nil then
                challenge.rewardAllNeeds = normalizeNeedRewardValue(challenge.rewardAllNeeds)
            end
            if type(challenge.rewardNeeds) == "table" then
                challenge.rewardNeeds.hunger = normalizeNeedRewardValue(challenge.rewardNeeds.hunger)
                challenge.rewardNeeds.joy = normalizeNeedRewardValue(challenge.rewardNeeds.joy)
                challenge.rewardNeeds.energy = normalizeNeedRewardValue(challenge.rewardNeeds.energy)
                challenge.rewardNeeds.cleanliness = normalizeNeedRewardValue(challenge.rewardNeeds.cleanliness)
            end
            if challenge.completionFlavor and challenge.completionFlavor ~= "" then
                challenge.completionFlavor = normalizeNeedRewardFlavor(challenge.completionFlavor)
            end
        end
    end

    if self:GetPet().stage == "egg" then
        for _, challenge in ipairs(economy.challenges.active) do
            if type(challenge) == "table" then
                local hatchReward = math.floor(tonumber(challenge.rewardHatch) or 0)
                if hatchReward <= 0 then
                    challenge.rewardHatch = RollEggChallengeHatchReward()
                else
                    challenge.rewardHatch = self:Clamp(hatchReward, EGG_CHALLENGE_HATCH_BONUS_MIN, EGG_CHALLENGE_HATCH_BONUS_MAX)
                end
            end
        end
    end

    if #economy.challenges.active == 0 then
        self:GenerateChallenges(true)
    end

    local stats = self:GetInteractionStats()
    stats.petAgeMinutesTotal = math.max(tonumber(stats.petAgeMinutesTotal) or 0, tonumber((self:GetPet() or {}).ageMinutes) or 0)
    if store.nameTagFreeUsed == nil then
        local priorNametagPurchases = tonumber(stats.purchasesByKey and stats.purchasesByKey.nameTag) or 0
        store.nameTagFreeUsed = priorNametagPurchases > 0
    else
        store.nameTagFreeUsed = store.nameTagFreeUsed == true
    end

    store.shells = tonumber(store.shells) or 120
    self:GetHabitatCustomizationState()
    if self:GetPet().stage == "egg" and (tonumber(economy.shellsEarned) or 0) <= 0 then
        store.shells = 0
    end
    self:NormalizeStoreItemOwnershipCaps()
end

function MM:GetStoreCatalog()
    return STORE_CATALOG
end

function MM:GetStoreSections()
    return STORE_SECTIONS
end

function MM:GetHabitatCustomizationCatalog()
    return HABITAT_CUSTOMIZATION_CATALOG
end

function MM:GetHabitatCustomizationEntry(choiceKey)
    return HABITAT_CUSTOMIZATION_BY_KEY[tostring(choiceKey or "")]
end

function MM:GetHabitatCustomizationState()
    local store = self:GetStore()
    store.customization = store.customization or {}
    local customization = store.customization
    customization.selectedPage = tostring(customization.selectedPage or "habitats")
    if customization.selectedPage ~= "habitats" and customization.selectedPage ~= "titles" then
        customization.selectedPage = "habitats"
    end

    customization.habitatOwned = customization.habitatOwned or {}
    for _, entry in ipairs(HABITAT_CUSTOMIZATION_CATALOG) do
        local owned = customization.habitatOwned[entry.key]
        customization.habitatOwned[entry.key] = CoerceOwnedFlag(owned)
    end
    customization.habitatOwned.auto = true

    local active = tostring(customization.activeHabitatBackground or "")
    if active == "" or not HABITAT_CUSTOMIZATION_BY_KEY[active] then
        active = "auto"
    end
    customization.activeHabitatBackground = active
    customization.activeTitleKey = tostring(customization.activeTitleKey or "")
    if customization.activeTitleKey ~= "" and not TITLE_REWARD_BY_KEY[customization.activeTitleKey] then
        customization.activeTitleKey = ""
    end
    return customization
end

function MM:GetActiveHabitatBackgroundChoice()
    local customization = self:GetHabitatCustomizationState()
    return tostring(customization.activeHabitatBackground or "auto")
end

function MM:IsHabitatBackgroundOwned(choiceKey)
    local choice = tostring(choiceKey or "")
    if choice == "" then
        return false
    end
    local customization = self:GetHabitatCustomizationState()
    return customization.habitatOwned[choice] == true
end

function MM:GetTitleRewardCatalog()
    return TITLE_REWARD_CATALOG
end

function MM:GetTitleRewardEntry(titleKey)
    return TITLE_REWARD_BY_KEY[tostring(titleKey or "")]
end

function MM:GetTitleRewardByAchievementID(achievementID)
    return TITLE_REWARD_BY_ACHIEVEMENT[tostring(achievementID or "")]
end

function MM:IsTitleRewardUnlocked(titleKey)
    local entry = self:GetTitleRewardEntry(titleKey)
    if not entry then
        return false
    end
    if self.IsAchievementUnlocked then
        return self:IsAchievementUnlocked(entry.achievementID)
    end
    local unlocked = self.db and self.db.profile and self.db.profile.achievements and self.db.profile.achievements.unlocked
    return type(unlocked) == "table" and unlocked[entry.achievementID] ~= nil
end

function MM:GetActiveTitleKey()
    local customization = self:GetHabitatCustomizationState()
    local key = tostring(customization.activeTitleKey or "")
    if key == "" then
        return ""
    end
    if not TITLE_REWARD_BY_KEY[key] then
        customization.activeTitleKey = ""
        return ""
    end
    if not self:IsTitleRewardUnlocked(key) then
        customization.activeTitleKey = ""
        return ""
    end
    return key
end

local function ApplyTitleFormatToName(titleFormat, name)
    local template = tostring(titleFormat or "")
    local baseName = tostring(name or "")
    if template == "" then
        return baseName
    end
    if template:find("NAME", 1, true) then
        return TrimString((template:gsub("NAME", baseName)))
    end
    return TrimString(baseName .. " " .. template)
end

function MM:FormatNameWithActiveTitle(baseName, titleKey)
    local name = TrimString(baseName or "")
    if name == "" then
        return ""
    end
    local key = tostring(titleKey or "")
    if key == "" then
        key = self:GetActiveTitleKey()
    end
    local entry = self:GetTitleRewardEntry(key)
    if not entry then
        return name
    end
    return ApplyTitleFormatToName(entry.format, name)
end

function MM:GetTitledPetName(baseName)
    local pet = self:GetPet()
    if not pet or tostring(pet.stage or "") == "egg" then
        return TrimString(baseName or "")
    end
    return self:FormatNameWithActiveTitle(baseName, nil)
end

function MM:GetPetNameMaxChars()
    return PET_NAME_MAX_CHARS
end

function MM:SanitizePetName(value)
    local text = TrimString(tostring(value or ""))
    if text == "" then
        return ""
    end
    if #text > PET_NAME_MAX_CHARS then
        text = TrimString(string.sub(text, 1, PET_NAME_MAX_CHARS))
    end
    return text
end

function MM:SetActiveTitleKey(titleKey)
    local customization = self:GetHabitatCustomizationState()
    local key = tostring(titleKey or "")
    if key == "" or key == "none" then
        customization.activeTitleKey = ""
        return true, "Cleared murloc title."
    end
    local entry = self:GetTitleRewardEntry(key)
    if not entry then
        return false, "Unknown title."
    end
    if not self:IsTitleRewardUnlocked(key) then
        return false, "That title is still locked."
    end
    customization.activeTitleKey = key
    local titleLabel = TrimString(entry.label or key)
    return true, string.format("Applied title: %s.", titleLabel ~= "" and titleLabel or key)
end

function MM:BuyHabitatBackground(choiceKey)
    local choice = tostring(choiceKey or "")
    local entry = self:GetHabitatCustomizationEntry(choice)
    if not entry then
        return false, "Unknown habitat background."
    end

    local store = self:GetStore()
    local economy = self:GetEconomy()
    local customization = self:GetHabitatCustomizationState()
    local owned = customization.habitatOwned[choice] == true
    local price = tonumber(entry.price) or 0
    local pet = self:GetPet()

    if not owned then
        if pet and pet.stage == "egg" then
            return false, "Customization unlocks after hatching."
        end
        if store.shells < price then
            return false, "Not enough shells."
        end
        store.shells = store.shells - price
        economy.shellsSpent = (tonumber(economy.shellsSpent) or 0) + price
        customization.habitatOwned[choice] = true
        if price > 0 then
            self:RecordPurchaseStat("habitat_bg_" .. choice, 1)
        end
        self:PushActivity(string.format("Purchased habitat background: %s.", entry.label))
    else
        self:PushActivity(string.format("Applied habitat background: %s.", entry.label))
    end

    customization.activeHabitatBackground = choice
    self.db.profile.dev = self.db.profile.dev or {}
    self.db.profile.dev.stageBackgroundOverrides = self.db.profile.dev.stageBackgroundOverrides or {}
    self.db.profile.dev.stageBackgroundOverrides.egg = choice
    self.db.profile.dev.stageBackgroundOverrides.adolescent = choice
    self.db.profile.dev.selectedBackgroundChoice = choice

    self:RefreshUI(true)
    return true, owned and "Applied." or "Purchased."
end

function MM:GetPersonalities()
    return PERSONALITIES
end

function MM:GetMurlocModelPool()
    return MURLOC_MODEL_POOL
end

function MM:GetMurlocModelByDisplayID(displayID)
    local target = tonumber(displayID)
    if not target then
        return nil
    end
    for _, entry in ipairs(MURLOC_MODEL_POOL) do
        if tonumber(entry.displayID) == target then
            return entry
        end
    end
    return nil
end

function MM:GetMurlocRarityColor(rarity)
    local key = tostring(rarity or "common"):lower()
    local color = MURLOC_MODEL_RARITY_COLORS[key] or MURLOC_MODEL_RARITY_COLORS.common
    return color[1], color[2], color[3]
end

function MM:GetMurlocRarityLabel(rarity)
    local key = tostring(rarity or "common"):lower()
    return MURLOC_MODEL_RARITY_LABELS[key] or MURLOC_MODEL_RARITY_LABELS.common
end

function MM:GetWeightedRandomMurlocModel()
    local grouped = {}
    for _, entry in ipairs(MURLOC_MODEL_POOL) do
        local rarity = tostring(entry.rarity or "common"):lower()
        grouped[rarity] = grouped[rarity] or {}
        grouped[rarity][#grouped[rarity] + 1] = entry
    end

    local totalWeight = 0
    for _, rarity in ipairs(MURLOC_MODEL_RARITY_ORDER) do
        if grouped[rarity] and #grouped[rarity] > 0 then
            totalWeight = totalWeight + (MURLOC_MODEL_RARITY_WEIGHTS[rarity] or 0)
        end
    end
    if totalWeight <= 0 then
        return MURLOC_MODEL_POOL[1]
    end

    local roll = math.random() * totalWeight
    local running = 0
    local selectedRarity = "common"
    for _, rarity in ipairs(MURLOC_MODEL_RARITY_ORDER) do
        if grouped[rarity] and #grouped[rarity] > 0 then
            running = running + (MURLOC_MODEL_RARITY_WEIGHTS[rarity] or 0)
            if roll <= running then
                selectedRarity = rarity
                break
            end
        end
    end

    local candidates = grouped[selectedRarity]
    if not candidates or #candidates == 0 then
        candidates = MURLOC_MODEL_POOL
    end
    return RandomChoiceFromTable(candidates)
end

function MM:GetWeightedRandomMurlocModelAtLeastRarity(minRarity, excludeDisplayID)
    local minKey = tostring(minRarity or "common"):lower()
    local minRank = MURLOC_MODEL_RARITY_RANK[minKey] or 1
    local excluded = tonumber(excludeDisplayID)

    local function pick(avoidExcluded)
        local grouped = {}
        for _, entry in ipairs(MURLOC_MODEL_POOL) do
            local rarity = tostring(entry.rarity or "common"):lower()
            local rarityRank = MURLOC_MODEL_RARITY_RANK[rarity] or 1
            local displayID = tonumber(entry.displayID) or 0
            if rarityRank >= minRank and (not avoidExcluded or excluded == nil or displayID ~= excluded) then
                grouped[rarity] = grouped[rarity] or {}
                grouped[rarity][#grouped[rarity] + 1] = entry
            end
        end

        local totalWeight = 0
        for _, rarity in ipairs(MURLOC_MODEL_RARITY_ORDER) do
            if grouped[rarity] and #grouped[rarity] > 0 then
                totalWeight = totalWeight + (MURLOC_MODEL_RARITY_WEIGHTS[rarity] or 0)
            end
        end
        if totalWeight <= 0 then
            return nil
        end

        local roll = math.random() * totalWeight
        local running = 0
        local selectedRarity = nil
        for _, rarity in ipairs(MURLOC_MODEL_RARITY_ORDER) do
            if grouped[rarity] and #grouped[rarity] > 0 then
                running = running + (MURLOC_MODEL_RARITY_WEIGHTS[rarity] or 0)
                if roll <= running then
                    selectedRarity = rarity
                    break
                end
            end
        end
        if not selectedRarity then
            return nil
        end

        return RandomChoiceFromTable(grouped[selectedRarity])
    end

    return pick(true) or pick(false) or self:GetWeightedRandomMurlocModel()
end

function MM:GetTraitDisplayText(pet)
    pet = pet or self:GetPet()
    local primary = pet and pet.personality or "Unknown"
    local secondary = pet and pet.personalitySecondary or nil
    if secondary and secondary ~= "" and secondary ~= primary then
        return string.format("%s / %s", primary, secondary)
    end
    return primary
end

local HATCH_PRIMARY_FLAIR = {
    curious = "with wide-eyed tidepool curiosity",
    playful = "with a splash-first playful spark",
    brash = "with loud, fearless shoreline swagger",
    calm = "with steady, unbothered tide wisdom",
    mischievous = "with a troublemaking mrrgl-grin",
    responsible = "with tiny foreman energy and a checklist stare",
    lazy = "with maximum loaf and minimum rush",
    loyal = "with protective 'stick-with-my-people' energy",
    greedy = "with shiny-shell tunnel vision",
    shy = "with quiet fins and cautious peeks",
    bold = "with all-gas, no-brakes confidence",
    neat = "with spotless-scale pride and tidy habits",
    gluttonous = "with snack-radar already online",
    dreamy = "with moon-drift daydream vibes",
    stubborn = "with 'I picked a route and that's final' resolve",
    optimistic = "with bright-eyed 'this tide will work out' spirit",
    resourceful = "with improvised gadget-brain and scavenger instincts",
}

local HATCH_SECONDARY_TWIST = {
    curious = "and keeps poking every suspicious bubble and nook.",
    playful = "and turns quiet moments into splash games instantly.",
    brash = "and adds a reckless cannonball to every plan.",
    calm = "and cools down chaos before it spirals.",
    mischievous = "and cannot resist one extra prank on the way.",
    responsible = "and still wants the shells sorted by size after the chaos.",
    lazy = "and always checks if there is a nap-friendly shortcut.",
    loyal = "and watches your side of the tidepool like a tiny guard.",
    greedy = "and hears a shiny clink from three coves away.",
    shy = "and prefers the quiet corner before making a move.",
    bold = "and escalates 'maybe' into 'watch this' immediately.",
    neat = "and pauses mid-adventure to fix the messy bits.",
    gluttonous = "and keeps one eye on the nearest snack stash.",
    dreamy = "and drifts into poetic bubble-thoughts between actions.",
    stubborn = "and refuses to abandon a plan once committed.",
    optimistic = "and bounces back from setbacks with a cheerful gurgle.",
    resourceful = "and turns random junk into useful tricks on the fly.",
}

local function DirectionalPairVariant(primary, secondary)
    local text = string.format("%s|%s", tostring(primary or ""), tostring(secondary or ""))
    local hash = 0
    for i = 1, #text do
        hash = (hash + string.byte(text, i) * i) % 2147483647
    end
    return (hash % 4) + 1
end

function MM:GetDirectionalHatchMessage(pet)
    pet = pet or self:GetPet()
    local primary = tostring(pet.personality or "Curious")
    local secondary = tostring(pet.personalitySecondary or primary)
    local primaryKey = string.lower(primary)
    local secondaryKey = string.lower(secondary)
    local name = tostring(pet.name or ""):match("^%s*(.-)%s*$")
    if name == "" then
        name = "The hatchling"
    end

    local primaryLine = HATCH_PRIMARY_FLAIR[primaryKey] or "with unpredictable tidepool energy"
    local secondaryLine = HATCH_SECONDARY_TWIST[secondaryKey] or "and adds a weird little mrrgl twist to every decision."
    local variant = DirectionalPairVariant(primaryKey, secondaryKey)

    if variant == 1 then
        return string.format("%s hatched %s (%s lead, %s streak) %s", name, primaryLine, primary, secondary, secondaryLine)
    elseif variant == 2 then
        return string.format("%s emerged as %s first and %s second: %s %s", name, primary, secondary, primaryLine, secondaryLine)
    elseif variant == 3 then
        return string.format("%s popped out with %s on top and %s underneath; %s %s", name, primary, secondary, primaryLine, secondaryLine)
    end
    return string.format("%s cracked the shell %s. Core trait: %s. Side trait: %s, %s", name, primaryLine, primary, secondary, secondaryLine)
end

function MM:GetStoreEntry(entryKey)
    for _, entry in ipairs(STORE_CATALOG) do
        if entry.key == entryKey then
            return entry
        end
    end
    return nil
end

function MM:GetStoreItemCount(itemKey)
    local store = self:GetStore()
    store.items = store.items or {}
    return CoerceOwnedCount(store.items[itemKey])
end

function MM:GetStoreSectionLabel(sectionKey)
    for _, section in ipairs(STORE_SECTIONS) do
        if section.key == sectionKey then
            return section.label
        end
    end
    return sectionKey or "Store"
end

function MM:GetFoodEffectMultiplier()
    local multiplier = 1.0
    if self:HasStoreUpgrade("feedingBowl") then
        multiplier = multiplier + 0.10
    end
    if self:HasStoreUpgrade("brineSpiceRack") then
        multiplier = multiplier + 0.08
    end
    return multiplier
end

function MM:GetFoodJoyDelta()
    local joyFlat = 2
    if self:HasStoreUpgrade("snackTongs") then
        joyFlat = joyFlat + 2
    end
    return joyFlat
end

function MM:GetFoodCleanlinessDelta()
    return 0
end

function MM:GetChallengeRewardMultiplier()
    local multiplier = 1.0
    if self:HasStoreUpgrade("luckyCharm") then
        multiplier = multiplier + 0.10
    end
    return multiplier
end

function MM:GetEffectiveChallengeShellReward(challenge)
    local baseReward = 0
    if type(challenge) == "table" then
        baseReward = tonumber(challenge.reward) or 0
    end
    if baseReward <= 0 then
        return 0
    end

    local pet = self:GetPet()
    if pet and pet.stage == "egg" then
        return 0
    end

    return math.floor((baseReward * self:GetChallengeRewardMultiplier()) + 0.5)
end

function MM:GetWorkPayoutMultiplier()
    local multiplier = 1.0
    if self:HasStoreUpgrade("shellCollar") then
        multiplier = multiplier + 1.0
    end
    return multiplier
end

function MM:GetWorkPayoutFlatBonus()
    local bonus = 0
    if self:HasStoreUpgrade("tinySatchel") then
        bonus = bonus + 1
    end
    if self:GetGatheringScavengeBonusRemaining() > 0 then
        bonus = bonus + GATHERING_SCAVENGE_BONUS_FLAT_PER_TICK
    end
    return bonus
end

local function BuildEmptyNeedDelta()
    return {
        hunger = 0,
        joy = 0,
        energy = 0,
        cleanliness = 0,
    }
end

function MM:GetRewardItemTierLookup()
    if self._mm_reward_item_tier_by_key then
        return self._mm_reward_item_tier_by_key
    end

    local byKey = {}

    local sortedFoods = {}
    for _, item in ipairs(self:GetFoodItems()) do
        sortedFoods[#sortedFoods + 1] = item
    end
    table.sort(sortedFoods, function(a, b)
        local ap = tonumber(a.price) or 0
        local bp = tonumber(b.price) or 0
        if ap == bp then
            return tostring(a.key or "") < tostring(b.key or "")
        end
        return ap < bp
    end)
    for index, item in ipairs(sortedFoods) do
        byKey[item.key] = math.max(1, math.min(5, index))
    end

    local groupedCare = {
        play = {},
        rest = {},
        clean = {},
    }
    for _, entry in ipairs(self:GetStoreCatalog()) do
        if CARE_ITEM_CATEGORIES[entry.category] then
            groupedCare[entry.category][#groupedCare[entry.category] + 1] = entry
        end
    end
    for _, groupedEntries in pairs(groupedCare) do
        table.sort(groupedEntries, function(a, b)
            local ap = tonumber(a.price) or 0
            local bp = tonumber(b.price) or 0
            if ap == bp then
                return tostring(a.key or "") < tostring(b.key or "")
            end
            return ap < bp
        end)
        local total = #groupedEntries
        for index, entry in ipairs(groupedEntries) do
            local tier = math.max(1, math.min(5, math.ceil((index * 5) / math.max(1, total))))
            byKey[entry.key] = tier
        end
    end

    self._mm_reward_item_tier_by_key = byKey
    return byKey
end

function MM:CanReceiveRewardItem(itemKey)
    if not itemKey or itemKey == "" then
        return false
    end

    if FOOD_ITEM_BY_KEY[itemKey] then
        return true
    end

    local store = self:GetStore()
    local entry = self:GetStoreEntry(itemKey)
    if not entry then
        return true
    end

    local owned = CoerceOwnedCount(store.items and store.items[itemKey])
    local maxOwned = tonumber(entry.maxOwned)
    if maxOwned and maxOwned >= 0 and owned >= maxOwned then
        return false
    end
    return true
end

function MM:AdjustRewardItemCount(itemKey, amount)
    amount = math.floor(tonumber(amount) or 0)
    if not itemKey or itemKey == "" or amount == 0 then
        return 0
    end

    local store = self:GetStore()
    store.inventory = store.inventory or {}
    store.items = store.items or {}

    if FOOD_ITEM_BY_KEY[itemKey] then
        local old = tonumber(store.inventory[itemKey]) or 0
        local newValue = math.max(0, old + amount)
        store.inventory[itemKey] = newValue
        return newValue - old
    end

    local entry = self:GetStoreEntry(itemKey)
    if entry then
        local old = CoerceOwnedCount(store.items[itemKey])
        local maxOwned = tonumber(entry.maxOwned)
        local newValue = math.max(0, old + amount)
        if amount > 0 and maxOwned and maxOwned >= 0 then
            newValue = math.min(newValue, maxOwned)
        end
        store.items[itemKey] = newValue
        return newValue - old
    end

    local old = tonumber(store.inventory[itemKey]) or 0
    local newValue = math.max(0, old + amount)
    store.inventory[itemKey] = newValue
    return newValue - old
end

function MM:RollRandomFindItem(options)
    options = options or {}

    local bucketWeights = options.foodOnly and FIND_FOOD_ONLY_BUCKET_WEIGHTS or FIND_ITEM_BUCKET_WEIGHTS
    local tierByKey = self:GetRewardItemTierLookup()
    local foodByTier = { [1] = {}, [2] = {}, [3] = {}, [4] = {}, [5] = {} }
    local careByTier = { [1] = {}, [2] = {}, [3] = {}, [4] = {}, [5] = {} }

    for _, item in ipairs(self:GetFoodItems()) do
        if self:CanReceiveRewardItem(item.key) then
            local tier = tierByKey[item.key] or 3
            foodByTier[tier][#foodByTier[tier] + 1] = item.key
        end
    end

    if not options.foodOnly then
        for _, entry in ipairs(self:GetStoreCatalog()) do
            if CARE_ITEM_CATEGORIES[entry.category] and self:CanReceiveRewardItem(entry.key) then
                local tier = tierByKey[entry.key] or 3
                careByTier[tier][#careByTier[tier] + 1] = entry.key
            end
        end
    end

    local weightedBuckets = {}
    local totalWeight = 0
    for _, bucket in ipairs(bucketWeights) do
        local source = (bucket.pool == "care") and careByTier or foodByTier
        local candidateKeys = {}
        for tier = bucket.tier_min, bucket.tier_max do
            local tierKeys = source[tier] or {}
            for i = 1, #tierKeys do
                candidateKeys[#candidateKeys + 1] = tierKeys[i]
            end
        end
        if #candidateKeys > 0 and (bucket.weight or 0) > 0 then
            totalWeight = totalWeight + bucket.weight
            weightedBuckets[#weightedBuckets + 1] = {
                weight = bucket.weight,
                keys = candidateKeys,
            }
        end
    end

    if totalWeight <= 0 then
        local fallbackKeys = {}
        for tier = 1, 5 do
            local tierKeys = foodByTier[tier] or {}
            for i = 1, #tierKeys do
                fallbackKeys[#fallbackKeys + 1] = tierKeys[i]
            end
            if not options.foodOnly then
                local careKeys = careByTier[tier] or {}
                for i = 1, #careKeys do
                    fallbackKeys[#fallbackKeys + 1] = careKeys[i]
                end
            end
        end
        return RandomChoiceFromTable(fallbackKeys)
    end

    local roll = math.random() * totalWeight
    local running = 0
    for _, bucket in ipairs(weightedBuckets) do
        running = running + bucket.weight
        if roll <= running then
            return RandomChoiceFromTable(bucket.keys)
        end
    end

    local tailBucket = weightedBuckets[#weightedBuckets]
    return tailBucket and RandomChoiceFromTable(tailBucket.keys) or nil
end

function MM:NormalizeStoreItemOwnershipCaps()
    local store = self:GetStore()
    store.items = store.items or {}
    for _, entry in ipairs(STORE_CATALOG) do
        local owned = CoerceOwnedCount(store.items[entry.key])
        local maxOwned = tonumber(entry.maxOwned)
        if maxOwned and maxOwned >= 0 then
            store.items[entry.key] = math.min(maxOwned, math.max(0, owned))
        else
            store.items[entry.key] = math.max(0, owned)
        end
    end
end

function MM:GetTraitTriggerNeedDelta(triggerKey, pulseCount)
    local profile = TRAIT_TRIGGER_NEED_EFFECTS[tostring(triggerKey or "")]
    if type(profile) ~= "table" then
        return nil
    end

    local traits = self:GetPersonalityWeights()
    local totals = BuildEmptyNeedDelta()
    for traitKey, deltas in pairs(profile) do
        if (tonumber(traits[traitKey]) or 0) > 0 then
            for _, needKey in ipairs(TRAIT_NEED_KEYS) do
                totals[needKey] = totals[needKey] + (tonumber(deltas[needKey]) or 0)
            end
        end
    end

    local pulses = math.max(1, math.floor(tonumber(pulseCount) or 1))
    local hasAny = false
    for _, needKey in ipairs(TRAIT_NEED_KEYS) do
        local clamped = math.max(-20, math.min(20, tonumber(totals[needKey]) or 0))
        clamped = clamped * pulses
        totals[needKey] = clamped
        if clamped ~= 0 then
            hasAny = true
        end
    end

    if not hasAny then
        return nil
    end
    return totals
end

function MM:GetTraitActionNeedDelta(triggerKey, pulseCount)
    return self:GetTraitTriggerNeedDelta(triggerKey, pulseCount)
end

function MM:GetTraitNeedDeltaForTrait(traitName, triggerKey, pulseCount)
    local traitKey = string.lower(TrimString(tostring(traitName or "")))
    if traitKey == "" then
        return nil
    end

    local profile = TRAIT_TRIGGER_NEED_EFFECTS[tostring(triggerKey or "")]
    if type(profile) ~= "table" then
        return nil
    end

    local source = profile[traitKey]
    if type(source) ~= "table" then
        return nil
    end

    local pulses = math.max(1, math.floor(tonumber(pulseCount) or 1))
    local totals = BuildEmptyNeedDelta()
    local hasAny = false
    for _, needKey in ipairs(TRAIT_NEED_KEYS) do
        local value = math.max(-20, math.min(20, tonumber(source[needKey]) or 0))
        value = value * pulses
        totals[needKey] = value
        if value ~= 0 then
            hasAny = true
        end
    end

    if not hasAny then
        return nil
    end
    return totals
end

function MM:GetTraitDrainFlatForTrait(traitName)
    local traitKey = string.lower(TrimString(tostring(traitName or "")))
    if traitKey == "" then
        return nil
    end

    local source = TRAIT_NEED_DRAIN_FLAT[traitKey]
    if type(source) ~= "table" then
        return nil
    end

    local totals = BuildEmptyNeedDelta()
    local hasAny = false
    for _, needKey in ipairs(TRAIT_NEED_KEYS) do
        local value = tonumber(source[needKey]) or 0
        totals[needKey] = value
        if value ~= 0 then
            hasAny = true
        end
    end

    if not hasAny then
        return nil
    end
    return totals
end

function MM:ApplyNeedDelta(delta)
    if type(delta) ~= "table" then
        return false
    end
    local pet = self:GetPet()
    if type(pet) ~= "table" then
        return false
    end

    local changed = false
    for _, needKey in ipairs(TRAIT_NEED_KEYS) do
        local amount = tonumber(delta[needKey]) or 0
        if amount ~= 0 then
            pet[needKey] = ClampNeedValue(self, (tonumber(pet[needKey]) or 0) + amount)
            changed = true
        end
    end
    return changed
end

function MM:ApplyTraitTriggerEffects(triggerKey, pulseCount)
    local pet = self:GetPet()
    if not pet or pet.stage == "egg" then
        return nil
    end
    local delta = self:GetTraitTriggerNeedDelta(triggerKey, pulseCount)
    if not delta then
        return nil
    end
    if self:ApplyNeedDelta(delta) then
        return delta
    end
    return nil
end

local BASIC_ACTION_PRIMARY_NEED = {
    feed = "hunger",
    play = "joy",
    rest = "energy",
    clean = "cleanliness",
}

function MM:ApplyBasicActionTraitEffects(triggerKey, pulseCount)
    local primaryNeed = BASIC_ACTION_PRIMARY_NEED[tostring(triggerKey or "")]
    if not primaryNeed then
        return nil
    end

    local delta = self:GetTraitTriggerNeedDelta(triggerKey, pulseCount)
    local primaryDelta = tonumber(delta and delta[primaryNeed]) or 0
    if primaryDelta <= 0 then
        return nil
    end

    local filtered = BuildEmptyNeedDelta()
    filtered[primaryNeed] = primaryDelta
    if self:ApplyNeedDelta(filtered) then
        return filtered
    end
    return nil
end

function MM:ApplyShellMilestoneTraitBonuses(previousEarned, newEarned)
    local startMilestone = math.floor(math.max(0, tonumber(previousEarned) or 0) / 100)
    local endMilestone = math.floor(math.max(0, tonumber(newEarned) or 0) / 100)
    local crossed = endMilestone - startMilestone
    if crossed <= 0 then
        return
    end

    local delta = self:ApplyTraitTriggerEffects("shell_milestone", crossed)
    if not delta then
        return
    end

    local joyGain = tonumber(delta.joy) or 0
    if joyGain > 0 then
        local pet = self:GetPet()
        local petName = TrimString((pet and pet.name) or "")
        if petName == "" then
            petName = "Your murloc"
        end
        self:PushActivity(string.format("%s got excited from the shell haul (+%d Joy).", petName, joyGain))
    end
end

function MM:AddShells(amount)
    amount = math.floor(tonumber(amount) or 0)
    if amount == 0 then
        return 0
    end

    local store = self:GetStore()
    local economy = self:GetEconomy()
    local current = tonumber(store.shells) or 0

    if amount > 0 then
        local previousEarned = tonumber(economy.shellsEarned) or 0
        store.shells = current + amount
        economy.shellsEarned = previousEarned + amount
        self:ApplyShellMilestoneTraitBonuses(previousEarned, economy.shellsEarned)
        if self.EvaluateAchievements then
            self:EvaluateAchievements({ source = "shell_gain", amount = amount })
        end
        return amount
    end

    local loss = math.min(current, math.abs(amount))
    store.shells = current - loss
    economy.shellsSpent = (tonumber(economy.shellsSpent) or 0) + loss
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "shell_spend", amount = loss })
    end
    return -loss
end

function MM:GetGatheringScavengeBonusRemaining()
    local economy = self:GetEconomy()
    local expiresAt = tonumber(economy.work.gatheringBonusExpiresAt) or 0
    local now = GetTime() or 0
    return math.max(0, expiresAt - now)
end

function MM:ActivateGatheringScavengeBonus()
    local economy = self:GetEconomy()
    local now = GetTime() or 0
    local expiresAt = now + GATHERING_SCAVENGE_BONUS_DURATION
    economy.work.gatheringBonusExpiresAt = expiresAt
    self:PushActivity(string.format("Gathering bonus active: +%d shells per scavenging tick for 5m.", GATHERING_SCAVENGE_BONUS_FLAT_PER_TICK))
end

function MM:GetCurrentScavengeTickPayout()
    return math.floor((WORK_SHELLS_PER_TICK * self:GetWorkPayoutMultiplier()) + 0.5) + self:GetWorkPayoutFlatBonus()
end

function MM:HasStoreUpgrade(upgradeKey)
    return self:GetStoreItemCount(upgradeKey) > 0
end

function MM:IsNameTagFirstPurchaseFree()
    local store = self:GetStore()
    if store.nameTagFreeUsed == nil then
        store.nameTagFreeUsed = false
    end
    return store.nameTagFreeUsed ~= true
end

function MM:GetStoreEntryEffectivePrice(entryOrKey)
    local entry = entryOrKey
    if type(entryOrKey) ~= "table" then
        entry = self:GetStoreEntry(entryOrKey)
    end
    if not entry then
        return nil
    end

    local basePrice = math.max(0, tonumber(entry.price) or 0)
    if entry.key == "nameTag" and self:IsNameTagFirstPurchaseFree() then
        return 0
    end
    return basePrice
end

function MM:GetStoreSectionTotalsText(sectionKey)
    sectionKey = tostring(sectionKey or "")
    if sectionKey ~= "trinket" and sectionKey ~= "habitat" then
        return nil
    end

    local lines = {}
    local function join(parts)
        return table.concat(parts, ", ")
    end

    if sectionKey == "trinket" then
        local scavPct = math.floor((self:GetWorkPayoutMultiplier() - 1) * 100 + 0.5)
        local challengePct = math.floor((self:GetChallengeRewardMultiplier() - 1) * 100 + 0.5)
        local flatTick = self:GetWorkPayoutFlatBonus()
        local playPct = self:HasStoreUpgrade("playBell") and 10 or 0
        local restPct = 0
        if self:HasStoreUpgrade("napWhistle") then
            restPct = restPct + 10
        end

        lines[#lines + 1] = join({
            string.format("Scavenge +%d%%", scavPct),
            string.format("Challenge +%d%%", challengePct),
            string.format("Flat/tick +%d", flatTick),
        })
        lines[#lines + 1] = join({
            string.format("Play +%d%%", playPct),
            string.format("Rest +%d%%", restPct),
        })
    else
        local foodPct = math.floor((self:GetFoodEffectMultiplier() - 1) * 100 + 0.5)
        local feedHungerFlat = self:HasStoreUpgrade("tideLadle") and 4 or 0
        local feedJoyFlat = self:GetFoodJoyDelta()
        local feedCleanDelta = self:GetFoodCleanlinessDelta()
        local feedSavePct = self:HasStoreUpgrade("rationLedger") and 12 or 0
        local toyRiskReductionPct = self:HasStoreUpgrade("toyChest") and 45 or 0
        local restPct = self:HasStoreUpgrade("cozyNest") and 20 or 0
        local cleanPct = self:HasStoreUpgrade("washStation") and 20 or 0
        local joyDrainReductionPct = self:HasStoreUpgrade("coralFountain") and 10 or 0
        local cleanDrainReductionPct = self:HasStoreUpgrade("tidepoolFilter") and 12 or 0

        lines[#lines + 1] = join({
            string.format("Food +%d%%", foodPct),
            string.format("Feed +%d H/+%d J", NeedPercentToPoints(feedHungerFlat), NeedPercentToPoints(feedJoyFlat)),
            string.format("Food %d C", NeedPercentToPoints(feedCleanDelta)),
            string.format("Conserve %d%%", feedSavePct),
        })
        lines[#lines + 1] = join({
            string.format("Toy risk -%d%%", toyRiskReductionPct),
            string.format("Rest +%d%%", restPct),
            string.format("Clean +%d%%", cleanPct),
            string.format("Joy drain -%d%%", joyDrainReductionPct),
            string.format("Clean drain -%d%%", cleanDrainReductionPct),
        })
    end

    return "Owned Totals: " .. table.concat(lines, "\n")
end

local RandomPersonalityExcludingSet

function MM:CanBuyStoreEntry(entry)
    if self:GetPet().stage == "egg" then
        return false
    end
    local store = self:GetStore()
    local owned = self:GetStoreItemCount(entry.key)
    if entry.maxOwned and owned >= entry.maxOwned then
        return false
    end
    local effectivePrice = self:GetStoreEntryEffectivePrice(entry) or 0
    return store.shells >= effectivePrice
end

function MM:BuyStoreEntry(entryKey)
    if self:GetPet().stage == "egg" then
        return false, "Store upgrades unlock after hatching."
    end

    local entry = self:GetStoreEntry(entryKey)
    local store = self:GetStore()
    local economy = self:GetEconomy()
    if not entry then
        return false, "Unknown store entry."
    end

    local effectivePrice = self:GetStoreEntryEffectivePrice(entry) or 0
    if store.shells < effectivePrice then
        return false, "Not enough shells."
    end

    local owned = self:GetStoreItemCount(entry.key)
    if entry.maxOwned and owned >= entry.maxOwned then
        return false, "Already owned."
    end

    if entry.key == "traitGem" or entry.key == "traitLamp" or entry.key == "nameTag" or entry.key == "transmuteOrb" then
        local pet = self:GetPet()
        local oldPrimary = tostring(pet.personality or PERSONALITIES[1])
        local oldSecondary = tostring(pet.personalitySecondary or PERSONALITIES[2] or PERSONALITIES[1])
        local oldModelText
        local newModelText

        if entry.key == "nameTag" then
            store.shells = store.shells - effectivePrice
            economy.shellsSpent = economy.shellsSpent + effectivePrice
            store.nameTagFreeUsed = true
            self:RecordPurchaseStat(entry.key, 1)
            if effectivePrice <= 0 then
                self:PushActivity("You used your free nametag and uncapped the marker.")
            else
                self:PushActivity("You peeled a fresh nametag and uncapped the marker.")
            end
            if self.ShowRenameMurlocPrompt then
                self:ShowRenameMurlocPrompt(true)
            end
            self:RefreshUI()
            return true, "Nametag used."
        elseif entry.key == "traitGem" then
            pet.personalitySecondary = RandomPersonalityExcludingSet({ oldPrimary, oldSecondary })
        elseif entry.key == "transmuteOrb" then
            local oldDisplayID = tonumber(pet.modelDisplayID)
            local oldModelEntry = self:GetMurlocModelByDisplayID(oldDisplayID)
            local oldRarity = tostring(pet.modelRarity or (oldModelEntry and oldModelEntry.rarity) or "common"):lower()
            local oldLabel = tostring(pet.modelLabel or (oldModelEntry and oldModelEntry.label) or ("Display " .. tostring(oldDisplayID or "Unknown")))

            local modelEntry = self:GetWeightedRandomMurlocModelAtLeastRarity("rare", oldDisplayID)
            if not modelEntry then
                return false, "No eligible rare-or-better model found."
            end
            pet.modelDisplayID = tonumber(modelEntry.displayID) or pet.modelDisplayID
            pet.modelRarity = tostring(modelEntry.rarity or "rare"):lower()
            pet.modelLabel = modelEntry.label or ("Display " .. tostring(pet.modelDisplayID or "Unknown"))

            if self.db and self.db.profile and self.db.profile.dev then
                -- Clear dev model override so live gameplay always reflects the rerolled model.
                self.db.profile.dev.selectedHatchDisplayID = nil
            end
            if self.frame then
                self.frame._petModelResolved = nil
                self.frame._petModelLoaded = nil
                self.frame._petModelSource = nil
            end

            oldModelText = string.format("%s %s", self:GetMurlocRarityLabel(oldRarity), oldLabel)
            newModelText = string.format("%s %s", self:GetMurlocRarityLabel(pet.modelRarity), tostring(pet.modelLabel or "Unknown"))
        else
            pet.personality = RandomPersonalityExcludingSet({ oldPrimary, oldSecondary })
            if pet.personality == pet.personalitySecondary then
                pet.personalitySecondary = RandomPersonalityExcluding(pet.personality)
            end
        end

        store.shells = store.shells - effectivePrice
        economy.shellsSpent = economy.shellsSpent + effectivePrice
        self:RecordPurchaseStat(entry.key, 1)

        if entry.key == "traitGem" then
            self:PushActivity(string.format(
                "The trait gem shattered. Secondary trait rerolled: %s -> %s.",
                oldSecondary,
                pet.personalitySecondary
            ))
        elseif entry.key == "transmuteOrb" then
            self:PushActivity(string.format(
                "The transmutation orb flares and shatters. Form rerolled: %s -> %s.",
                oldModelText or "Unknown",
                newModelText or "Unknown"
            ))
        else
            self:PushActivity(string.format(
                "You rubbed the lamp. Primary trait rerolled: %s -> %s.",
                oldPrimary,
                pet.personality
            ))
        end
        self:RefreshUI()
        if entry.key == "transmuteOrb" then
            return true, "Form rerolled."
        end
        return true, "Trait rerolled."
    end

    store.shells = store.shells - effectivePrice
    economy.shellsSpent = economy.shellsSpent + effectivePrice
    store.items = store.items or {}
    store.items[entry.key] = owned + 1
    self:RecordPurchaseStat(entry.key, 1)

    self:PushActivity(string.format("Purchased %s.", entry.label))
    self:RefreshUI()
    return true, "Purchased."
end

local function RollPercentChance(percent)
    return math.random() < percent
end

local PROF_SKILL = {
    fishing = 356,
    herbalism = 182,
    mining = 186,
    skinning = 393,
    alchemy = 171,
    blacksmithing = 164,
    enchanting = 333,
    engineering = 202,
    inscription = 773,
    jewelcrafting = 755,
    leatherworking = 165,
    tailoring = 197,
    cooking = 185,
}

local function ScaleGoal(base, level, step, cap)
    return math.max(1, tonumber(base) or 1)
end

local function ScaleReward(base, level)
    return math.max(5, math.floor(tonumber(base) or 0))
end

local function ShuffleInPlace(list)
    for i = #list, 2, -1 do
        local j = math.random(1, i)
        list[i], list[j] = list[j], list[i]
    end
end

local function NameHasKeyword(name, keywords)
    if not name or name == "" then
        return false
    end

    local lowerName = string.lower(name)
    for _, keyword in ipairs(keywords) do
        if string.find(lowerName, keyword, 1, true) then
            return true
        end
    end
    return false
end

local function MessageContainsKeyword(lowerMessage, keywords)
    local text = tostring(lowerMessage or "")
    if text == "" then
        return false
    end
    for _, keyword in ipairs(keywords or {}) do
        local needle = tostring(keyword or "")
        if needle ~= "" and string.find(text, needle, 1, true) then
            return true
        end
    end
    return false
end

local function RefreshToySpellLookup(addon)
    addon._mmToySpellIDSet = {}
    addon._mmToySpellNameSet = {}

    if not addon or not C_ToyBox or not C_ToyBox.GetNumToys or not C_ToyBox.GetToyFromIndex then
        return
    end

    local totalToys = tonumber(C_ToyBox.GetNumToys()) or 0
    for i = 1, totalToys do
        local toyItemID = C_ToyBox.GetToyFromIndex(i)
        if toyItemID and (not PlayerHasToy or PlayerHasToy(toyItemID)) then
            local spellName, spellID = nil, nil
            if GetItemSpell then
                spellName, spellID = GetItemSpell(toyItemID)
            end

            local lowerSpell = string.lower(TrimString(spellName or ""))
            if lowerSpell ~= "" then
                addon._mmToySpellNameSet[lowerSpell] = true
            end

            local numericSpellID = tonumber(spellID)
            if numericSpellID then
                addon._mmToySpellIDSet[numericSpellID] = true
            end
        end
    end
end

local function IsToySpellCast(addon, spellID, spellName)
    if not addon then
        return false
    end

    local numericSpellID = tonumber(spellID)
    if numericSpellID and addon._mmToySpellIDSet and addon._mmToySpellIDSet[numericSpellID] then
        return true
    end

    local lowerSpell = string.lower(TrimString(spellName or ""))
    if lowerSpell ~= "" and addon._mmToySpellNameSet and addon._mmToySpellNameSet[lowerSpell] then
        return true
    end

    return false
end

local CHALLENGE_ZONE_FALLBACK = "your current waters"
local CHALLENGE_ZONE_FALLBACKS = {
    ["your current waters"] = true,
    ["your current zone"] = true,
}

local function IsChallengeZoneFallback(zoneName)
    local normalized = string.lower(TrimString(zoneName))
    if normalized == "" then
        return false
    end
    return CHALLENGE_ZONE_FALLBACKS[normalized] == true
end

local function ResolveCurrentZoneName()
    local zoneName = TrimString(GetRealZoneText and GetRealZoneText() or "")
    if zoneName ~= "" then
        return zoneName
    end

    zoneName = TrimString(GetZoneText and GetZoneText() or "")
    if zoneName ~= "" then
        return zoneName
    end

    if C_Map and C_Map.GetBestMapForUnit and C_Map.GetMapInfo then
        local mapID = C_Map.GetBestMapForUnit("player")
        if mapID then
            local mapInfo = C_Map.GetMapInfo(mapID)
            zoneName = TrimString(mapInfo and mapInfo.name or "")
            if zoneName ~= "" then
                return zoneName
            end
        end
    end

    return CHALLENGE_ZONE_FALLBACK
end

local function RandomPersonalityExcluding(excluded)
    local candidates = {}
    for _, trait in ipairs(PERSONALITIES) do
        if trait ~= excluded then
            candidates[#candidates + 1] = trait
        end
    end
    if #candidates == 0 then
        return PERSONALITIES[math.random(1, #PERSONALITIES)]
    end
    return candidates[math.random(1, #candidates)]
end

RandomPersonalityExcludingSet = function(excluded)
    local blocked = {}
    if type(excluded) == "table" then
        for _, trait in ipairs(excluded) do
            if trait and trait ~= "" then
                blocked[trait] = true
            end
        end
    elseif excluded and excluded ~= "" then
        blocked[excluded] = true
    end

    local candidates = {}
    for _, trait in ipairs(PERSONALITIES) do
        if not blocked[trait] then
            candidates[#candidates + 1] = trait
        end
    end
    if #candidates == 0 then
        return PERSONALITIES[math.random(1, #PERSONALITIES)]
    end
    return candidates[math.random(1, #candidates)]
end

local function TryConsumeItemFromTable(tbl, key, amount)
    if type(tbl) ~= "table" or not key then
        return false, 0
    end
    amount = tonumber(amount) or 1
    if amount <= 0 then
        return true, tonumber(tbl[key]) or 0
    end
    local current = tonumber(tbl[key]) or 0
    if current < amount then
        return false, current
    end
    tbl[key] = current - amount
    return true, tbl[key]
end

local _mmChallengeUID = 0
local function EnsureUniqueChallengeID(challenge)
    if type(challenge) ~= "table" then
        return challenge
    end
    _mmChallengeUID = _mmChallengeUID + 1
    local base = tostring(challenge.id or (challenge.type or "challenge"))
    challenge.id = string.format("%s_u%d", base, _mmChallengeUID)
    return challenge
end

local CHALLENGE_SLOT_COUNT = 4

local function CopyChallengeTemplate(template)
    if type(template) ~= "table" then
        return nil
    end
    local out = {
        id = template.id,
        label = template.label,
        type = template.type,
        zone = template.zone,
        goal = template.goal,
        progress = template.progress or 0,
        reward = template.reward or 0,
        rewardHatch = template.rewardHatch,
        rewardAllNeeds = template.rewardAllNeeds,
        completionFlavor = template.completionFlavor,
    }
    if type(template.rewardNeeds) == "table" then
        out.rewardNeeds = {
            hunger = template.rewardNeeds.hunger,
            joy = template.rewardNeeds.joy,
            energy = template.rewardNeeds.energy,
            cleanliness = template.rewardNeeds.cleanliness,
        }
    end
    return out
end

local function GetChallengeSignature(challenge)
    if type(challenge) ~= "table" then
        return ""
    end
    return string.format(
        "%s|%s|%s|%s",
        tostring(challenge.type or ""),
        tostring(challenge.zone or ""),
        tostring(challenge.goal or ""),
        tostring(challenge.label or "")
    )
end

local function GetHelpfulAuraNameAndSpellID(unit, index)
    if C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then
        local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, "HELPFUL")
        if not auraData then
            return nil, nil, false
        end

        -- Some aura rows can be restricted; guard field access and skip safely.
        local okName, auraName = pcall(function()
            return auraData.name
        end)
        local okSpell, auraSpellID = pcall(function()
            return auraData.spellId
        end)

        if not okName then
            auraName = nil
        end
        if not okSpell then
            auraSpellID = nil
        end
        return auraName, auraSpellID, true
    end

    if UnitAura then
        local name, _, _, _, _, _, _, _, _, spellId = UnitAura(unit, index, "HELPFUL")
        return name, spellId, name ~= nil
    end

    if UnitBuff then
        local name, _, _, _, _, _, _, _, _, spellId = UnitBuff(unit, index)
        return name, spellId, name ~= nil
    end

    return nil, nil, false
end

function MM:GetCharacterChallengeContext()
    local zoneName = ResolveCurrentZoneName()

    local level = UnitLevel("player") or 1
    local knownBySkillLine = {}
    local knownNames = {}

    local prof1, prof2, archaeology, fishing, cooking = GetProfessions()
    local profSlots = { prof1, prof2, archaeology, fishing, cooking }
    for _, slot in pairs(profSlots) do
        if slot and GetProfessionInfo then
            local name, _, _, _, _, _, skillLine = GetProfessionInfo(slot)
            if name and skillLine then
                knownBySkillLine[skillLine] = true
                knownNames[#knownNames + 1] = name
            end
        end
    end

    local hasGathering = knownBySkillLine[PROF_SKILL.herbalism]
        or knownBySkillLine[PROF_SKILL.mining]
        or knownBySkillLine[PROF_SKILL.skinning]

    local hasCrafting = knownBySkillLine[PROF_SKILL.alchemy]
        or knownBySkillLine[PROF_SKILL.blacksmithing]
        or knownBySkillLine[PROF_SKILL.enchanting]
        or knownBySkillLine[PROF_SKILL.engineering]
        or knownBySkillLine[PROF_SKILL.inscription]
        or knownBySkillLine[PROF_SKILL.jewelcrafting]
        or knownBySkillLine[PROF_SKILL.leatherworking]
        or knownBySkillLine[PROF_SKILL.tailoring]
        or knownBySkillLine[PROF_SKILL.cooking]

    return {
        level = level,
        zoneName = zoneName,
        professionNames = knownNames,
        hasFishing = knownBySkillLine[PROF_SKILL.fishing] == true,
        hasCooking = knownBySkillLine[PROF_SKILL.cooking] == true,
        hasHerbalism = knownBySkillLine[PROF_SKILL.herbalism] == true,
        hasMining = knownBySkillLine[PROF_SKILL.mining] == true,
        hasSkinning = knownBySkillLine[PROF_SKILL.skinning] == true,
        hasGathering = hasGathering == true,
        hasCrafting = hasCrafting == true,
        hasAnyProfession = #knownNames > 0,
    }
end

local MONEY_ONLY_CHALLENGE_TYPES = {
    feed = true,
    play = true,
    rest = true,
    clean = true,
    zone_activity = true,
}

local function IsMoneyOnlyChallengeType(challengeType)
    return MONEY_ONLY_CHALLENGE_TYPES[challengeType] == true
end

local function BuildNeedRewardText(challenge)
    if not challenge then
        return nil
    end
    if challenge.rewardAllNeeds and challenge.rewardAllNeeds > 0 then
        return string.format("+%d all needs", challenge.rewardAllNeeds)
    end
    if challenge.rewardNeeds then
        local parts = {}
        if (challenge.rewardNeeds.hunger or 0) > 0 then
            parts[#parts + 1] = string.format("+%d hunger", challenge.rewardNeeds.hunger)
        end
        if (challenge.rewardNeeds.joy or 0) > 0 then
            parts[#parts + 1] = string.format("+%d joy", challenge.rewardNeeds.joy)
        end
        if (challenge.rewardNeeds.energy or 0) > 0 then
            parts[#parts + 1] = string.format("+%d energy", challenge.rewardNeeds.energy)
        end
        if (challenge.rewardNeeds.cleanliness or 0) > 0 then
            parts[#parts + 1] = string.format("+%d cleanliness", challenge.rewardNeeds.cleanliness)
        end
        if #parts > 0 then
            return table.concat(parts, ", ")
        end
    end
    return nil
end

function MM:ApplyChallengeNeedRewards(challenge)
    local pet = self:GetPet()
    if not challenge or not pet or pet.stage == "egg" then
        return nil
    end
    if IsMoneyOnlyChallengeType(challenge.type) then
        return nil
    end

    local gains = { hunger = 0, joy = 0, energy = 0, cleanliness = 0 }
    if challenge.rewardAllNeeds and challenge.rewardAllNeeds > 0 then
        local all = tonumber(challenge.rewardAllNeeds) or 0
        gains.hunger = all
        gains.joy = all
        gains.energy = all
        gains.cleanliness = all
    end
    if challenge.rewardNeeds then
        gains.hunger = math.max(gains.hunger, tonumber(challenge.rewardNeeds.hunger) or 0)
        gains.joy = math.max(gains.joy, tonumber(challenge.rewardNeeds.joy) or 0)
        gains.energy = math.max(gains.energy, tonumber(challenge.rewardNeeds.energy) or 0)
        gains.cleanliness = math.max(gains.cleanliness, tonumber(challenge.rewardNeeds.cleanliness) or 0)
    end

    if gains.hunger <= 0 and gains.joy <= 0 and gains.energy <= 0 and gains.cleanliness <= 0 then
        return nil
    end

    pet.hunger = ClampNeedValue(self, (pet.hunger or 0) + gains.hunger)
    pet.joy = ClampNeedValue(self, (pet.joy or 0) + gains.joy)
    pet.energy = ClampNeedValue(self, (pet.energy or 0) + gains.energy)
    pet.cleanliness = ClampNeedValue(self, (pet.cleanliness or 0) + gains.cleanliness)

    local parts = {}
    if gains.hunger > 0 then parts[#parts + 1] = string.format("+%d Hunger", gains.hunger) end
    if gains.joy > 0 then parts[#parts + 1] = string.format("+%d Joy", gains.joy) end
    if gains.energy > 0 then parts[#parts + 1] = string.format("+%d Energy", gains.energy) end
    if gains.cleanliness > 0 then parts[#parts + 1] = string.format("+%d Cleanliness", gains.cleanliness) end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", ")
end

function MM:BuildChallengePool(context, eggStage)
    context = context or self:GetCharacterChallengeContext()
    local zoneName = TrimString(context.zoneName)
    local level = tonumber(context.level) or 1
    local petName = TrimString((self:GetPet().name or ""))
    if petName == "" then
        petName = "Mrrgl"
    end

    local questGoal = ScaleGoal(2, level, 35, 5)
    local careGoal = ScaleGoal(3, level, 30, 6)
    local zoneGoal = ScaleGoal(4, level, 40, 7)
    local shortCareGoal = ScaleGoal(2, level, 45, 5)
    local pool = {}
    local optional = {}
    local hasSpecificZone = zoneName ~= "" and not IsChallengeZoneFallback(zoneName)
    local zoneSuffix = hasSpecificZone and string.format(" in %s", zoneName) or ""
    local zoneChallengeLabel = string.format("Do %d caretaker actions%s", zoneGoal, zoneSuffix)
    local zoneRequirement = hasSpecificZone and zoneName or nil
    local NEED_KEYS = { "hunger", "joy", "energy", "cleanliness" }
    local NEED_LABELS = {
        hunger = "Hunger",
        joy = "Joy",
        energy = "Energy",
        cleanliness = "Cleanliness",
    }
    local traitChallengeProfileKey = GetTraitChallengeProfileKey(self:GetPet())

    local function AddChallenge(challenge)
        ApplyTraitChallengeVariant(challenge, traitChallengeProfileKey)
        optional[#optional + 1] = challenge
    end

    local function BuildSingleNeedReward(amount, key)
        local reward = {}
        reward[key] = amount
        return reward
    end

    local function BuildRandomNeedReward(amount)
        local key = NEED_KEYS[math.random(1, #NEED_KEYS)]
        return BuildSingleNeedReward(amount, key), NEED_LABELS[key] or "Need", key
    end

    if eggStage then
        local adolescentPool = self:BuildChallengePool(context, false)
        local eggPool = {}
        local function BuildEggCareLabel(actionType, goal)
            local count = math.max(1, math.floor(tonumber(goal) or 1))
            if actionType == "soothe" then
                return count == 1 and "Soothe the egg once" or string.format("Soothe the egg %d times", count)
            elseif actionType == "warm" then
                return count == 1 and "Warm the egg once" or string.format("Warm the egg %d times", count)
            end
            return count == 1 and "Clean the egg once" or string.format("Clean the egg shell %d times", count)
        end

        for _, template in ipairs(adolescentPool) do
            local challenge = CopyChallengeTemplate(template)
            if challenge then
                local challengeType = tostring(challenge.type or "")
                local isFoodBased = (challengeType == "feed" or challengeType == "eat_food")
                if challengeType ~= "work_ticks" and not isFoodBased then
                    if challengeType == "play" then
                        challenge.type = "soothe"
                        challenge.label = BuildEggCareLabel("soothe", challenge.goal)
                    elseif challengeType == "rest" then
                        challenge.type = "warm"
                        challenge.label = BuildEggCareLabel("warm", challenge.goal)
                    elseif challengeType == "clean" then
                        challenge.type = "clean"
                        challenge.label = BuildEggCareLabel("clean", challenge.goal)
                    end
                    challenge.reward = 0
                    challenge.rewardNeeds = nil
                    challenge.rewardAllNeeds = nil
                    challenge.completionFlavor = nil
                    challenge.rewardHatch = RollEggChallengeHatchReward()
                    eggPool[#eggPool + 1] = challenge
                end
            end
        end

        return eggPool
    end

    AddChallenge({
        id = "zone_activity_core",
        label = zoneChallengeLabel,
        type = "zone_activity",
        zone = zoneRequirement,
        goal = zoneGoal,
        progress = 0,
        reward = ScaleReward(20 + (zoneGoal * 2), level),
    })
    AddChallenge({
        id = "play_core",
        label = string.format("Play with %s %d times", petName, careGoal),
        type = "play",
        goal = careGoal,
        progress = 0,
        reward = ScaleReward(18 + (careGoal * 2), level),
    })
    AddChallenge({
        id = "feed_core",
        label = string.format("Feed %s %d times", petName, shortCareGoal),
        type = "feed",
        goal = shortCareGoal,
        progress = 0,
        reward = ScaleReward(16, level),
    })
    AddChallenge({
        id = "rest_core",
        label = string.format("Let %s rest %d times", petName, shortCareGoal),
        type = "rest",
        goal = shortCareGoal,
        progress = 0,
        reward = ScaleReward(16, level),
    })
    AddChallenge({
        id = "clean_core",
        label = string.format("Clean the habitat %d times", shortCareGoal),
        type = "clean",
        goal = shortCareGoal,
        progress = 0,
        reward = ScaleReward(14, level),
    })

    AddChallenge({
        id = "eat_food_hunger_5",
        label = "Eat some food!",
        type = "eat_food",
        goal = 1,
        progress = 0,
        reward = ScaleReward(14, level),
        rewardNeeds = { hunger = 50 },
        completionFlavor = "You shared a quick snack with %s (+50 Hunger).",
    })
    AddChallenge({
        id = "eat_food_hunger_10",
        label = "Share a hearty meal!",
        type = "eat_food",
        goal = 1,
        progress = 0,
        reward = ScaleReward(19, level),
        rewardNeeds = { hunger = 100 },
        completionFlavor = "%s polished off a hearty plate (+100 Hunger).",
    })
    AddChallenge({
        id = "eat_food_hunger_20",
        label = "Serve a feast!",
        type = "eat_food",
        goal = 1,
        progress = 0,
        reward = ScaleReward(24, level),
        rewardNeeds = { hunger = 200 },
        completionFlavor = "%s hit peak snack bliss (+200 Hunger).",
    })

    if context.hasFishing then
        AddChallenge({
            id = "fish_hunger_8",
            label = "Catch a fish!",
            type = "fish_zone",
            zone = zoneName,
            goal = 1,
            progress = 0,
            reward = ScaleReward(22, level),
            rewardNeeds = { hunger = 80 },
            completionFlavor = "Fresh catch for %s (+80 Hunger).",
        })
    else
        AddChallenge({
            id = "eat_food_hunger_8",
            label = "Eat a proper snack!",
            type = "eat_food",
            goal = 1,
            progress = 0,
            reward = ScaleReward(17, level),
            rewardNeeds = { hunger = 80 },
            completionFlavor = "%s enjoyed a proper snack (+80 Hunger).",
        })
    end

    AddChallenge({
        id = "joy_cheer_5",
        label = "Use /cheer!",
        type = "emote_cheer",
        goal = 1,
        progress = 0,
        reward = ScaleReward(13, level),
        rewardNeeds = { joy = 50 },
        completionFlavor = "%s lit up from your cheer (+50 Joy).",
    })
    AddChallenge({
        id = "joy_toy_8",
        label = "Use a toy from Collections!",
        type = "use_toy",
        goal = 1,
        progress = 0,
        reward = ScaleReward(16, level),
        rewardNeeds = { joy = 80 },
        completionFlavor = "%s lit up from your toy trick (+80 Joy).",
    })
    AddChallenge({
        id = "joy_summon_10",
        label = "Summon a mount or pet!",
        type = "summon_companion",
        goal = 1,
        progress = 0,
        reward = ScaleReward(18, level),
        rewardNeeds = { joy = 100 },
        completionFlavor = "%s was thrilled by your companion call (+100 Joy).",
    })
    AddChallenge({
        id = "joy_quests_20",
        label = "Complete 2 quests",
        type = "quest_turnin",
        goal = 2,
        progress = 0,
        reward = ScaleReward(26, level),
        rewardNeeds = { joy = 200 },
        completionFlavor = "%s went full celebration mode (+200 Joy).",
    })
    if level >= 10 then
        AddChallenge({
            id = "joy_dungeon_20",
            label = "Complete a dungeon",
            type = "dungeon_complete",
            goal = 1,
            progress = 0,
            reward = ScaleReward(30, level),
            rewardNeeds = { joy = 200 },
            completionFlavor = "%s loved the dungeon victory energy (+200 Joy).",
        })
    end

    local emoteRewardNeeds, emoteNeedLabel = BuildRandomNeedReward(50)
    AddChallenge({
        id = "emote_random_need_5",
        label = "Use any emote!",
        type = "emote",
        goal = 1,
        progress = 0,
        reward = ScaleReward(12, level),
        rewardNeeds = emoteRewardNeeds,
        completionFlavor = string.format("That emote gave %%s +50 %s.", emoteNeedLabel),
    })

    AddChallenge({
        id = "energy_lean_5",
        label = "Use /lean",
        type = "emote_lean",
        goal = 1,
        progress = 0,
        reward = ScaleReward(14, level),
        rewardNeeds = { energy = 50 },
        completionFlavor = "%s relaxed into your lean break (+50 Energy).",
    })
    if context.hasCooking then
        AddChallenge({
            id = "energy_fire_8",
            label = "Create a fire",
            type = "create_fire",
            goal = 1,
            progress = 0,
            reward = ScaleReward(16, level),
            rewardNeeds = { energy = 80 },
            completionFlavor = "%s warmed up by the fire (+80 Energy).",
        })
    else
        AddChallenge({
            id = "energy_fire_8_fallback",
            label = "Take a breather",
            type = "rest",
            goal = 1,
            progress = 0,
            reward = ScaleReward(16, level),
            rewardNeeds = { energy = 80 },
            completionFlavor = "%s caught a solid second wind (+80 Energy).",
        })
    end
    AddChallenge({
        id = "energy_sleep_10",
        label = "Use /sleep at an inn",
        type = "sleep_rested",
        goal = 1,
        progress = 0,
        reward = ScaleReward(18, level),
        rewardNeeds = { energy = 100 },
        completionFlavor = "%s took a deep inn nap (+100 Energy).",
    })
    AddChallenge({
        id = "energy_city_20",
        label = "Visit a major city",
        type = "visit_major_city",
        goal = 1,
        progress = 0,
        reward = ScaleReward(22, level),
        rewardNeeds = { energy = 200 },
        completionFlavor = "%s drew big-city momentum (+200 Energy).",
    })

    AddChallenge({
        id = "clean_drink_5",
        label = "Drink a drink",
        type = "drink_any",
        goal = 1,
        progress = 0,
        reward = ScaleReward(14, level),
        rewardNeeds = { cleanliness = 50 },
        completionFlavor = "%s rinsed off with a tidy sip break (+50 Cleanliness).",
    })
    AddChallenge({
        id = "clean_swim_short_8",
        label = "Go for a short swim",
        type = "swim_short",
        goal = 3,
        progress = 0,
        reward = ScaleReward(16, level),
        rewardNeeds = { cleanliness = 80 },
        completionFlavor = "%s got a quick rinse (+80 Cleanliness).",
    })
    AddChallenge({
        id = "clean_inn_10",
        label = "Visit an inn",
        type = "visit_inn",
        goal = 1,
        progress = 0,
        reward = ScaleReward(18, level),
        rewardNeeds = { cleanliness = 100 },
        completionFlavor = "%s cleaned up after the inn stop (+100 Cleanliness).",
    })
    AddChallenge({
        id = "clean_swim_long_20",
        label = "Go for a long swim",
        type = "swim_long",
        goal = 10,
        progress = 0,
        reward = ScaleReward(22, level),
        rewardNeeds = { cleanliness = 200 },
        completionFlavor = "%s did a full tidepool wash (+200 Cleanliness).",
    })

    AddChallenge({
        id = "use_potion_all_5",
        label = "Use a potion!",
        type = "use_potion",
        goal = 1,
        progress = 0,
        reward = ScaleReward(18, level),
        rewardAllNeeds = 50,
        completionFlavor = "That potion buff perked %s up across the board (+50 all needs).",
    })

    if context.hasGathering then
        local gatherLabel = "Gather something!"
        if context.hasMining then
            gatherLabel = "Mine some ore!"
        elseif context.hasHerbalism then
            gatherLabel = "Pick a flower!"
        elseif context.hasSkinning then
            gatherLabel = "Skin a beast!"
        end
        local gatherRewardNeeds, gatherNeedLabel = BuildRandomNeedReward(50)
        AddChallenge({
            id = "loot_any_random_need_5",
            label = gatherLabel,
            type = "loot_any",
            zone = zoneName,
            goal = 1,
            progress = 0,
            reward = ScaleReward(20, level),
            rewardNeeds = gatherRewardNeeds,
            completionFlavor = string.format("Gathering paid off and gave %%s +50 %s.", gatherNeedLabel),
        })
    end

    AddChallenge({
        id = "work_ticks_core",
        label = string.format("Complete %d scavenging ticks", ScaleGoal(4, level, 25, 10)),
        type = "work_ticks",
        goal = ScaleGoal(4, level, 25, 10),
        progress = 0,
        reward = ScaleReward(20, level),
    })

    for _, challenge in ipairs(optional) do
        pool[#pool + 1] = challenge
    end

    return pool
end

function MM:BuildEggTutorialChallenges()
    return {
        {
            id = "egg_tutorial_care_combo",
            label = "Complete 3 care actions",
            type = "zone_activity",
            goal = 3,
            progress = 0,
            reward = 0,
            rewardHatch = 6,
        },
        {
            id = "egg_tutorial_soothe_once",
            label = "Soothe the egg once",
            type = "soothe",
            goal = 1,
            progress = 0,
            reward = 0,
            rewardHatch = 3,
        },
        {
            id = "egg_tutorial_warm_once",
            label = "Warm the egg once",
            type = "warm",
            goal = 1,
            progress = 0,
            reward = 0,
            rewardHatch = 3,
        },
        {
            id = "egg_tutorial_clean_once",
            label = "Clean the egg once",
            type = "clean",
            goal = 1,
            progress = 0,
            reward = 0,
            rewardHatch = 3,
        },
    }
end

function MM:GenerateChallengeForSlot(slotIndex, previousChallenge, context, eggStage)
    local economy = self:GetEconomy()
    local active = (economy.challenges and economy.challenges.active) or {}
    context = context or self:GetCharacterChallengeContext()
    eggStage = eggStage == nil and (self:GetPet().stage == "egg") or eggStage

    local pool = self:BuildChallengePool(context, eggStage)
    if #pool == 0 then
        return nil
    end

    local blocked = {}
    for i, challenge in ipairs(active) do
        if i ~= slotIndex and type(challenge) == "table" then
            blocked[GetChallengeSignature(challenge)] = true
        end
    end

    local previousSignature = GetChallengeSignature(previousChallenge)
    ShuffleInPlace(pool)

    local fallback = nil
    for _, template in ipairs(pool) do
        local signature = GetChallengeSignature(template)
        if not blocked[signature] then
            if signature ~= previousSignature then
                local challenge = CopyChallengeTemplate(template)
                challenge.progress = 0
                if eggStage and (tonumber(challenge.rewardHatch) or 0) <= 0 then
                    challenge.rewardHatch = RollEggChallengeHatchReward()
                end
                return EnsureUniqueChallengeID(challenge)
            end
            fallback = fallback or template
        end
    end

    fallback = fallback or pool[1]
    if not fallback then
        return nil
    end
    local out = CopyChallengeTemplate(fallback)
    out.progress = 0
    if eggStage and (tonumber(out.rewardHatch) or 0) <= 0 then
        out.rewardHatch = RollEggChallengeHatchReward()
    end
    return EnsureUniqueChallengeID(out)
end

function MM:GenerateChallenges(silent)
    local economy = self:GetEconomy()
    economy.challenges = economy.challenges or {}
    economy.challenges.active = economy.challenges.active or {}
    economy.challenges.eggTutorialShown = economy.challenges.eggTutorialShown == true

    local context = self:GetCharacterChallengeContext()
    local eggStage = self:GetPet().stage == "egg"
    local active = {}
    if eggStage and not economy.challenges.eggTutorialShown then
        local tutorial = self:BuildEggTutorialChallenges()
        for slot = 1, CHALLENGE_SLOT_COUNT do
            local template = tutorial[slot]
            if template then
                local challenge = CopyChallengeTemplate(template)
                if challenge then
                    challenge.progress = 0
                    active[slot] = EnsureUniqueChallengeID(challenge)
                end
            end
            if not active[slot] then
                active[slot] = self:GenerateChallengeForSlot(slot, nil, context, eggStage)
            end
        end
        economy.challenges.eggTutorialShown = true
    else
        for slot = 1, CHALLENGE_SLOT_COUNT do
            active[slot] = self:GenerateChallengeForSlot(slot, nil, context, eggStage)
        end
    end
    economy.challenges.active = active
    economy.challenges.reloadReadyAt = tonumber(economy.challenges.reloadReadyAt) or 0
    self:TryAutoCompleteMajorCityChallenge(true)
    self:TryAutoCompleteVisitInnChallenge(true)

    if not silent then
        self:PushActivity("New challenge board posted.")
    end
end

function MM:GenerateReplacementChallenge()
    return self:GenerateChallengeForSlot(1, nil)
end

function MM:GenerateReplacementChallengeForIndex(index)
    local economy = self:GetEconomy()
    local active = economy.challenges and economy.challenges.active or {}
    return self:GenerateChallengeForSlot(index, active[index])
end

function MM:ReplaceCompletedChallengeByID(challengeID)
    local economy = self:GetEconomy()
    local active = economy.challenges and economy.challenges.active or {}
    local index = nil
    for i, ch in ipairs(active) do
        if ch and ch.id == challengeID then
            index = i
            break
        end
    end
    if not index then
        return false
    end

    return self:ReplaceCompletedChallengeAtIndex(index, challengeID)
end

function MM:ReplaceCompletedChallengeAtIndex(index, expectedID)
    local economy = self:GetEconomy()
    local active = economy.challenges and economy.challenges.active or {}
    local current = active[index]
    if not current then
        return false
    end
    if expectedID and current.id ~= expectedID then
        return false
    end

    local replacement = self:GenerateChallengeForSlot(index, current)
    if not replacement then
        return false
    end

    active[index] = replacement
    if self.RefreshChallengeCard then
        if self.frame and self.frame.ChallengeCells and self.frame.ChallengeCells[index] then
            self.frame.ChallengeCells[index]._lastRenderKey = nil
        end
        self:RefreshChallengeCard(index)
    end
    return true
end

function MM:DevCompleteChallengeSlot(slotIndex)
    slotIndex = tonumber(slotIndex)
    if not slotIndex or slotIndex < 1 or slotIndex > 4 then
        return false, "Challenge slot must be 1-4."
    end

    local economy = self:GetEconomy()
    local active = economy.challenges and economy.challenges.active or {}
    local challenge = active[slotIndex]
    if not challenge then
        return false, string.format("No challenge in slot %d.", slotIndex)
    end

    if self:ReplaceCompletedChallengeAtIndex(slotIndex, challenge.id) then
        if self.RefreshUI then
            self:RefreshUI(true)
        end
        return true, string.format("Challenge slot %d completed and refreshed.", slotIndex)
    end
    return false, string.format("Failed to refresh challenge slot %d.", slotIndex)
end

function MM:GetChallengeRerollRemaining()
    if self:GetPet().stage ~= "egg" then
        return 0
    end

    local economy = self:GetEconomy()
    local challenges = economy.challenges or {}
    local now = GetTime() or 0
    local readyAt = tonumber(challenges.nextRerollAt) or 0
    -- Normalize stale/session-mismatched cooldown timestamps so egg rerolls never get stuck.
    if readyAt > 0 and (readyAt > (now + CHALLENGE_REROLL_COOLDOWN) or readyAt < (now - 3600)) then
        readyAt = 0
        challenges.nextRerollAt = 0
    end
    return math.max(0, readyAt - now)
end

function MM:GetChallengeReloadRemaining(slotIndex)
    local economy = self:GetEconomy()
    local challenges = economy.challenges or {}
    local now = GetTime() or 0
    local readyAt = tonumber(challenges.reloadReadyAt) or 0
    -- Normalize stale/session-mismatched cooldown timestamps so reload buttons never get stuck disabled.
    if readyAt > 0 and (readyAt > (now + CHALLENGE_RELOAD_COOLDOWN) or readyAt < (now - 3600)) then
        readyAt = 0
        challenges.reloadReadyAt = 0
    end
    return math.max(0, readyAt - now)
end

function MM:ReloadChallengeAtIndex(slotIndex)
    slotIndex = tonumber(slotIndex)
    if not slotIndex or slotIndex < 1 or slotIndex > CHALLENGE_SLOT_COUNT then
        return false, string.format("Challenge slot must be 1-%d.", CHALLENGE_SLOT_COUNT)
    end

    local pet = self:GetPet()
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local challenges = economy.challenges or {}
    challenges.active = challenges.active or {}
    local challenge = challenges.active[slotIndex]
    if not challenge then
        return false, string.format("No challenge in slot %d.", slotIndex)
    end

    if pet.stage == "egg" then
        local remaining = self:GetChallengeRerollRemaining()
        if remaining > 0 then
            local mins = math.floor(remaining / 60)
            local secs = math.floor(remaining % 60)
            return false, string.format("Reload ready in %d:%02d.", mins, secs)
        end

        challenges.nextRerollAt = (GetTime() or 0) + CHALLENGE_REROLL_COOLDOWN

        if not self:ReplaceCompletedChallengeAtIndex(slotIndex, challenge.id) then
            return false, string.format("Could not reload challenge in slot %d.", slotIndex)
        end

        self:PushActivity(string.format("Reloaded egg challenge %d.", slotIndex))
        self:RefreshUI(true)
        return true, "Reloaded"
    end

    challenges.reloadReadyAt = tonumber(challenges.reloadReadyAt) or 0
    local remaining = self:GetChallengeReloadRemaining(slotIndex)
    if remaining > 0 then
        return false, string.format("Reload ready in %ds.", math.ceil(remaining))
    end

    if (store.shells or 0) < CHALLENGE_RELOAD_COST then
        return false, string.format("Need %d shells to reload.", CHALLENGE_RELOAD_COST)
    end

    store.shells = (store.shells or 0) - CHALLENGE_RELOAD_COST
    economy.shellsSpent = (economy.shellsSpent or 0) + CHALLENGE_RELOAD_COST
    challenges.reloadReadyAt = (GetTime() or 0) + CHALLENGE_RELOAD_COOLDOWN
    do
        local stats = self:GetInteractionStats()
        stats.challenges.reloads = (tonumber(stats.challenges.reloads) or 0) + 1
    end

    if not self:ReplaceCompletedChallengeAtIndex(slotIndex, challenge.id) then
        return false, string.format("Could not reload challenge in slot %d.", slotIndex)
    end

    self:PushActivity(string.format("Reloaded challenge %d for %d shells.", slotIndex, CHALLENGE_RELOAD_COST))
    self:RefreshUI(true)
    return true, "Reloaded"
end

function MM:RerollChallenges()
    local pet = self:GetPet()
    local store = self:GetStore()
    local economy = self:GetEconomy()

    if pet.stage ~= "egg" then
        if (store.shells or 0) < CHALLENGE_REROLL_COST_ADOLESCENT then
            return false, string.format("Need %d shells to reroll.", CHALLENGE_REROLL_COST_ADOLESCENT)
        end

        store.shells = store.shells - CHALLENGE_REROLL_COST_ADOLESCENT
        economy.shellsSpent = (economy.shellsSpent or 0) + CHALLENGE_REROLL_COST_ADOLESCENT
        economy.challenges = economy.challenges or {}
        economy.challenges.nextRerollAt = 0
        self:GenerateChallenges()
        self:RefreshUI()
        return true, "Rerolled"
    end

    local remaining = self:GetChallengeRerollRemaining()
    if remaining > 0 then
        local mins = math.floor(remaining / 60)
        local secs = math.floor(remaining % 60)
        return false, string.format("Reroll ready in %d:%02d.", mins, secs)
    end

    economy.challenges = economy.challenges or {}
    economy.challenges.nextRerollAt = (GetTime() or 0) + CHALLENGE_REROLL_COOLDOWN
    self:GenerateChallenges()
    self:RefreshUI()
    return true, "Rerolled"
end

function MM:AdvanceChallenges(challengeType, amount, context, suppressRefresh)
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local anyCompleted = false

    for slotIndex, challenge in ipairs(economy.challenges.active) do
        if challenge.progress < challenge.goal and challenge.type == challengeType then
            local matches = true

            -- Keep fish challenges globally completable; zone matching was too brittle.
            if challenge.type == "zone_activity" then
                local requiredZone = TrimString(challenge.zone or "")
                if requiredZone ~= "" and not IsChallengeZoneFallback(requiredZone) then
                    local zone = TrimString((context and context.zone) or "")
                    if zone == "" then
                        zone = ResolveCurrentZoneName()
                    end
                    if zone == "" or IsChallengeZoneFallback(zone) or zone ~= requiredZone then
                        matches = false
                    end
                end
            end
            -- Keep gather/loot challenges globally completable; zone matching was too brittle.

            if matches then
                challenge.progress = self:Clamp(challenge.progress + (amount or 1), 0, challenge.goal)

                if challenge.progress >= challenge.goal then
                    local pet = self:GetPet()
                    local reward = self:GetEffectiveChallengeShellReward(challenge)
                    if reward > 0 then
                        self:AddShells(reward)
                    end
                    local needRewardText = self:ApplyChallengeNeedRewards(challenge)
                    economy.challenges.completed = economy.challenges.completed + 1
                    anyCompleted = true
                    local completedChallengeID = challenge.id
                    challenge._mmCompletedAt = GetTime() or 0
                    challenge._mmReplaceAt = challenge._mmCompletedAt
                    if reward > 0 and needRewardText then
                        self:PushActivity(string.format("Challenge complete: %s (+%d shells, %s)", challenge.label, reward, needRewardText))
                    elseif reward > 0 then
                        self:PushActivity(string.format("Challenge complete: %s (+%d shells)", challenge.label, reward))
                    elseif needRewardText then
                        self:PushActivity(string.format("Challenge complete: %s (%s)", challenge.label, needRewardText))
                    else
                        self:PushActivity(string.format("Challenge complete: %s", challenge.label))
                    end
                    self:PushTraitChallengeReaction(challenge)
                    if pet.stage ~= "egg" and challenge.completionFlavor and challenge.completionFlavor ~= "" then
                        self:PushActivity(string.format(challenge.completionFlavor, pet.name))
                    end
                    if pet.stage == "egg" then
                        local hatchBonus = math.floor(tonumber(challenge.rewardHatch) or 0)
                        if hatchBonus <= 0 then
                            hatchBonus = RollEggChallengeHatchReward()
                            challenge.rewardHatch = hatchBonus
                        end
                        self:IncreaseHatchProgress(hatchBonus)
                        self:PushActivity(string.format("Challenge momentum warmed the egg (+%d%% hatch).", hatchBonus))
                    elseif IsGatheringChallengeType(challenge.type) then
                        self:ActivateGatheringScavengeBonus()
                    end
                    if completedChallengeID and self.ReplaceCompletedChallengeAtIndex then
                        self:ReplaceCompletedChallengeAtIndex(slotIndex, completedChallengeID)
                    end
                end
            end
        end
    end

    if not suppressRefresh then
        self:RefreshUI()
    end
end

function MM:AllChallengesCompleted()
    local challenges = self:GetEconomy().challenges.active
    for _, challenge in ipairs(challenges) do
        if challenge.progress < challenge.goal then
            return false
        end
    end
    return true
end

function MM:GetChallengeProgressText(challenge)
    if type(challenge) ~= "table" then
        return "0/0"
    end
    local progress = math.floor(tonumber(challenge.progress) or 0)
    local goal = math.floor(tonumber(challenge.goal) or 0)
    if challenge.type == "swim_short" or challenge.type == "swim_long" then
        return string.format("%d/%ds", progress, goal)
    end
    return string.format("%d/%d", progress, goal)
end

function MM:GetChallengeBoardText()
    local challenges = self:GetEconomy().challenges.active
    local pet = self:GetPet()
    local eggStage = pet and pet.stage == "egg"
    local lines = {}

    for _, challenge in ipairs(challenges) do
        local state = self:GetChallengeProgressText(challenge)
        if eggStage then
            local hatchReward = math.floor(tonumber(challenge.rewardHatch) or 0)
            if hatchReward <= 0 then
                hatchReward = EGG_CHALLENGE_HATCH_BONUS_MIN
            end
            lines[#lines + 1] = string.format("- %s (%s) [+%d%%]", challenge.label, state, hatchReward)
        else
            local rewardParts = {}
            local shellReward = self:GetEffectiveChallengeShellReward(challenge)
            if shellReward > 0 then
                rewardParts[#rewardParts + 1] = string.format("+%d shells", shellReward)
            end
            if not IsMoneyOnlyChallengeType(challenge.type) then
                local needText = BuildNeedRewardText(challenge)
                if needText and needText ~= "" then
                    rewardParts[#rewardParts + 1] = needText
                end
            end
            local rewardText = #rewardParts > 0 and table.concat(rewardParts, ", ") or "reward varies"
            lines[#lines + 1] = string.format("- %s (%s) [%s]", challenge.label, state, rewardText)
        end
    end

    if #lines == 0 then
        return "No active challenges."
    end

    return table.concat(lines, "\n")
end

function MM:GetWorkStatusText()
    local economy = self:GetEconomy()
    local skipped = tonumber(economy.work.pendingSkippedPayoutTicks) or 0
    local restartRemaining = self:GetWorkStartCooldownRemaining()
    local pausedForSafeZone = economy.work.pausedForSafeZone == true
    local frozenInInstance = self:IsNeedFreezeInstanceContent()
    local zone = GetRealZoneText()
    local inSafeZone = ((IsResting and IsResting()) or false) or self:IsMajorCityZone(zone)
    local payout = self:GetCurrentScavengeTickPayout()
    local buffRemaining = self:GetGatheringScavengeBonusRemaining()
    local buffText = ""
    if buffRemaining > 0 then
        local mins = math.floor(buffRemaining / 60)
        local secs = math.floor(buffRemaining % 60)
        buffText = string.format(", gather bonus +%d (%d:%02d)", GATHERING_SCAVENGE_BONUS_FLAT_PER_TICK, mins, secs)
    end
    if economy.work.active then
        if skipped > 0 then
            return string.format("Scavenging: yes (+%d shells/tick%s, payout skips: %d)", payout, buffText, skipped)
        end
        return string.format("Scavenging: yes (+%d shells/tick%s)", payout, buffText)
    end
    if pausedForSafeZone then
        if frozenInInstance then
            return "Scavenging: paused (instanced content)"
        end
        if inSafeZone then
            return "Scavenging: paused (rested/city zone)"
        end
        return "Scavenging: paused"
    end
    if restartRemaining > 0 then
        return string.format("Scavenging: no (rejoin in %.0fs)", restartRemaining)
    end
    if skipped > 0 then
        return string.format("Scavenging: no (queued payout skips: %d%s)", skipped, buffText)
    end
    if buffRemaining > 0 then
        local mins = math.floor(buffRemaining / 60)
        local secs = math.floor(buffRemaining % 60)
        return string.format("Scavenging: no (gather bonus +%d for %d:%02d)", GATHERING_SCAVENGE_BONUS_FLAT_PER_TICK, mins, secs)
    end
    return "Scavenging: no"
end

function MM:GetWorkStartCooldownRemaining()
    local economy = self:GetEconomy()
    local readyAt = tonumber(economy.work.nextStartAllowedAt) or 0
    local now = GetTime() or 0
    -- Normalize stale/session-mismatched cooldown timestamps so scavenge never shows runaway countdowns.
    if readyAt > 0 and (readyAt > (now + WORK_RESTART_COOLDOWN) or readyAt < (now - 3600)) then
        readyAt = 0
        economy.work.nextStartAllowedAt = 0
    end
    return math.max(0, readyAt - now)
end

function MM:StartWorkShift()
    local pet = self:GetPet()
    local economy = self:GetEconomy()
    local zone = GetRealZoneText()
    local inRestedArea = (IsResting and IsResting()) or false
    local inMajorCity = self:IsMajorCityZone(zone)
    local frozenInInstance = self:IsNeedFreezeInstanceContent()

    if pet.stage == "egg" then
        return false, "The egg cannot scavenge yet."
    end

    if frozenInInstance then
        return false, "Scavenging is disabled in instanced content."
    end

    if inRestedArea or inMajorCity then
        return false, "Scavenging is paused in inns and major cities."
    end

    if economy.work.active then
        return false, "Already scavenging."
    end

    local restartRemaining = self:GetWorkStartCooldownRemaining()
    if restartRemaining > 0 then
        return false, string.format("Scavenge ready in %.0fs.", restartRemaining)
    end

    economy.work.pausedForSafeZone = false
    economy.work.active = true
    economy.work.currentShiftTicks = 0
    do
        local stats = self:GetInteractionStats()
        stats.scavenge.starts = (tonumber(stats.scavenge.starts) or 0) + 1
    end
    self:PushActivity(string.format("%s started scavenging.", pet.name))
    self:RefreshUI()
    return true, "Scavenging"
end

function MM:StopWorkShift(reason)
    local economy = self:GetEconomy()
    if not economy.work.active then
        return false, "Not currently scavenging."
    end

    economy.work.active = false
    economy.work.pausedForSafeZone = false
    economy.work.currentShiftTicks = 0
    economy.work.nextStartAllowedAt = (GetTime() or 0) + WORK_RESTART_COOLDOWN
    if reason and reason ~= "" then
        self:PushActivity(reason)
    else
        self:PushActivity("Scavenging ended.")
    end
    self:RefreshUI()
    return true, "Stopped"
end

function MM:ToggleWorkShift()
    if self:GetEconomy().work.active then
        do
            local stats = self:GetInteractionStats()
            stats.scavenge.manualRecalls = (tonumber(stats.scavenge.manualRecalls) or 0) + 1
        end
        local petName = TrimString((self:GetPet().name or ""))
        if petName == "" then
            petName = "your hatchling"
        end
        self:StopWorkShift(string.format("You called %s home from scavenging.", petName))
    else
        local ok, msg = self:StartWorkShift()
        if not ok then
            self:PushActivity(msg)
        end
    end
end

function MM:InitializePetState(force)
    local pet = self.db.profile.pet
    local store = self.db.profile.store

    if force then
        pet = self.db.profile.pet
        store = self.db.profile.store
    end

    pet.name = self:SanitizePetName(pet.name)
    if pet.stage == "egg" and pet.name == "Mrrgl" then
        pet.name = ""
    end

    if not pet.personality or pet.personality == "Unknown" then
        pet.personality = PERSONALITIES[math.random(1, #PERSONALITIES)]
    end
    if not pet.personalitySecondary or pet.personalitySecondary == "" or pet.personalitySecondary == pet.personality then
        pet.personalitySecondary = RandomPersonalityExcluding(pet.personality)
    end

    local function normalizeNeedValue(rawValue, fallbackPercent)
        local value = tonumber(rawValue)
        if value == nil then
            value = NeedPercentToPoints(fallbackPercent)
        elseif IsLegacyNeedValue(value) then
            value = NeedPercentToPoints(value)
        end
        return ClampNeedValue(self, value)
    end

    pet.hunger = normalizeNeedValue(pet.hunger, 80)
    pet.joy = normalizeNeedValue(pet.joy, 70)
    pet.energy = normalizeNeedValue(pet.energy, 90)
    pet.cleanliness = normalizeNeedValue(pet.cleanliness, 80)
    pet.needScaleVersion = 2
    pet.hatchProgress = self:Clamp(tonumber(pet.hatchProgress) or 0, 0, 100)
    pet.readyToHatch = pet.readyToHatch == true
    if pet.stage == "egg" and pet.hatchProgress >= 100 then
        pet.readyToHatch = true
    end
    pet.ageMinutes = tonumber(pet.ageMinutes) or 0
    pet.eggCare = pet.eggCare or {}
    pet.eggCare.careProgress = tonumber(pet.eggCare.careProgress) or 0
    pet.eggCare.lastWarmAt = tonumber(pet.eggCare.lastWarmAt) or 0
    pet.eggCare.lastCleanAt = tonumber(pet.eggCare.lastCleanAt) or 0
    pet.eggCare.lastSootheAt = tonumber(pet.eggCare.lastSootheAt) or 0
    pet.actionCooldowns = pet.actionCooldowns or {}
    pet.actionCooldowns.feed = tonumber(pet.actionCooldowns.feed) or 0
    pet.actionCooldowns.play = tonumber(pet.actionCooldowns.play) or 0
    pet.actionCooldowns.clean = tonumber(pet.actionCooldowns.clean) or 0
    pet.actionCooldowns.rest = tonumber(pet.actionCooldowns.rest) or 0
    pet.actionCooldowns.find = tonumber(pet.actionCooldowns.find) or 0
    pet.behavior = pet.behavior or {}
    pet.behavior.nextDecisionTick = tonumber(pet.behavior.nextDecisionTick) or 0
    pet.behavior.lastEventKey = pet.behavior.lastEventKey or ""

    store.shells = tonumber(store.shells) or 120
    store.inventory = store.inventory or {}
    store.items = store.items or {}
    if store.nameTagFreeUsed ~= nil then
        store.nameTagFreeUsed = store.nameTagFreeUsed == true
    end
    if store.selectedSection == "feeding" then
        store.selectedSection = "habitat"
    end
    store.selectedSection = store.selectedSection or "food"
    self:GetHabitatCustomizationState()

    for _, item in ipairs(FOOD_ITEMS) do
        store.inventory[item.key] = tonumber(store.inventory[item.key]) or 0
    end

    for _, item in ipairs(STORE_CATALOG) do
        store.items[item.key] = CoerceOwnedCount(store.items[item.key])
    end
    self:NormalizeStoreItemOwnershipCaps()

    self:InitializeEconomyState()
    self:InitializeNeedThresholdTracker()
end

function MM:ResetMurloc()
    local pet = self:GetPet()
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local carriedShells = math.max(0, tonumber(store.shells) or 0)
    local preservedCustomization = (type(store.customization) == "table") and CopyTable(store.customization) or nil

    economy.work.active = false
    economy.work.currentShiftTicks = 0
    economy.work.pendingSkippedPayoutTicks = 0
    economy.work.nextStartAllowedAt = 0
    economy.work.gatheringBonusExpiresAt = 0
    economy.work.pausedForSafeZone = false
    economy.challenges.active = {}
    economy.challenges.nextRerollAt = 0
    economy.challenges.reloadReadyAt = 0
    economy.challenges.eggTutorialShown = false
    store.shells = carriedShells
    store.inventory = {}
    store.items = {}
    store.nameTagFreeUsed = false
    store.selectedSection = "food"
    if preservedCustomization then
        store.customization = preservedCustomization
    else
        store.customization = {
            selectedPage = "habitats",
            activeHabitatBackground = "auto",
            activeTitleKey = "",
            habitatOwned = {
                auto = true,
            },
        }
    end
    store.customization = store.customization or {}
    store.customization.habitatOwned = store.customization.habitatOwned or {}
    store.customization.habitatOwned.auto = true
    store.customization.selectedPage = tostring(store.customization.selectedPage or "habitats")
    if store.customization.selectedPage ~= "habitats" and store.customization.selectedPage ~= "titles" then
        store.customization.selectedPage = "habitats"
    end
    store.customization.activeHabitatBackground = tostring(store.customization.activeHabitatBackground or "auto")
    store.customization.activeTitleKey = tostring(store.customization.activeTitleKey or "")

    pet.name = ""
    pet.stage = "egg"
    pet.ageMinutes = 0
    pet.hunger = NEED_POINTS_MAX
    pet.joy = NEED_POINTS_MAX
    pet.energy = NEED_POINTS_MAX
    pet.cleanliness = NEED_POINTS_MAX
    pet.needScaleVersion = 2
    pet.hatchProgress = 0
    pet.readyToHatch = false
    pet.eggCare = {
        careProgress = 0,
        lastWarmAt = 0,
        lastCleanAt = 0,
        lastSootheAt = 0,
    }
    pet.actionCooldowns = {
        feed = 0,
        play = 0,
        clean = 0,
        rest = 0,
        find = 0,
    }
    pet.behavior = {
        nextDecisionTick = 0,
        lastEventKey = "",
        lastActionType = "",
        lastPolarity = "",
    }
    pet.personality = PERSONALITIES[math.random(1, #PERSONALITIES)]
    pet.personalitySecondary = RandomPersonalityExcluding(pet.personality)
    self.db.profile.activityLog = {}
    self.db.profile.activityHistory = {}
    self:GetHabitatCustomizationState()
    self:InitializeNeedThresholdTracker()
    self:GetInteractionStats()
    self:GenerateChallenges(true)
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "reset_murloc" })
    end
    self:PushActivity("Hatch your new friend.")
    self:RefreshUI(true)
end

local function GetNeedThresholdState(value)
    value = tonumber(value) or 0
    if value >= NEED_POINTS_MAX then
        return "maxed"
    end
    if value <= 0 then
        return "empty"
    end
    return "mid"
end

function MM:InitializeNeedThresholdTracker()
    local pet = self:GetPet()
    self._needThresholdState = {
        hunger = GetNeedThresholdState(pet.hunger),
        joy = GetNeedThresholdState(pet.joy),
        energy = GetNeedThresholdState(pet.energy),
        cleanliness = GetNeedThresholdState(pet.cleanliness),
    }
    self:InitializeTraitNeedAlertTracker()
end

function MM:InitializeTraitNeedAlertTracker()
    local pet = self:GetPet()
    self._traitNeedAlertState = {}
    if not pet or pet.stage == "egg" then
        return
    end

    for _, traitKey in ipairs(GetActiveTraitKeys(pet)) do
        local config = TRAIT_NEED_ALERTS[traitKey]
        if config then
            local needKey = tostring(config.need or "")
            local threshold = tonumber(config.threshold) or 30
            local value = tonumber(pet[needKey]) or 0
            self._traitNeedAlertState[traitKey] = value <= threshold
        end
    end
end

function MM:CheckTraitNeedAlertMessages()
    local pet = self:GetPet()
    if not pet or pet.stage == "egg" then
        self._traitNeedAlertState = {}
        return
    end

    self._traitNeedAlertState = self._traitNeedAlertState or {}
    local activeTraits = GetActiveTraitKeys(pet)
    local activeSet = {}
    for _, traitKey in ipairs(activeTraits) do
        activeSet[traitKey] = true
    end
    for traitKey in pairs(self._traitNeedAlertState) do
        if not activeSet[traitKey] then
            self._traitNeedAlertState[traitKey] = nil
        end
    end

    for _, traitKey in ipairs(activeTraits) do
        local config = TRAIT_NEED_ALERTS[traitKey]
        if config then
            local needKey = tostring(config.need or "")
            local threshold = tonumber(config.threshold) or 30
            local resetThreshold = tonumber(config.reset_threshold) or (threshold + 10)
            local currentValue = tonumber(pet[needKey]) or 0
            local wasLow = self._traitNeedAlertState[traitKey] == true
            local isLow = currentValue <= threshold

            if not wasLow and isLow then
                local line = RandomChoiceFromTable(config.lines or {})
                if line and line ~= "" then
                    self:PushActivity(string.format(line, pet.name), BENIGN_ACTIVITY_META)
                end
                self._traitNeedAlertState[traitKey] = true
            elseif wasLow and currentValue >= resetThreshold then
                self._traitNeedAlertState[traitKey] = false
            elseif self._traitNeedAlertState[traitKey] == nil then
                self._traitNeedAlertState[traitKey] = isLow
            end
        end
    end
end

function MM:PushTraitChallengeReaction(challenge)
    local pet = self:GetPet()
    if not pet or pet.stage == "egg" then
        return
    end

    local activeTraits = GetActiveTraitKeys(pet)
    if #activeTraits <= 0 then
        return
    end

    local traitKey = RandomChoiceFromTable(activeTraits)
    local lines = TRAIT_CHALLENGE_REACTIONS[traitKey]
    if not lines or #lines <= 0 then
        return
    end

    local line = RandomChoiceFromTable(lines)
    if line and line ~= "" then
        self:PushActivity(string.format(line, pet.name), BENIGN_ACTIVITY_META)
    end
end

function MM:CheckNeedThresholdMessages()
    if self._needThresholdCheckInProgress then
        return
    end

    local pet = self:GetPet()
    if not pet or pet.stage == "egg" then
        self:InitializeNeedThresholdTracker()
        return
    end

    if not self._needThresholdState then
        self:InitializeNeedThresholdTracker()
    end

    self._needThresholdCheckInProgress = true
    for needKey, messageTable in pairs(NEED_THRESHOLD_MESSAGES) do
        local currentState = GetNeedThresholdState(pet[needKey])
        local previousState = self._needThresholdState[needKey] or "mid"
        if currentState ~= previousState and (currentState == "maxed" or currentState == "empty") then
            local lines = messageTable[currentState]
            local line = lines and RandomChoiceFromTable(lines)
            if line and line ~= "" then
                self:PushActivity(string.format(line, pet.name), BENIGN_ACTIVITY_META)
            end
        end
        self._needThresholdState[needKey] = currentState
    end
    self:CheckTraitNeedAlertMessages()
    self._needThresholdCheckInProgress = false
end

function MM:GetAdolescentActionCooldown(action)
    local pet = self:GetPet()
    if pet.stage == "egg" then
        return 0, ADOLESCENT_ACTION_COOLDOWN
    end

    pet.actionCooldowns = pet.actionCooldowns or {}
    local now = GetTime() or 0
    local lastAt = tonumber(pet.actionCooldowns[action]) or 0
    -- Normalize legacy/stale cooldown timestamps (old sessions, epoch-based values, bad writes).
    if lastAt > 0 and (lastAt > (now + ADOLESCENT_ACTION_COOLDOWN) or lastAt < (now - 3600)) then
        lastAt = 0
        pet.actionCooldowns[action] = 0
    end
    if lastAt <= 0 then
        return 0, ADOLESCENT_ACTION_COOLDOWN
    end

    local remaining = ADOLESCENT_ACTION_COOLDOWN - (now - lastAt)
    return math.max(0, remaining), ADOLESCENT_ACTION_COOLDOWN
end

function MM:TriggerAdolescentActionCooldown(action)
    local pet = self:GetPet()
    if pet.stage == "egg" then
        return
    end
    pet.actionCooldowns = pet.actionCooldowns or {}
    pet.actionCooldowns[action] = GetTime() or 0
end

function MM:GetPersonalityWeights()
    local pet = self:GetPet()
    local primary = (pet.personality or ""):lower()
    local secondary = (pet.personalitySecondary or ""):lower()
    local weights = {
        responsible = 0,
        lazy = 0,
        curious = 0,
        playful = 0,
        brash = 0,
        calm = 0,
        mischievous = 0,
        loyal = 0,
        greedy = 0,
        shy = 0,
        bold = 0,
        neat = 0,
        gluttonous = 0,
        dreamy = 0,
        stubborn = 0,
        optimistic = 0,
        resourceful = 0,
    }
    if weights[primary] ~= nil then
        weights[primary] = 1
    end
    if weights[secondary] ~= nil and secondary ~= primary then
        weights[secondary] = weights[secondary] + SECONDARY_TRAIT_WEIGHT
    end
    return weights
end

function MM:BuildDrainTelemetryReport()
    local pet = self:GetPet()
    local store = self:GetStore()
    local economy = self:GetEconomy()
    local traits = self:GetPersonalityWeights()
    local work = economy.work or {}
    local nowText = (date and date("%Y-%m-%d %H:%M:%S")) or "unknown"
    local addonVersion = (self.GetAddonVersionText and self:GetAddonVersionText()) or "0.2.1"

    local function fmt(value, digits)
        return string.format("%." .. tostring(digits or 3) .. "f", tonumber(value) or 0)
    end

    local function pct(value)
        return string.format("%s%%", fmt(value, 2))
    end

    local function needPct(points)
        return string.format("%s%%", fmt(NeedPointsToPercent(points), 2))
    end

    local function boolText(value)
        return value and "true" or "false"
    end

    local lines = {}
    local function push(line)
        lines[#lines + 1] = tostring(line or "")
    end

    local function resolveItemLabel(itemKey)
        local key = tostring(itemKey or "")
        if key == "" then
            return "Unknown"
        end
        for _, food in ipairs(self:GetFoodItems() or {}) do
            if food.key == key then
                return food.label or key
            end
        end
        local storeEntry = self:GetStoreEntry(key)
        if storeEntry and storeEntry.label then
            return storeEntry.label
        end
        local habitatKey = key:match("^habitat_bg_(.+)$")
        local habitatEntry = self:GetHabitatCustomizationEntry(habitatKey or key)
        if habitatEntry and habitatEntry.label then
            return habitatEntry.label
        end
        return key
    end

    local function buildCountText(rows)
        if type(rows) ~= "table" or #rows == 0 then
            return "none"
        end
        table.sort(rows, function(a, b)
            local labelA = tostring(a.label or "")
            local labelB = tostring(b.label or "")
            local countA = tonumber(a.count) or 0
            local countB = tonumber(b.count) or 0
            if labelA == labelB then
                return countA > countB
            end
            return labelA < labelB
        end)
        local parts = {}
        for _, row in ipairs(rows) do
            local label = tostring(row.label or "Unknown")
            local count = tonumber(row.count) or 0
            if count > 1 then
                parts[#parts + 1] = string.format("%s x%d", label, count)
            else
                parts[#parts + 1] = label
            end
        end
        return table.concat(parts, ", ")
    end

    local function flattenOwnedTotals(text)
        local summary = tostring(text or "")
        summary = summary:gsub("^Owned Totals:%s*", "")
        summary = summary:gsub("\n", " | ")
        summary = TrimString(summary)
        return summary ~= "" and summary or "none"
    end

    local petName = TrimString((pet and pet.name) or "")
    if petName == "" then
        petName = "Unnamed Murloc"
    end

    local traitParts = {}
    for traitKey, weight in pairs(traits or {}) do
        local scaled = tonumber(weight) or 0
        if scaled > 0 then
            traitParts[#traitParts + 1] = string.format("%s=%.2f", traitKey, scaled)
        end
    end
    table.sort(traitParts)
    local traitsText = (#traitParts > 0) and table.concat(traitParts, ", ") or "none"

    local shells = tonumber(store.shells) or 0
    local shellsEarned = tonumber(economy.shellsEarned) or 0
    local shellsSpent = tonumber(economy.shellsSpent) or 0
    local stats = self:GetInteractionStats()
    local challengeStats = stats.challenges or {}
    local activeChallenges = (economy.challenges and economy.challenges.active) or {}
    local challengeDone = 0
    for _, challenge in ipairs(activeChallenges) do
        if challenge and (tonumber(challenge.progress) or 0) >= math.max(1, tonumber(challenge.goal) or 1) then
            challengeDone = challengeDone + 1
        end
    end

    local zoneName = TrimString(GetRealZoneText() or "")
    if zoneName == "" then
        zoneName = "unknown"
    end
    local resting = ((IsResting and IsResting()) or false)
    local inMajorCity = self:IsMajorCityZone(zoneName)
    local inInstance, instanceType = self:GetInstanceState()
    local frozenInInstance = self:IsNeedFreezeInstanceContent()
    local zoneMode = frozenInInstance and "instance" or (resting and "rested_xp" or "overworld")
    local scavengeStatusText = self:GetWorkStatusText()
    local restartRemaining = self:GetWorkStartCooldownRemaining()

    local customization = self:GetHabitatCustomizationState()
    local activeHabitatKey = self:GetActiveHabitatBackgroundChoice()
    local activeHabitatEntry = self:GetHabitatCustomizationEntry(activeHabitatKey)
    local activeHabitatLabel = (activeHabitatEntry and activeHabitatEntry.label) or tostring(activeHabitatKey or "auto")

    local ownedFoodRows = {}
    store.inventory = store.inventory or {}
    for _, food in ipairs(self:GetFoodItems() or {}) do
        local count = tonumber(store.inventory[food.key]) or 0
        if count > 0 then
            ownedFoodRows[#ownedFoodRows + 1] = { label = food.label or food.key, count = count }
        end
    end

    local ownedStoreRows = {}
    for _, entry in ipairs(self:GetStoreCatalog() or {}) do
        local count = tonumber(self:GetStoreItemCount(entry.key)) or 0
        if count > 0 then
            ownedStoreRows[#ownedStoreRows + 1] = { label = entry.label or entry.key, count = count }
        end
    end

    local ownedHabitatRows = {}
    for _, entry in ipairs(self:GetHabitatCustomizationCatalog() or {}) do
        if customization.habitatOwned[entry.key] == true then
            ownedHabitatRows[#ownedHabitatRows + 1] = { label = entry.label or entry.key, count = 1 }
        end
    end

    local purchaseRows = {}
    for itemKey, count in pairs(stats.purchasesByKey or {}) do
        local owned = tonumber(count) or 0
        if owned > 0 then
            purchaseRows[#purchaseRows + 1] = { label = resolveItemLabel(itemKey), count = owned }
        end
    end

    local habitatTotalsText = flattenOwnedTotals(self:GetStoreSectionTotalsText("habitat"))
    local trinketTotalsText = flattenOwnedTotals(self:GetStoreSectionTotalsText("trinket"))

    local summaryDecayLine = "Hunger: 0.000/tick Joy: 0.000/tick Energy: 0.000/tick Cleanliness: 0.000/tick"

    local needKeys = { "hunger", "joy", "energy", "cleanliness" }
    local current = {
        hunger = tonumber(pet.hunger) or 0,
        joy = tonumber(pet.joy) or 0,
        energy = tonumber(pet.energy) or 0,
        cleanliness = tonumber(pet.cleanliness) or 0,
    }
    local baseMain = {
        hunger = BASE_NEED_DECAY_PER_TICK.hunger,
        joy = BASE_NEED_DECAY_PER_TICK.joy,
        energy = BASE_NEED_DECAY_PER_TICK.energy,
        cleanliness = BASE_NEED_DECAY_PER_TICK.cleanliness,
    }
    local restedScale = resting and 0.5 or 1.0
    if frozenInInstance then
        restedScale = 0
    end
    local joyDrainFlatOffset = self:HasStoreUpgrade("coralFountain") and -(baseMain.joy * 0.10) or 0
    local cleanDrainFlatOffset = self:HasStoreUpgrade("tidepoolFilter") and -(baseMain.cleanliness * 0.12) or 0
    local traitFlat = {
        hunger = GetTraitNeedDrainFlatDelta(traits, "hunger"),
        joy = GetTraitNeedDrainFlatDelta(traits, "joy"),
        energy = GetTraitNeedDrainFlatDelta(traits, "energy"),
        cleanliness = GetTraitNeedDrainFlatDelta(traits, "cleanliness"),
    }
    local baseWithTrait = {
        hunger = baseMain.hunger,
        joy = baseMain.joy,
        energy = baseMain.energy,
        cleanliness = baseMain.cleanliness,
    }
    local mainNeedMultiplier = {}
    local mainDrain = { hunger = 0, joy = 0, energy = 0, cleanliness = 0 }
    local mainEnd = { hunger = current.hunger, joy = current.joy, energy = current.energy, cleanliness = current.cleanliness }
    local scavengeExtraNeedMultiplier = { hunger = 0, joy = 0, energy = 0, cleanliness = 0 }
    local scavengeExtraDrain = { hunger = 0, joy = 0, energy = 0, cleanliness = 0 }
    local totalDrain = { hunger = 0, joy = 0, energy = 0, cleanliness = 0 }
    local totalEnd = { hunger = current.hunger, joy = current.joy, energy = current.energy, cleanliness = current.cleanliness }

    if pet.stage ~= "egg" then
        for _, key in ipairs(needKeys) do
            mainNeedMultiplier[key] = GetNeedDrainMultiplier(current[key])
            local baseValue = baseMain[key]
            if key == "joy" then
                baseValue = math.max(0, baseValue + joyDrainFlatOffset)
            elseif key == "cleanliness" then
                baseValue = math.max(0, baseValue + cleanDrainFlatOffset)
            end
            baseWithTrait[key] = math.max(MIN_BASE_NEED_DRAIN_PER_TICK, baseValue + (traitFlat[key] or 0))
            mainDrain[key] = baseWithTrait[key]
                * mainNeedMultiplier[key]
                * restedScale
            mainEnd[key] = self:Clamp(current[key] - mainDrain[key], 0, NEED_POINTS_MAX)
        end

        if work.active then
            for _, key in ipairs(needKeys) do
                scavengeExtraNeedMultiplier[key] = SCAVENGE_DRAIN_BONUS_MULT
                scavengeExtraDrain[key] = mainDrain[key] * SCAVENGE_DRAIN_BONUS_MULT
                totalDrain[key] = mainDrain[key] + scavengeExtraDrain[key]
                totalEnd[key] = self:Clamp(current[key] - totalDrain[key], 0, NEED_POINTS_MAX)
            end
        else
            for _, key in ipairs(needKeys) do
                totalDrain[key] = mainDrain[key]
                totalEnd[key] = mainEnd[key]
            end
        end

        summaryDecayLine = string.format(
            "Hunger: %s/tick Joy: %s/tick Energy: %s/tick Cleanliness: %s/tick",
            fmt(NeedPointsToPercent(totalDrain.hunger), 3),
            fmt(NeedPointsToPercent(totalDrain.joy), 3),
            fmt(NeedPointsToPercent(totalDrain.energy), 3),
            fmt(NeedPointsToPercent(totalDrain.cleanliness), 3)
        )
    end

    local ticksPerMinute = 60 / NEEDS_DECAY_TICK_SECONDS

    push("Murloc Minder Drain Telemetry")
    push(string.rep("=", 30))
    push("version: " .. addonVersion)
    push("timestamp: " .. nowText)
    push("name: " .. petName)
    push("stage: " .. tostring(pet.stage or "unknown"))
    push("traits: " .. traitsText)
    push(summaryDecayLine)
    push(string.format("shells: current=%d earned=%d spent=%d", shells, shellsEarned, shellsSpent))
    push(string.format("scavenge: active=%s payout_per_tick=%d shift_ticks=%d pending_skip_ticks=%d",
        boolText(work.active == true),
        tonumber(self:GetCurrentScavengeTickPayout()) or 0,
        tonumber(work.currentShiftTicks) or 0,
        tonumber(work.pendingSkippedPayoutTicks) or 0
    ))
    push(string.format("zone: name=%s mode=%s resting=%s major_city=%s in_instance=%s instance_type=%s",
        zoneName,
        zoneMode,
        boolText(resting),
        boolText(inMajorCity),
        boolText(inInstance),
        tostring(instanceType ~= "" and instanceType or "none")
    ))
    push("scavenge_status: " .. scavengeStatusText)
    push(string.format("restart_remaining: %ss", fmt(restartRemaining, 2)))
    push("active_habitat: " .. activeHabitatLabel)
    push("owned_food_inventory: " .. buildCountText(ownedFoodRows))
    push("owned_store_items: " .. buildCountText(ownedStoreRows))
    push("owned_habitats: " .. buildCountText(ownedHabitatRows))
    push(string.format("purchase_history: total=%d items=%s", tonumber(stats.purchasesTotal) or 0, buildCountText(purchaseRows)))
    push("upgrade_totals_habitat: " .. habitatTotalsText)
    push("upgrade_totals_trinket: " .. trinketTotalsText)
    push(string.format("challenge_state: active=%d complete=%d reloads=%d",
        #activeChallenges,
        challengeDone,
        tonumber(challengeStats.reloads) or 0
    ))
    for index, challenge in ipairs(activeChallenges) do
        if challenge then
            push(string.format("challenge_%d: [%d/%d] %s (%s)",
                index,
                tonumber(challenge.progress) or 0,
                math.max(1, tonumber(challenge.goal) or 1),
                tostring(challenge.label or "Challenge"),
                tostring(challenge.type or "unknown")
            ))
        end
    end
    push("")

    if pet.stage == "egg" then
        push("Egg stage telemetry")
        push(string.format("hatch_progress: %s", pct(tonumber(pet.hatchProgress) or 0)))
        push(string.format("base_hatch_per_tick: %s", fmt(EGG_NATURAL_HATCH_PER_TICK, 3)))
        push("need_drain: disabled in egg stage")
        return table.concat(lines, "\n")
    end

    push("effects")
    push(string.format("tick_seconds=%d points_scale=%d rested_scale=%s instance_freeze=%s instance_type=%s joy_drain_offset=%s clean_drain_offset=%s trait_flat_mode=direct_matrix min_base_drain=%s scavenge_bonus_mult=%s",
        NEEDS_DECAY_TICK_SECONDS,
        NEED_POINTS_SCALE,
        fmt(restedScale, 2),
        boolText(frozenInInstance),
        tostring(instanceType ~= "" and instanceType or "none"),
        fmt(NeedPointsToPercent(joyDrainFlatOffset), 3),
        fmt(NeedPointsToPercent(cleanDrainFlatOffset), 3),
        fmt(NeedPointsToPercent(MIN_BASE_NEED_DRAIN_PER_TICK), 3),
        fmt(SCAVENGE_DRAIN_BONUS_MULT, 2)
    ))
    push("")
    push("need telemetry")
    for _, key in ipairs(needKeys) do
        push(string.format(
            "%s current=%s need_mult=%s trait_flat_tick=%s base_with_trait_tick=%s main_drain_tick=%s main_drain_min=%s main_end=%s",
            key,
            needPct(current[key]),
            fmt(mainNeedMultiplier[key], 2),
            fmt(NeedPointsToPercent(traitFlat[key]), 3),
            fmt(NeedPointsToPercent(baseWithTrait[key]), 3),
            fmt(NeedPointsToPercent(mainDrain[key]), 3),
            fmt(NeedPointsToPercent(mainDrain[key]) * ticksPerMinute, 3),
            needPct(mainEnd[key])
        ))
        if work.active then
            push(string.format(
                "%s scavenge_extra_need_mult=%s scavenge_extra_tick=%s total_drain_tick=%s total_drain_min=%s total_end=%s",
                key,
                fmt(scavengeExtraNeedMultiplier[key], 2),
                fmt(NeedPointsToPercent(scavengeExtraDrain[key]), 3),
                fmt(NeedPointsToPercent(totalDrain[key]), 3),
                fmt(NeedPointsToPercent(totalDrain[key]) * ticksPerMinute, 3),
                needPct(totalEnd[key])
            ))
        end
    end

    return table.concat(lines, "\n")
end

function MM:BuildAutonomousBehaviorEvents(context)
    local pet = context.pet
    local store = context.store
    local economy = context.economy
    local traits = context.traits
    local lowJoy = context.lowJoy
    local lowEnergy = context.lowEnergy
    local lowClean = context.lowClean
    local lowHunger = context.lowHunger
    local needsPressure = context.needsPressure
    local working = context.working
    local workStartCooldown = context.workStartCooldown
    local foodCandidates = context.foodCandidates
    local shiftTicks = math.max(0, tonumber(economy.work.currentShiftTicks) or 0)
    local shiftFatigue = Clamp01((shiftTicks - 4) / 30)
    local quitTraitBias = Clamp01(
        0.35
        + (traits.lazy * 0.35)
        + (traits.dreamy * 0.20)
        + (traits.playful * 0.15)
        + (traits.mischievous * 0.15)
        - (traits.responsible * 0.25)
        - (traits.calm * 0.15)
        - (traits.stubborn * 0.10)
        - (traits.loyal * 0.10)
    )
    local quitPressure = shiftFatigue * (0.65 + (quitTraitBias * 0.90))

    local function adjustNeedByFlat(needKey, deltaFlat)
        pet[needKey] = ClampNeedValue(self, (tonumber(pet[needKey]) or 0) + (tonumber(deltaFlat) or 0))
    end

    local function adjustNeeds(delta)
        adjustNeedByFlat("hunger", delta.hunger or 0)
        adjustNeedByFlat("joy", delta.joy or 0)
        adjustNeedByFlat("energy", delta.energy or 0)
        adjustNeedByFlat("cleanliness", delta.cleanliness or 0)
    end

    local function adjustShells(delta)
        local current = tonumber(store.shells) or 0
        if delta >= 0 then
            return self:AddShells(delta)
        end

        local loss = math.min(current, math.abs(delta))
        self:AddShells(-loss)
        return -loss
    end

    local function addItem(itemKey, amount)
        return self:AdjustRewardItemCount(itemKey, amount)
    end

    local function getItemLabel(itemKey)
        for _, food in ipairs(self:GetFoodItems()) do
            if food.key == itemKey then
                return food.label
            end
        end
        local entry = self:GetStoreEntry(itemKey)
        if entry then
            return entry.label
        end
        return itemKey
    end

    local tierWeights = {
        [1] = 50,
        [2] = 25,
        [3] = 15,
        [4] = 9,
        [5] = 1,
    }

    local function buildItemTierLookup()
        local byKey = {}

        local sortedFoods = {}
        for _, item in ipairs(self:GetFoodItems()) do
            sortedFoods[#sortedFoods + 1] = item
        end
        table.sort(sortedFoods, function(a, b)
            local ap = tonumber(a.price) or 0
            local bp = tonumber(b.price) or 0
            if ap == bp then
                return tostring(a.key or "") < tostring(b.key or "")
            end
            return ap < bp
        end)
        for i, item in ipairs(sortedFoods) do
            byKey[item.key] = math.max(1, math.min(5, i))
        end

        local byCategory = {}
        for _, entry in ipairs(self:GetStoreCatalog()) do
            local category = tostring(entry.category or "misc")
            byCategory[category] = byCategory[category] or {}
            byCategory[category][#byCategory[category] + 1] = entry
        end

        for _, entries in pairs(byCategory) do
            table.sort(entries, function(a, b)
                local ap = tonumber(a.price) or 0
                local bp = tonumber(b.price) or 0
                if ap == bp then
                    return tostring(a.key or "") < tostring(b.key or "")
                end
                return ap < bp
            end)
            local total = #entries
            for i, entry in ipairs(entries) do
                local tier = math.max(1, math.min(5, math.ceil((i * 5) / math.max(1, total))))
                byKey[entry.key] = tier
            end
        end

        return byKey
    end

    local itemTierByKey = buildItemTierLookup()

    local function chooseTierByWeight(availableByTier)
        local total = 0
        for tier = 1, 5 do
            if availableByTier[tier] and #availableByTier[tier] > 0 then
                total = total + (tierWeights[tier] or 0)
            end
        end
        if total <= 0 then
            return nil
        end

        local roll = math.random() * total
        local running = 0
        for tier = 1, 5 do
            if availableByTier[tier] and #availableByTier[tier] > 0 then
                running = running + (tierWeights[tier] or 0)
                if roll <= running then
                    return tier
                end
            end
        end

        return nil
    end

    local function tryLoseOwnedItemByTier()
        if math.random() > 0.25 then
            return nil, 0
        end

        local availableByTier = {
            [1] = {},
            [2] = {},
            [3] = {},
            [4] = {},
            [5] = {},
        }

        for _, food in ipairs(self:GetFoodItems()) do
            if (tonumber(store.inventory[food.key]) or 0) > 0 then
                local tier = itemTierByKey[food.key] or 3
                availableByTier[tier][#availableByTier[tier] + 1] = food.key
            end
        end
        for _, entry in ipairs(self:GetStoreCatalog()) do
            if CARE_ITEM_CATEGORIES[entry.category] and CoerceOwnedCount(store.items[entry.key]) > 0 then
                local tier = itemTierByKey[entry.key] or 3
                availableByTier[tier][#availableByTier[tier] + 1] = entry.key
            end
        end

        local tier = chooseTierByWeight(availableByTier)
        if not tier then
            return nil, 0
        end
        local itemKey = RandomChoiceFromTable(availableByTier[tier])
        if not itemKey then
            return nil, 0
        end

        local removed = -addItem(itemKey, -1)
        if removed > 0 then
            self:RecordLossStat(itemKey, removed)
            return itemKey, tier
        end
        return nil, 0
    end

    local function randomGainItem()
        return self:RollRandomFindItem()
    end

    local function canonicalPairKey(a, b)
        local first = tostring(a or ""):lower()
        local second = tostring(b or ""):lower()
        if first == "" or second == "" or first == second then
            return nil
        end
        if first < second then
            return first .. "_" .. second
        end
        return second .. "_" .. first
    end

    local activePairKey = canonicalPairKey(pet.personality, pet.personalitySecondary)

    local actionNeedHints = {
        eat = { hunger = 120, joy = 25, energy = -10, cleanliness = -8 },
        play = { hunger = -20, joy = 120, energy = -80, cleanliness = -90 },
        rest = { hunger = -40, joy = 15, energy = 130, cleanliness = -20 },
        clean = { hunger = -10, joy = -10, energy = -25, cleanliness = 130 },
        work = { hunger = -35, joy = -25, energy = -70, cleanliness = -60 },
    }

    local function GetEventNeedHint(event)
        if type(event) ~= "table" then
            return nil
        end
        if type(event.needHint) == "table" then
            return event.needHint
        end
        return actionNeedHints[event.actionType]
    end

    local function GetNeedBalanceScale(event)
        local hint = GetEventNeedHint(event)
        if type(hint) ~= "table" then
            return 1.0
        end

        local alignScore = 0
        local harmScore = 0
        for _, needKey in ipairs(TRAIT_NEED_KEYS) do
            local delta = tonumber(hint[needKey]) or 0
            if delta ~= 0 then
                local current = tonumber(pet[needKey]) or 0
                local deficit = NeedDeficit(current)
                local excess = NeedExcess(current)
                if delta > 0 then
                    alignScore = alignScore + (delta * (0.20 + deficit))
                    harmScore = harmScore + (delta * excess * 1.55)
                else
                    local drop = math.abs(delta)
                    alignScore = alignScore + (drop * excess * 0.85)
                    harmScore = harmScore + (drop * (0.30 + deficit) * 1.85)
                end
            end
        end

        local score = alignScore - harmScore
        local scale = 1 + (score / 240)
        return math.max(0.18, math.min(2.15, scale))
    end

    local function BuildTargetActionShares()
        local targets = {
            eat = 0.20 + (NeedDeficit(pet.hunger) * 0.30) - (NeedExcess(pet.hunger) * 0.08),
            play = 0.20 + (NeedDeficit(pet.joy) * 0.22) - (NeedExcess(pet.joy) * 0.20),
            rest = 0.20 + (NeedDeficit(pet.energy) * 0.30) - (NeedExcess(pet.energy) * 0.08),
            clean = 0.20 + (NeedDeficit(pet.cleanliness) * 0.30) - (NeedExcess(pet.cleanliness) * 0.08),
            work = 0.20 + (needsPressure * 0.10) - (((NeedDeficit(pet.energy) + NeedDeficit(pet.cleanliness)) * 0.5) * 0.06),
        }

        local total = 0
        for _, actionType in ipairs(AUTON_ACTION_TYPES) do
            targets[actionType] = math.max(0.08, math.min(0.50, tonumber(targets[actionType]) or 0.20))
            total = total + targets[actionType]
        end
        if total <= 0 then
            local fallback = 1 / #AUTON_ACTION_TYPES
            for _, actionType in ipairs(AUTON_ACTION_TYPES) do
                targets[actionType] = fallback
            end
            return targets
        end
        for _, actionType in ipairs(AUTON_ACTION_TYPES) do
            targets[actionType] = targets[actionType] / total
        end
        return targets
    end

    local function getActionMotivation(actionType)
        if actionType == "eat" then
            return Clamp01(lowHunger + NeedDeficit(pet.hunger) * 0.40 - NeedExcess(pet.hunger) * 0.10)
        end
        if actionType == "play" then
            return Clamp01(lowJoy + NeedDeficit(pet.joy) * 0.35 - NeedExcess(pet.joy) * 0.42)
        end
        if actionType == "rest" then
            return Clamp01(lowEnergy + NeedDeficit(pet.energy) * 0.40 - NeedExcess(pet.energy) * 0.10)
        end
        if actionType == "clean" then
            return Clamp01(lowClean + NeedDeficit(pet.cleanliness) * 0.40 - NeedExcess(pet.cleanliness) * 0.10)
        end
        if actionType == "work" then
            local pressure = (NeedDeficit(pet.hunger) * 0.50) + (NeedDeficit(pet.joy) * 0.15) + (NeedDeficit(pet.energy) * 0.20)
            if working then
                pressure = pressure + 0.08
            end
            return Clamp01(pressure)
        end
        return Clamp01(needsPressure)
    end

    local function normalizeEventWeights(eventList)
        local polarityTotals = { positive = 0, negative = 0 }
        local actionTotals = {}
        for _, actionType in ipairs(AUTON_ACTION_TYPES) do
            actionTotals[actionType] = 0
        end
        local targetActionShares = BuildTargetActionShares()
        local behavior = pet.behavior or {}
        local lastActionType = tostring(behavior.lastActionType or "")
        local lastPolarity = tostring(behavior.lastPolarity or "")

        for _, event in ipairs(eventList) do
            local weight = tonumber(event.weight) or 0
            if weight > 0 then
                local polarity = event.polarity == "negative" and "negative" or "positive"
                local actionType = event.actionType
                if actionTotals[actionType] == nil then
                    actionType = "play"
                    event.actionType = actionType
                end
                weight = weight * GetTraitActionPolarityScale(traits, actionType, polarity)
                weight = weight * GetNeedBalanceScale(event)
                if lastActionType ~= "" and actionType == lastActionType then
                    weight = weight * 0.58
                end
                if lastPolarity ~= "" and polarity == lastPolarity then
                    weight = weight * 0.90
                end
                event.weight = weight
                polarityTotals[polarity] = polarityTotals[polarity] + weight
                actionTotals[actionType] = actionTotals[actionType] + weight
            end
        end

        local targetPolarityShare = 0.5
        for _, event in ipairs(eventList) do
            local weight = tonumber(event.weight) or 0
            if weight > 0 then
                local polarity = event.polarity == "negative" and "negative" or "positive"
                local actionType = event.actionType
                if actionTotals[actionType] == nil then
                    actionType = "play"
                end
                local pTotal = polarityTotals[polarity]
                local aTotal = actionTotals[actionType]
                local pScaleRaw = (pTotal > 0) and (targetPolarityShare / pTotal) or 1
                local targetActionShare = tonumber(targetActionShares[actionType]) or (1 / #AUTON_ACTION_TYPES)
                local aScaleRaw = (aTotal > 0) and (targetActionShare / aTotal) or 1
                local pScale = 1 + ((pScaleRaw - 1) * 0.35)
                local aScale = 1 + ((aScaleRaw - 1) * 0.34)
                local motivation = getActionMotivation(actionType)
                local motivationScale = 0.55 + (motivation * 1.00)
                event.weight = weight * pScale * aScale * motivationScale
                if event.isBespoke then
                    event.weight = event.weight * 0.28
                end
            end
        end
    end

    local traitNeedProfiles = {
        responsible = { pos = { hunger = -10, joy = 20, energy = -20, cleanliness = 80 }, neg = { hunger = -50, joy = -80, energy = -120, cleanliness = -60 } },
        lazy = { pos = { hunger = -20, joy = 60, energy = 120, cleanliness = -20 }, neg = { hunger = -30, joy = -60, energy = -50, cleanliness = -120 } },
        curious = { pos = { hunger = -20, joy = 90, energy = -40, cleanliness = -30 }, neg = { hunger = -50, joy = -50, energy = -90, cleanliness = -100 } },
        playful = { pos = { hunger = -20, joy = 120, energy = -60, cleanliness = -40 }, neg = { hunger = -40, joy = -90, energy = -80, cleanliness = -70 } },
        brash = { pos = { hunger = -20, joy = 80, energy = -50, cleanliness = -50 }, neg = { hunger = -60, joy = -80, energy = -110, cleanliness = -90 } },
        calm = { pos = { hunger = -10, joy = 40, energy = 80, cleanliness = 60 }, neg = { hunger = -20, joy = -70, energy = -60, cleanliness = -50 } },
        mischievous = { pos = { hunger = -10, joy = 110, energy = -40, cleanliness = -60 }, neg = { hunger = -40, joy = -100, energy = -80, cleanliness = -110 } },
        loyal = { pos = { hunger = -10, joy = 60, energy = -30, cleanliness = 100 }, neg = { hunger = -40, joy = -70, energy = -100, cleanliness = -70 } },
        greedy = { pos = { hunger = -30, joy = 40, energy = -60, cleanliness = -40 }, neg = { hunger = -60, joy = -60, energy = -100, cleanliness = -120 } },
        shy = { pos = { hunger = -10, joy = 50, energy = 90, cleanliness = 40 }, neg = { hunger = -30, joy = -90, energy = -70, cleanliness = -80 } },
        bold = { pos = { hunger = -20, joy = 100, energy = -80, cleanliness = -40 }, neg = { hunger = -50, joy = -70, energy = -130, cleanliness = -100 } },
        neat = { pos = { hunger = -10, joy = 40, energy = -20, cleanliness = 140 }, neg = { hunger = -30, joy = -60, energy = -70, cleanliness = -100 } },
        gluttonous = { pos = { hunger = 120, joy = 30, energy = -20, cleanliness = -20 }, neg = { hunger = -120, joy = -50, energy = -80, cleanliness = -60 } },
        dreamy = { pos = { hunger = -10, joy = 60, energy = 120, cleanliness = 0 }, neg = { hunger = -30, joy = -70, energy = -90, cleanliness = -80 } },
        stubborn = { pos = { hunger = -20, joy = 50, energy = -10, cleanliness = 60 }, neg = { hunger = -50, joy = -80, energy = -90, cleanliness = -70 } },
        optimistic = { pos = { hunger = -10, joy = 100, energy = 50, cleanliness = 10 }, neg = { hunger = -30, joy = -80, energy = -70, cleanliness = -60 } },
        resourceful = { pos = { hunger = 80, joy = 40, energy = -20, cleanliness = 30 }, neg = { hunger = -60, joy = -60, energy = -80, cleanliness = -80 } },
    }

    local events = {
        {
            key = "auto_work_shift",
            actionType = "work",
            polarity = "positive",
            weight = ((not working) and workStartCooldown <= 0 and (0.04 + traits.responsible * 2.4 + NeedDeficit(pet.hunger) * 0.6 + NeedDeficit(pet.joy) * 0.2) or 0)
                * (1 - lowClean * 0.75),
            run = function()
                if self:GetWorkStartCooldownRemaining() > 0 then
                    return
                end
                economy.work.active = true
                economy.work.currentShiftTicks = 0
                self:PushActivity(string.format("%s did a proud 'mrgl!' and started scavenging on their own.", pet.name), true)
            end,
        },
        {
            key = "lazy_quit_work",
            actionType = "work",
            polarity = "negative",
            weight = (working and (0.03 + (traits.lazy * 2.6) + lowJoy * 1.6 + lowEnergy * 1.2 + (quitPressure * 1.9)) or 0),
            run = function()
                economy.work.active = false
                economy.work.currentShiftTicks = 0
                economy.work.nextStartAllowedAt = (GetTime() or 0) + WORK_RESTART_COOLDOWN
                adjustNeedByFlat("joy", 80)
                adjustNeedByFlat("energy", 50)
                self:PushActivity(string.format("%s flopped over with a dramatic 'mrrr' and bailed on scavenging.", pet.name), true)
            end,
        },
        {
            key = "curious_mud_play",
            actionType = "play",
            polarity = "negative",
            weight = 0.08 + (traits.curious * 2.5) + (traits.mischievous * 1.2) + lowJoy * 1.8,
            run = function()
                adjustNeedByFlat("joy", 140)
                adjustNeedByFlat("cleanliness", -180)
                adjustNeedByFlat("energy", -40)
                self:PushActivity(string.format("%s dove snout-first into a muddy tidepool and loved every second.", pet.name), true)
            end,
        },
        {
            key = "self_tidy",
            actionType = "clean",
            polarity = "positive",
            weight = 0.08 + (lowClean * 2.0) + (traits.responsible * 1.8) + (traits.calm * 1.0),
            run = function()
                adjustNeedByFlat("cleanliness", 200)
                adjustNeedByFlat("joy", -30)
                adjustNeedByFlat("energy", -20)
                self:PushActivity(string.format("%s did a fussy little shell-scrub patrol around the habitat.", pet.name), true)
            end,
        },
        {
            key = "wild_stunt",
            actionType = "play",
            polarity = "negative",
            weight = 0.08 + (lowJoy * 2.4) + (traits.brash * 2.0) + (traits.mischievous * 1.2),
            run = function()
                adjustNeedByFlat("joy", 160)
                adjustNeedByFlat("energy", -110)
                adjustNeedByFlat("cleanliness", -120)
                self:PushActivity(string.format("%s attempted a heroic splash-flip and faceplanted magnificently.", pet.name), true)
            end,
        },
        {
            key = "power_nap",
            actionType = "rest",
            polarity = "positive",
            weight = 0.08 + (lowEnergy * 2.7) + (traits.lazy * 1.4) + (traits.calm * 1.1),
            run = function()
                adjustNeedByFlat("energy", 220)
                adjustNeedByFlat("hunger", -70)
                adjustNeedByFlat("joy", 20)
                if working and math.random() < self:Clamp(0.45 + (quitPressure * 0.35), 0, 0.90) then
                    economy.work.active = false
                    economy.work.currentShiftTicks = 0
                    economy.work.nextStartAllowedAt = (GetTime() or 0) + WORK_RESTART_COOLDOWN
                    self:PushActivity(string.format("%s curled up in seaweed and skipped scavenging for a nap.", pet.name), true)
                else
                    self:PushActivity(string.format("%s took a champion-grade tidepool nap.", pet.name), true)
                end
            end,
        },
        {
            key = "snack_stash",
            actionType = "eat",
            polarity = "positive",
            weight = (#foodCandidates > 0 and (0.08 + lowHunger * 2.8 + traits.responsible * 0.4 + traits.playful * 0.6) or 0),
            run = function()
                local item = RandomChoiceFromTable(foodCandidates)
                if not item then
                    return
                end
                local consumed = TryConsumeItemFromTable(store.inventory, item.key, 1)
                if not consumed then
                    return
                end
                adjustNeedByFlat("hunger", ((tonumber(item.hunger) or 0) * NEED_POINTS_SCALE * 0.7))
                adjustNeedByFlat("joy", ((tonumber(item.joy) or 0) * NEED_POINTS_SCALE * 0.5))
                self:PushActivity(string.format("%s sniffed out a hidden %s and gobbled it in two bites.", pet.name, item.label), true)
            end,
        },
        {
            key = "small_shell_haul",
            actionType = "work",
            polarity = "positive",
            weight = ((not working) and (0.04 + traits.responsible * 1.4 + traits.curious * 0.9 + NeedDeficit(pet.hunger) * 0.3) or 0),
            run = function()
                local gain = math.random(3, 8)
                self:AddShells(gain)
                adjustNeedByFlat("energy", -50)
                adjustNeedByFlat("cleanliness", -60)
                self:PushActivity(string.format("%s went full beach-goblin and hauled in %d shells.", pet.name, gain), true)
            end,
        },
        {
            key = "forage_food_stash",
            actionType = "eat",
            polarity = "positive",
            weight = ((not working) and (0.04 + lowHunger * 2.4 + traits.curious * 1.2 + traits.responsible * 0.8) or 0),
            run = function()
                local foodKey = RandomChoiceFromTable({ "kelpSnack", "tideWorm", "bubbleBiscuit" })
                if not foodKey then
                    return
                end
                store.inventory[foodKey] = (store.inventory[foodKey] or 0) + 1
                local label = foodKey
                for _, food in ipairs(self:GetFoodItems()) do
                    if food.key == foodKey then
                        label = food.label
                        break
                    end
                end
                self:PushActivity(string.format("%s poked around the reeds and surfaced with %s.", pet.name, label), true)
            end,
        },
        {
            key = "find_supplies_cache",
            actionType = "work",
            polarity = "positive",
            weight = ((not working) and (0.04 + lowJoy * 1.1 + lowEnergy * 0.8 + traits.curious * 1.4 + traits.mischievous * 0.6) or 0),
            run = function()
                local itemLabel
                local itemKey = randomGainItem()
                if itemKey then
                    local added = addItem(itemKey, 1)
                    if added > 0 then
                        itemLabel = getItemLabel(itemKey)
                    end
                end

                local shells = math.random(1, 4)
                self:AddShells(shells)
                if itemLabel then
                    self:PushActivity(string.format("%s dug up a sneaky cache: %s plus %d shells.", pet.name, itemLabel, shells), true)
                else
                    self:PushActivity(string.format("%s dug up a sneaky cache worth %d shells.", pet.name, shells), true)
                end
            end,
        },
        {
            key = "avoid_work_dirty",
            actionType = "work",
            polarity = "negative",
            weight = (working and (0.03 + lowClean * 3.2 + traits.calm * 0.8 + (quitPressure * 1.5)) or 0),
            run = function()
                economy.work.active = false
                economy.work.currentShiftTicks = 0
                economy.work.nextStartAllowedAt = (GetTime() or 0) + WORK_RESTART_COOLDOWN
                adjustNeedByFlat("cleanliness", 80)
                self:PushActivity(string.format("%s made a scandalized 'mrrgl?!' face and quit scavenging to clean up.", pet.name), true)
            end,
        },
        {
            key = "playful_song",
            actionType = "play",
            polarity = "positive",
            weight = 0.08 + (traits.playful * 2.0) + (traits.calm * 1.0) + lowJoy * 0.9,
            run = function()
                adjustNeedByFlat("joy", 110)
                adjustNeedByFlat("energy", -30)
                self:PushActivity(string.format("%s sang an off-key murloc ballad to the tidepool.", pet.name), true)
            end,
        },
        {
            key = "mischief_cleanup_penalty",
            actionType = "clean",
            polarity = "negative",
            weight = 0.08 + (traits.mischievous * 1.9) + lowJoy * 0.7,
            run = function()
                adjustNeedByFlat("cleanliness", -100)
                adjustNeedByFlat("joy", 60)
                self:PushActivity(string.format("%s made a chaotic splashy mess and called it art.", pet.name), true)
            end,
        },
    }

    local traitOrder = {}
    local traitLabels = {}
    local traitPositiveActions = {
        responsible = "organized a neat shell route",
        lazy = "found a comfy current and recovered smoothly",
        curious = "investigated a strange tidepool nook",
        playful = "started a playful splash game nearby",
        brash = "charged into a bold shoreline run",
        calm = "settled into a steady, focused routine",
        mischievous = "pulled a clever little prank that paid off",
        loyal = "guarded the habitat edge and came back with steady finds",
        greedy = "chased every glinting shell trail and scored big",
        shy = "worked the quiet shallows and quietly found value",
        bold = "launched into a daring reef sweep and returned triumphant",
        neat = "organized the stash area and found extra useful supplies",
        gluttonous = "followed snack-scent trails into a lucky haul",
        dreamy = "drifted through a perfect tide rhythm and returned refreshed",
        stubborn = "dug in with determined grit until the route paid out",
        optimistic = "spotted the bright side of the tide and turned it into a lucky run",
        resourceful = "pieced together odd scraps into a useful little windfall",
    }
    local traitNegativeActions = {
        responsible = "overplanned and spent too long fussing with details",
        lazy = "put things off until it became a mess",
        curious = "followed a distraction into trouble",
        playful = "got carried away and broke the rhythm",
        brash = "took a reckless shortcut",
        calm = "moved too slowly and missed an opportunity",
        mischievous = "pushed a prank too far",
        loyal = "stayed on guard too long and missed easier shell lanes",
        greedy = "overreached for one more shiny shell and spilled the haul",
        shy = "hesitated at the shoreline and lost momentum",
        bold = "forced an overconfident rush and wiped out hard",
        neat = "got stuck over-cleaning gear and wasted prime scavenging time",
        gluttonous = "raided the snack stash mid-run and came back short",
        dreamy = "spaced out watching bubbles and drifted off-route",
        stubborn = "refused to pivot from a bad route and paid the price",
        optimistic = "got carried away by big hopes and overlooked the obvious",
        resourceful = "jury-rigged a risky shortcut that fell apart mid-run",
    }
    local pairPositiveTemplates = {
        "charted a fresh mrrgl-route and came back with shiny finds",
        "timed a splash-pass perfectly and stayed on a juicy shell line",
        "ran a proper shell-sweep and looked very proud about it",
        "rode the current lanes like a seasoned tide gremlin",
        "sniffed out a rich reed pocket and cleaned it out",
        "cycled between pools with smug little efficiency",
        "kept a crisp scavenging groove from first splash to last",
        "read the waterline like an old tide sage",
        "worked the shallows with determined murloc focus",
        "picked smart spots in sequence and stayed locked in",
        "managed a clean perimeter loop like a tiny foreman",
        "did a quick hotspot run and came back richer",
        "tracked shell trails with suspiciously good instincts",
        "pivoted routes mid-run and made it pay",
        "used a perfect tide window and cashed in",
        "stacked the haul neatly with meticulous little grunts",
        "covered the cove before the tide could betray them",
        "found a clean scavenging lane and milked it",
        "linked several small finds into one glorious haul",
        "stayed disciplined and closed the run like a pro",
        "executed a laser-focused pass and returned triumphant",
    }
    local pairNegativeTemplates = {
        "rushed the opener and yeeted part of the planned haul",
        "misread the tide turn and lost all momentum",
        "hyper-focused one spot and missed the good lanes",
        "bungled route order and waddled in circles",
        "drifted off-task chasing bubbles and came back short",
        "followed a weak lead straight into wasted time",
        "took a clumsy shallow line and wiped out",
        "let the run get messy and dropped part of the take",
        "hesitated at key moments and missed the prime window",
        "packed like chaos and spilled supplies on return",
        "burned out too early and lost pacing",
        "stalled in a dead patch with no payoff",
        "overreached near the rocks and lost gathered goods",
        "botched the handoff and misplaced useful stash",
        "ignored clear warning signs and paid for it",
        "fell into a sloppy rhythm and bled value",
        "picked noisy routes and scared off easy finds",
        "cut through rough water and got punished for it",
        "spread effort thin and left shells behind",
        "tripped on the finish and spilled the take",
        "forced a messy finale and gave up hard-earned gains",
    }
    for _, traitName in ipairs(PERSONALITIES) do
        local key = string.lower(traitName)
        traitOrder[#traitOrder + 1] = key
        traitLabels[key] = traitName
    end

    for traitIndex, traitKey in ipairs(traitOrder) do
        local traitLabel = traitLabels[traitKey] or traitKey
        local profile = traitNeedProfiles[traitKey]
        local traitPower = traits[traitKey] or 0
        local assignedAction = AUTON_ACTION_TYPES[((traitIndex - 1) % #AUTON_ACTION_TYPES) + 1]

        events[#events + 1] = {
            key = "trait_pos_" .. traitKey,
            actionType = assignedAction,
            polarity = "positive",
            weight = 0.10 + (traitPower * 1.35) + (needsPressure * 0.15),
            run = function()
                local shellGain = math.random(2, 5) + math.floor(traitPower * 2)
                adjustShells(shellGain)
                adjustNeeds(profile and profile.pos or { joy = 50, energy = 20 })
                local gainedKey = randomGainItem()
                local added = addItem(gainedKey, 1)
                local itemText = (added > 0 and gainedKey) and string.format(" and picked up %s", getItemLabel(gainedKey)) or ""
                local actionText = traitPositiveActions[traitKey] or "made a clever tidepool move"
                self:PushActivity(string.format("%s %s, gaining %d shells%s.", pet.name, actionText, shellGain, itemText), true)
            end,
        }

        events[#events + 1] = {
            key = "trait_neg_" .. traitKey,
            actionType = assignedAction,
            polarity = "negative",
            weight = 0.08 + (traitPower * 0.95) + (needsPressure * 0.22),
            run = function()
                local shellLoss = math.random(2, 6) + math.floor(traitPower)
                local actualShellDelta = adjustShells(-shellLoss)
                adjustNeeds(profile and profile.neg or { joy = -60, energy = -50, cleanliness = -40 })
                local lostKey = tryLoseOwnedItemByTier()
                local itemText = ""
                if lostKey then
                    itemText = string.format(" after misplacing %s", getItemLabel(lostKey))
                end
                local actionText = traitNegativeActions[traitKey] or "made a messy call"
                self:PushActivity(string.format("%s %s, losing %d shells%s.", pet.name, actionText, math.abs(actualShellDelta), itemText), true)
            end,
        }
    end

    local pairIndex = 0
    for i = 1, #traitOrder - 1 do
        for j = i + 1, #traitOrder do
            pairIndex = pairIndex + 1
            local traitA = traitOrder[i]
            local traitB = traitOrder[j]
            local pairPower = (traits[traitA] or 0) + (traits[traitB] or 0)
            local needSlot = pairIndex % 4
            local assignedAction = AUTON_ACTION_TYPES[((pairIndex - 1) % #AUTON_ACTION_TYPES) + 1]
            local pairPosKey = string.format("pair_pos_%s_%s", traitA, traitB)
            local pairNegKey = string.format("pair_neg_%s_%s", traitA, traitB)

            events[#events + 1] = {
                key = pairPosKey,
                actionType = assignedAction,
                polarity = "positive",
                weight = 0.06 + (pairPower * 0.85) + (needsPressure * 0.10),
                run = function()
                    local shellGain = math.random(2, 5) + math.floor(pairPower * 2)
                    adjustShells(shellGain)
                    if needSlot == 0 then
                        adjustNeeds({ hunger = 60, joy = 20, energy = 10, cleanliness = 10 })
                    elseif needSlot == 1 then
                        adjustNeeds({ hunger = 10, joy = 80, energy = 10, cleanliness = 0 })
                    elseif needSlot == 2 then
                        adjustNeeds({ hunger = 0, joy = 20, energy = 80, cleanliness = 10 })
                    else
                        adjustNeeds({ hunger = 10, joy = 20, energy = 10, cleanliness = 80 })
                    end
                    local gainedKey = randomGainItem()
                    local added = addItem(gainedKey, 1)
                    local itemText = (added > 0 and gainedKey) and string.format(" and picked up %s", getItemLabel(gainedKey)) or ""
                    local pairMessage = AUTONOMOUS_PAIR_EFFECT_OVERRIDES[pairPosKey]
                    if not pairMessage or pairMessage == "" then
                        local pairAction = pairPositiveTemplates[pairIndex] or "pulled off a rare combo move and came back with extra haul"
                        pairMessage = string.format("%%s %s, gaining %%d shells%%s.", pairAction)
                    end
                    self:PushActivity(FormatPairEffectMessage(pairMessage, pet.name, shellGain, itemText), true)
                end,
            }

            events[#events + 1] = {
                key = pairNegKey,
                actionType = assignedAction,
                polarity = "negative",
                weight = 0.05 + (pairPower * 0.65) + (needsPressure * 0.20),
                run = function()
                    local shellLoss = math.random(2, 5) + math.floor(pairPower)
                    local actualShellDelta = adjustShells(-shellLoss)
                    if needSlot == 0 then
                        adjustNeeds({ hunger = -80, joy = -30, energy = -20, cleanliness = -20 })
                    elseif needSlot == 1 then
                        adjustNeeds({ hunger = -30, joy = -90, energy = -20, cleanliness = -20 })
                    elseif needSlot == 2 then
                        adjustNeeds({ hunger = -20, joy = -30, energy = -90, cleanliness = -20 })
                    else
                        adjustNeeds({ hunger = -20, joy = -30, energy = -20, cleanliness = -90 })
                    end
                    local lostKey = tryLoseOwnedItemByTier()
                    local itemText = ""
                    if lostKey then
                        itemText = string.format(" and lost %s", getItemLabel(lostKey))
                    end
                    local pairMessage = AUTONOMOUS_PAIR_EFFECT_OVERRIDES[pairNegKey]
                    if not pairMessage or pairMessage == "" then
                        local pairAction = pairNegativeTemplates[pairIndex] or "fumbled a rare combo move and paid for it in shells and supplies"
                        pairMessage = string.format("%%s %s, losing %%d shells%%s.", pairAction)
                    end
                    self:PushActivity(FormatPairEffectMessage(pairMessage, pet.name, math.abs(actualShellDelta), itemText), true)
                end,
            }
        end
    end

    local bespokePairEvents = {
        ["lazy_resourceful"] = {
            { key = "siesta_gizmo", actionType = "work", polarity = "positive", base = 0.020, pressure = 0.025, needs = { hunger = -20, joy = 40, energy = 60, cleanliness = -40 }, shellMin = 4, shellMax = 9, gainItem = true, text = "%s rigged a nap-friendly shell-sifter and hauled in %d shells%s." },
            { key = "shortcut_spill", actionType = "clean", polarity = "negative", base = 0.017, pressure = 0.022, needs = { hunger = -30, joy = -40, energy = -30, cleanliness = -110 }, shellMin = 3, shellMax = 7, loseItem = true, text = "%s took a comfy shortcut that dumped the supply pile and cost %d shells%s." },
        },
        ["mischievous_optimistic"] = {
            { key = "prank_party", actionType = "play", polarity = "positive", base = 0.019, pressure = 0.024, needs = { hunger = -20, joy = 120, energy = -50, cleanliness = -50 }, shellMin = 3, shellMax = 8, gainItem = true, text = "%s turned a harmless prank into a full mrrgl festival, netting %d shells%s." },
            { key = "prank_backfire", actionType = "play", polarity = "negative", base = 0.018, pressure = 0.024, needs = { hunger = -30, joy = -90, energy = -70, cleanliness = -90 }, shellMin = 4, shellMax = 8, loseItem = true, text = "%s laughed too early when a prank backfired, dropping %d shells%s." },
        },
        ["greedy_responsible"] = {
            { key = "ledger_run", actionType = "work", polarity = "positive", base = 0.020, pressure = 0.026, needs = { hunger = -30, joy = 20, energy = -50, cleanliness = -40 }, shellMin = 5, shellMax = 10, gainItem = true, text = "%s ran a strict shell ledger route and banked %d shells%s." },
            { key = "overclock_shift", actionType = "rest", polarity = "negative", base = 0.017, pressure = 0.023, needs = { hunger = -40, joy = -50, energy = -110, cleanliness = -80 }, shellMin = 4, shellMax = 8, loseItem = true, text = "%s overworked the route and crashed, losing %d shells%s." },
        },
        ["bold_calm"] = {
            { key = "steady_surge", actionType = "play", polarity = "positive", base = 0.019, pressure = 0.024, needs = { hunger = -20, joy = 90, energy = -50, cleanliness = -30 }, shellMin = 3, shellMax = 7, gainItem = true, text = "%s picked the perfect moment for a daring tide-surge and won %d shells%s." },
            { key = "late_retreat", actionType = "work", polarity = "negative", base = 0.017, pressure = 0.023, needs = { hunger = -30, joy = -40, energy = -90, cleanliness = -60 }, shellMin = 3, shellMax = 7, loseItem = true, text = "%s held a risky line a little too long and lost %d shells%s." },
        },
        ["curious_resourceful"] = {
            { key = "reef_hack_cache", actionType = "eat", polarity = "positive", base = 0.020, pressure = 0.024, needs = { hunger = 80, joy = 50, energy = -30, cleanliness = -20 }, shellMin = 3, shellMax = 8, gainItem = true, text = "%s reverse-engineered a reef cache trick, finding %d shells%s." },
            { key = "tinker_tangle", actionType = "clean", polarity = "negative", base = 0.017, pressure = 0.023, needs = { hunger = -30, joy = -50, energy = -60, cleanliness = -100 }, shellMin = 3, shellMax = 7, loseItem = true, text = "%s got tangled in a gadget experiment and dropped %d shells%s." },
        },
        ["neat_playful"] = {
            { key = "sparkle_games", actionType = "clean", polarity = "positive", base = 0.019, pressure = 0.024, needs = { hunger = -20, joy = 80, energy = -30, cleanliness = 110 }, shellMin = 3, shellMax = 7, gainItem = true, text = "%s invented a tidy splash game and earned %d shells%s." },
            { key = "overdecorated", actionType = "clean", polarity = "negative", base = 0.017, pressure = 0.022, needs = { hunger = -20, joy = -40, energy = -50, cleanliness = -90 }, shellMin = 3, shellMax = 6, loseItem = true, text = "%s got carried away decorating and misplaced %d shells%s." },
        },
        ["loyal_stubborn"] = {
            { key = "hold_the_line", actionType = "work", polarity = "positive", base = 0.020, pressure = 0.025, needs = { hunger = -30, joy = 30, energy = -50, cleanliness = 50 }, shellMin = 4, shellMax = 9, gainItem = true, text = "%s refused to abandon patrol and secured %d shells%s." },
            { key = "refused_pivot", actionType = "work", polarity = "negative", base = 0.017, pressure = 0.024, needs = { hunger = -30, joy = -60, energy = -90, cleanliness = -50 }, shellMin = 4, shellMax = 8, loseItem = true, text = "%s refused a safer route change and lost %d shells%s." },
        },
        ["dreamy_shy"] = {
            { key = "quiet_recovery", actionType = "rest", polarity = "positive", base = 0.020, pressure = 0.023, needs = { hunger = -10, joy = 60, energy = 120, cleanliness = 20 }, shellMin = 2, shellMax = 6, gainItem = true, text = "%s found a hidden calm nook, restored up, and found %d shells%s." },
            { key = "missed_window", actionType = "work", polarity = "negative", base = 0.017, pressure = 0.022, needs = { hunger = -20, joy = -70, energy = -60, cleanliness = -40 }, shellMin = 3, shellMax = 6, loseItem = true, text = "%s hesitated in a daydream and missed a tide window worth %d shells%s." },
        },
        ["brash_optimistic"] = {
            { key = "hero_splash", actionType = "play", polarity = "positive", base = 0.019, pressure = 0.024, needs = { hunger = -20, joy = 110, energy = -70, cleanliness = -60 }, shellMin = 4, shellMax = 9, gainItem = true, text = "%s yelled 'mrgl me!' and landed a heroic splash-run for %d shells%s." },
            { key = "showoff_wipeout", actionType = "play", polarity = "negative", base = 0.018, pressure = 0.024, needs = { hunger = -30, joy = -80, energy = -100, cleanliness = -90 }, shellMin = 4, shellMax = 9, loseItem = true, text = "%s tried a showoff finish, wiped out, and lost %d shells%s." },
        },
        ["gluttonous_resourceful"] = {
            { key = "snack_stash_map", actionType = "eat", polarity = "positive", base = 0.020, pressure = 0.024, needs = { hunger = 120, joy = 40, energy = -20, cleanliness = -30 }, shellMin = 3, shellMax = 7, gainItem = true, text = "%s mapped snack scents to hidden stashes, gaining %d shells%s." },
            { key = "grease_fumble", actionType = "clean", polarity = "negative", base = 0.017, pressure = 0.023, needs = { hunger = -50, joy = -40, energy = -50, cleanliness = -120 }, shellMin = 3, shellMax = 7, loseItem = true, text = "%s over-snacked mid-run, slipped in kelp grease, and dropped %d shells%s." },
        },
        ["mischievous_neat"] = {
            { key = "precision_prank", actionType = "clean", polarity = "positive", base = 0.019, pressure = 0.024, needs = { hunger = -20, joy = 80, energy = -40, cleanliness = 90 }, shellMin = 3, shellMax = 7, gainItem = true, text = "%s executed a perfectly tidy prank and pocketed %d shells%s." },
            { key = "organized_chaos", actionType = "clean", polarity = "negative", base = 0.017, pressure = 0.022, needs = { hunger = -20, joy = -50, energy = -50, cleanliness = -100 }, shellMin = 3, shellMax = 6, loseItem = true, text = "%s misjudged a precision prank cleanup and lost %d shells%s." },
        },
        ["lazy_responsible"] = {
            { key = "slow_and_steady", actionType = "rest", polarity = "positive", base = 0.020, pressure = 0.023, needs = { hunger = -20, joy = 50, energy = 90, cleanliness = 40 }, shellMin = 3, shellMax = 7, gainItem = true, text = "%s balanced naps with duty and still brought home %d shells%s." },
            { key = "schedule_slump", actionType = "work", polarity = "negative", base = 0.017, pressure = 0.023, needs = { hunger = -30, joy = -60, energy = -80, cleanliness = -70 }, shellMin = 3, shellMax = 7, loseItem = true, text = "%s drifted off schedule mid-shift and lost %d shells%s." },
        },
    }

    local function addBespokeEvent(pairKey, spec)
        if not pairKey or not spec then
            return
        end
        local isActivePair = (activePairKey == pairKey)
        events[#events + 1] = {
            key = "pair_bespoke_" .. pairKey .. "_" .. tostring(spec.key or "event"),
            actionType = spec.actionType or "play",
            polarity = spec.polarity == "negative" and "negative" or "positive",
            isBespoke = true,
            weight = isActivePair and ((spec.base or 0.015) + (needsPressure * (spec.pressure or 0.02))) or 0,
            run = function()
                adjustNeeds(spec.needs or {})
                local shellAmount = math.random(spec.shellMin or 2, spec.shellMax or 6)
                local shellDelta
                if spec.polarity == "negative" then
                    shellDelta = adjustShells(-shellAmount)
                else
                    shellDelta = adjustShells(shellAmount)
                end

                local itemText = ""
                if spec.gainItem then
                    local gainedKey = randomGainItem()
                    local added = addItem(gainedKey, 1)
                    if added > 0 and gainedKey then
                        itemText = string.format(" and found %s", getItemLabel(gainedKey))
                    end
                elseif spec.loseItem then
                    local lostKey = tryLoseOwnedItemByTier()
                    if lostKey then
                        itemText = string.format(" plus misplaced %s", getItemLabel(lostKey))
                    end
                end

                local shellValue = math.abs(shellDelta or 0)
                local message = spec.text or "%s had a strange pair-trait moment worth %d shells%s."
                self:PushActivity(string.format(message, pet.name, shellValue, itemText), true)
            end,
        }
    end

    for pairKey, specs in pairs(bespokePairEvents) do
        for _, spec in ipairs(specs) do
            addBespokeEvent(pairKey, spec)
        end
    end

    normalizeEventWeights(events)
    return events
end

function MM:BuildBenignAutonomousBehaviorEvents(context)
    local pet = context.pet
    local traits = context.traits or {}
    local needsPressure = context.needsPressure or 0

    local needConfigs = {
        {
            key = "hunger",
            actionType = "eat",
            label = "Hunger",
            value = tonumber(pet.hunger) or 0,
            messages = {
                positive = {
                    "%s looks happily full and does a smug little mrgl-wiggle.",
                    "%s pats their belly and drifts like royalty through the shells.",
                    "%s is well-fed and making satisfied bubbling noises.",
                },
                neutral = {
                    "%s sniffs around for a snack, then shrugs and keeps cruising.",
                    "%s eyes the food pile, gives a quiet mrrp, and moves on.",
                    "%s lingers by the snack bowl like a tiny critic.",
                },
                negative = {
                    "%s looks hungry and grumbles in low bubbly murloc.",
                    "%s rubs their belly and haunts the food corner.",
                    "%s is distracted by hunger and keeps sniffing the air.",
                },
                maxed = {
                    "%s is mega-stuffed and rejects snacks with regal disdain.",
                },
                empty = {
                    "%s is starving and does a wobbly, desperate little shuffle.",
                },
            },
        },
        {
            key = "joy",
            actionType = "play",
            label = "Joy",
            value = tonumber(pet.joy) or 0,
            messages = {
                positive = {
                    "%s is pure joy-beast right now, splash-bouncing everywhere.",
                    "%s chirps and zooms between rocks like a happy torpedo.",
                    "%s is in top spirits and inventing games every few seconds.",
                },
                neutral = {
                    "%s watches ripples with mild curiosity and a soft mrrp.",
                    "%s wanders around, chill but not exactly thrilled.",
                    "%s keeps it mellow: half-play, half-loaf.",
                },
                negative = {
                    "%s looks bored and stares into the distance dramatically.",
                    "%s barely reacts to toys and gives a tiny sighing gurgle.",
                    "%s is restless and impossible to impress right now.",
                },
                maxed = {
                    "%s is so hyped they can barely stay in one puddle.",
                },
                empty = {
                    "%s is full-on moody and refuses fun on principle.",
                },
            },
        },
        {
            key = "energy",
            actionType = "rest",
            label = "Energy",
            value = tonumber(pet.energy) or 0,
            messages = {
                positive = {
                    "%s is turbo-charged and bouncing with mission energy.",
                    "%s zips through the habitat like a tiny rocket fish.",
                    "%s looks sharp, alert, and ready to do murloc business.",
                },
                neutral = {
                    "%s stretches, blinks, and keeps a steady tidepool pace.",
                    "%s takes a breather, then resumes with calm determination.",
                    "%s looks awake enough, just not in turbo mode.",
                },
                negative = {
                    "%s lets out a long yawn and drops to sleepy speed.",
                    "%s looks worn out and keeps taking flop-breaks.",
                    "%s is drained and struggling to keep moving.",
                },
                maxed = {
                    "%s is vibrating with energy and questionable decisions.",
                },
                empty = {
                    "%s is exhausted and basically puddled in place.",
                },
            },
        },
        {
            key = "cleanliness",
            actionType = "clean",
            label = "Cleanliness",
            value = tonumber(pet.cleanliness) or 0,
            messages = {
                positive = {
                    "%s is sparkling and preening like a little tide celebrity.",
                    "%s keeps those scales polished to showroom shine.",
                    "%s looks freshly rinsed and very pleased about it.",
                },
                neutral = {
                    "%s does a quick rinse-and-shake and keeps going.",
                    "%s splashes off the worst of it and calls it good.",
                    "%s runs a light cleanup lap around the habitat.",
                },
                negative = {
                    "%s looks grimy and sulky in the muck.",
                    "%s tracks mud everywhere like it is a personal brand.",
                    "%s is noticeably dirty and dodging clean water.",
                },
                maxed = {
                    "%s is pristine from fin to tail, pure ocean glamour.",
                },
                empty = {
                    "%s is caked in grime and one splash away from scandal.",
                },
            },
        },
    }

    local events = {}
    for _, need in ipairs(needConfigs) do
        local value = self:Clamp(NeedPointsToPercent(need.value or 0), 0, 100)
        local actionType = need.actionType

        local function addNeedEvent(state, weight, polarity, keySuffix)
            local lines = need.messages[state]
            if not lines or #lines == 0 then
                return
            end
            events[#events + 1] = {
                key = string.format("benign_%s_%s", need.key, keySuffix),
                actionType = actionType,
                polarity = polarity,
                weight = math.max(0, tonumber(weight) or 0),
                run = function()
                    local line = RandomChoiceFromTable(lines)
                    if line and line ~= "" then
                        self:PushActivity(string.format(line, pet.name), BENIGN_ACTIVITY_META)
                    end
                end,
            }
        end

        local maxedWeight = (value >= 100) and 2.8 or 0
        local emptyWeight = (value <= 0) and 2.8 or 0
        local positiveWeight = 0
        local neutralWeight = 0
        local negativeWeight = 0

        if value > 65 and value < 100 then
            positiveWeight = 0.55 + ((value - 65) / 35) * 2.10
        end
        if value >= 15 and value <= 85 then
            local dist = math.abs(value - 50)
            neutralWeight = 0.60 + (1 - (dist / 35)) * 1.80
        end
        if value > 0 and value < 35 then
            negativeWeight = 0.55 + ((35 - value) / 35) * 2.10
        end

        addNeedEvent("positive", positiveWeight, "positive", "positive")
        addNeedEvent("neutral", neutralWeight, "positive", "neutral")
        addNeedEvent("negative", negativeWeight, "negative", "negative")
        addNeedEvent("maxed", maxedWeight, "positive", "maxed")
        addNeedEvent("empty", emptyWeight, "negative", "empty")
    end

    local function canonicalPairKey(a, b)
        local first = tostring(a or ""):lower()
        local second = tostring(b or ""):lower()
        if first == "" or second == "" or first == second then
            return nil
        end
        if first < second then
            return first .. "_" .. second
        end
        return second .. "_" .. first
    end

    local primaryKey = tostring(pet.personality or ""):lower()
    local secondaryKey = tostring(pet.personalitySecondary or ""):lower()
    local activePairKey = canonicalPairKey(primaryKey, secondaryKey)

    local activeTraits = {}
    local traitSeen = {}
    for _, traitKey in ipairs({ primaryKey, secondaryKey }) do
        if traitKey ~= "" and not traitSeen[traitKey] then
            traitSeen[traitKey] = true
            activeTraits[#activeTraits + 1] = traitKey
        end
    end

    local traitBenignActions = {
        responsible = {
            { actionType = "work", text = "%s sorted pebbles into tidy little shell-ledgers just to feel organized." },
            { actionType = "clean", text = "%s did a quiet sweep of the habitat edges and nodded at the neat result." },
        },
        lazy = {
            { actionType = "rest", text = "%s found the softest seaweed patch and declared it the official loaf zone." },
            { actionType = "play", text = "%s did one tiny flop-roll, then returned to premium lounging." },
        },
        curious = {
            { actionType = "play", text = "%s inspected a suspicious bubble trail like a tiny tide detective." },
            { actionType = "eat", text = "%s poked around under a shell lip to see what interesting smells were hiding." },
        },
        playful = {
            { actionType = "play", text = "%s bounced between puddles, narrating their own splash championship." },
            { actionType = "clean", text = "%s made a game out of dodging drips and cheering at every close call." },
        },
        brash = {
            { actionType = "play", text = "%s squared up to a wavelet and won by pure attitude." },
            { actionType = "work", text = "%s marched a dramatic patrol lap like the tidepool belonged to them." },
        },
        calm = {
            { actionType = "rest", text = "%s sat quietly and watched the waterline settle into a gentle rhythm." },
            { actionType = "clean", text = "%s took a slow rinse pass and looked perfectly unbothered the whole time." },
        },
        mischievous = {
            { actionType = "play", text = "%s stacked pebbles into a prank pile, then waited for someone to notice." },
            { actionType = "clean", text = "%s traced little loops in the mud and pretended it was important work." },
        },
        loyal = {
            { actionType = "work", text = "%s checked every corner of the habitat and stood proud on guard duty." },
            { actionType = "rest", text = "%s parked nearby, keeping a watchful eye while pretending to nap." },
        },
        greedy = {
            { actionType = "work", text = "%s lined up their shiniest shells and admired the collection with intense focus." },
            { actionType = "eat", text = "%s counted shell clinks in the stash like soothing bedtime music." },
        },
        shy = {
            { actionType = "rest", text = "%s tucked behind the coral and peeked out once the splashing calmed down." },
            { actionType = "clean", text = "%s did a tiny quiet cleanup in the corner where no one was looking." },
        },
        bold = {
            { actionType = "play", text = "%s claimed a rock as a stage and posed like a legend of the shallows." },
            { actionType = "work", text = "%s picked the loudest route on purpose and strutted through it." },
        },
        neat = {
            { actionType = "clean", text = "%s aligned shells by size and gave an approving little mrrgl." },
            { actionType = "rest", text = "%s fluffed their nap spot until every edge was exactly right." },
        },
        gluttonous = {
            { actionType = "eat", text = "%s hovered near the snack shelf, inhaling aromas like fine cuisine." },
            { actionType = "play", text = "%s daydreamed about fancy kelp platters between playful hops." },
        },
        dreamy = {
            { actionType = "rest", text = "%s stared into the shimmer and drifted through a moonlit bubble-thought." },
            { actionType = "play", text = "%s hummed softly and traced circles in the water with distant focus." },
        },
        stubborn = {
            { actionType = "work", text = "%s repeated the same route three times because it was 'the correct route'." },
            { actionType = "clean", text = "%s refused to leave one speck behind and kept polishing with grit." },
        },
        optimistic = {
            { actionType = "play", text = "%s found one bright shell and treated it like proof every tide is lucky." },
            { actionType = "rest", text = "%s took a hopeful little breather and bounced back smiling." },
        },
        resourceful = {
            { actionType = "work", text = "%s improvised a tiny tool from drift bits and looked very proud of it." },
            { actionType = "eat", text = "%s repurposed loose kelp strands into a smart snack bundle." },
        },
    }

    for _, traitKey in ipairs(activeTraits) do
        local traitEntries = traitBenignActions[traitKey]
        local traitPower = tonumber(traits[traitKey]) or 0
        if traitEntries and #traitEntries > 0 then
            for i, entry in ipairs(traitEntries) do
                events[#events + 1] = {
                    key = string.format("benign_trait_%s_%d", traitKey, i),
                    actionType = entry.actionType or "play",
                    polarity = "positive",
                    weight = 0.90 + (traitPower * 1.25) + (needsPressure * 0.05),
                    run = function()
                        self:PushActivity(string.format(entry.text, pet.name), BENIGN_ACTIVITY_META)
                    end,
                }
            end
        end
    end

    local pairTemplateA = {
        "%s let %s and %s take turns steering and somehow made it look smooth.",
        "%s blended %s instincts with %s timing and put on a tidy little show.",
        "%s mixed %s focus and %s vibes into a surprisingly graceful loop.",
    }
    local pairTemplateB = {
        "%s played mediator between %s and %s, then took a smug victory lap.",
        "%s balanced %s impulses with %s patience and kept things mellow.",
        "%s juggled %s and %s like a seasoned tidepool manager.",
    }

    if activePairKey then
        local pairPower = (tonumber(traits[primaryKey]) or 0) + (tonumber(traits[secondaryKey]) or 0)
        local benignPairKeyOne = "benign_pair_" .. activePairKey .. "_1"
        local benignPairKeyTwo = "benign_pair_" .. activePairKey .. "_2"
        local pairLinesOne = AUTONOMOUS_BENIGN_PAIR_OVERRIDES[benignPairKeyOne] or pairTemplateA
        local pairLinesTwo = AUTONOMOUS_BENIGN_PAIR_OVERRIDES[benignPairKeyTwo] or pairTemplateB

        events[#events + 1] = {
            key = benignPairKeyOne,
            actionType = "play",
            polarity = "positive",
            weight = 1.05 + (pairPower * 1.10) + (needsPressure * 0.05),
            run = function()
                local line = RandomChoiceFromTable(pairLinesOne)
                if line and line ~= "" then
                    local rendered = SafeFormatMessage(line, pet.name, GetAutonTraitDialogue(primaryKey), GetAutonTraitDialogue(secondaryKey))
                    self:PushActivity(SanitizeAutonomousTraitLanguage(rendered), BENIGN_ACTIVITY_META)
                end
            end,
        }

        events[#events + 1] = {
            key = benignPairKeyTwo,
            actionType = "rest",
            polarity = "positive",
            weight = 1.00 + (pairPower * 1.05) + (needsPressure * 0.05),
            run = function()
                local line = RandomChoiceFromTable(pairLinesTwo)
                if line and line ~= "" then
                    local rendered = SafeFormatMessage(line, pet.name, GetAutonTraitDialogue(primaryKey), GetAutonTraitDialogue(secondaryKey))
                    self:PushActivity(SanitizeAutonomousTraitLanguage(rendered), BENIGN_ACTIVITY_META)
                end
            end,
        }
    end

    return events
end

function MM:BuildAutonomousBehaviorPools(context)
    local impactful = self:BuildAutonomousBehaviorEvents(context)
    local benign = self:BuildBenignAutonomousBehaviorEvents(context)
    return {
        benign = benign or {},
        impactful = impactful or {},
    }
end

local function SumEventWeights(events)
    local total = 0
    for _, event in ipairs(events or {}) do
        if (event.weight or 0) > 0 then
            total = total + (event.weight or 0)
        end
    end
    return total
end

local function ApplyRepeatPenalty(events, lastEventKey)
    if not events or lastEventKey == nil or lastEventKey == "" then
        return
    end
    for _, event in ipairs(events) do
        if event.key == lastEventKey and (event.weight or 0) > 0 then
            event.weight = event.weight * 0.45
            break
        end
    end
end

function MM:BuildAllAutonomousBehaviorEvents(context)
    local pools = self:BuildAutonomousBehaviorPools(context)
    local benign = pools.benign or {}
    local impactful = pools.impactful or {}
    local benignTotal = SumEventWeights(benign)
    local impactfulTotal = SumEventWeights(impactful)

    local benignShare = AUTON_BUCKET_BENIGN
    local impactfulShare = AUTON_BUCKET_IMPACTFUL
    if benignTotal <= 0 and impactfulTotal > 0 then
        impactfulShare = AUTON_BUCKET_BENIGN + AUTON_BUCKET_IMPACTFUL
        benignShare = 0
    elseif impactfulTotal <= 0 and benignTotal > 0 then
        benignShare = AUTON_BUCKET_BENIGN + AUTON_BUCKET_IMPACTFUL
        impactfulShare = 0
    end

    local all = {}
    for _, event in ipairs(benign) do
        local clone = CopyTable(event)
        clone.bucket = "benign"
        clone.weight = ((event.weight or 0) > 0 and benignTotal > 0)
            and ((event.weight / benignTotal) * benignShare)
            or 0
        all[#all + 1] = clone
    end
    for _, event in ipairs(impactful) do
        local clone = CopyTable(event)
        clone.bucket = "impactful"
        clone.weight = ((event.weight or 0) > 0 and impactfulTotal > 0)
            and ((event.weight / impactfulTotal) * impactfulShare)
            or 0
        all[#all + 1] = clone
    end
    return all
end

local function HumanizeAutoEventKey(key)
    local label = tostring(key or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if label == "" then
        return "Unknown Action"
    end
    label = label:gsub("_", " ")
    label = label:gsub("(%a)([%w_']*)", function(first, rest)
        return string.upper(first) .. string.lower(rest)
    end)
    return label
end

function MM:GetAutonomousBehaviorContext()
    local pet = self:GetPet()
    local store = self:GetStore()
    local economy = self:GetEconomy()
    local traits = self:GetPersonalityWeights()
    local lowJoy = NeedUrgencyBelow(pet.joy, 40)
    local lowEnergy = NeedUrgencyBelow(pet.energy, 35)
    local lowClean = NeedUrgencyBelow(pet.cleanliness, 35)
    local lowHunger = NeedUrgencyBelow(pet.hunger, 35)
    local needsPressure = (NeedDeficit(pet.hunger) + NeedDeficit(pet.joy) + NeedDeficit(pet.energy) + NeedDeficit(pet.cleanliness)) / 4
    local working = economy.work.active == true
    local workStartCooldown = self:GetWorkStartCooldownRemaining()

    local foodCandidates = {}
    for _, item in ipairs(self:GetFoodItems()) do
        local owned = tonumber(store.inventory[item.key]) or 0
        if owned > 0 then
            foodCandidates[#foodCandidates + 1] = item
        end
    end

    return {
        pet = pet,
        store = store,
        economy = economy,
        traits = traits,
        lowJoy = lowJoy,
        lowEnergy = lowEnergy,
        lowClean = lowClean,
        lowHunger = lowHunger,
        needsPressure = needsPressure,
        working = working,
        workStartCooldown = workStartCooldown,
        foodCandidates = foodCandidates,
    }
end

function MM:GetAutonomousBehaviorOptions(applyRepeatPenalty)
    local pet = self:GetPet()
    if pet.stage ~= "adolescent" then
        return {}
    end

    local behavior = pet.behavior or {}
    local pools = self:BuildAutonomousBehaviorPools(self:GetAutonomousBehaviorContext())
    local benign = pools.benign or {}
    local impactful = pools.impactful or {}
    if applyRepeatPenalty and behavior.lastEventKey and behavior.lastEventKey ~= "" then
        ApplyRepeatPenalty(benign, behavior.lastEventKey)
        ApplyRepeatPenalty(impactful, behavior.lastEventKey)
    end

    local benignTotal = SumEventWeights(benign)
    local impactfulTotal = SumEventWeights(impactful)
    local benignShare = AUTON_BUCKET_BENIGN
    local impactfulShare = AUTON_BUCKET_IMPACTFUL
    if benignTotal <= 0 and impactfulTotal > 0 then
        impactfulShare = AUTON_BUCKET_BENIGN + AUTON_BUCKET_IMPACTFUL
        benignShare = 0
    elseif impactfulTotal <= 0 and benignTotal > 0 then
        benignShare = AUTON_BUCKET_BENIGN + AUTON_BUCKET_IMPACTFUL
        impactfulShare = 0
    end

    local events = {}
    for _, event in ipairs(benign) do
        local probability = 0
        if benignTotal > 0 and (event.weight or 0) > 0 and benignShare > 0 then
            probability = (event.weight / benignTotal) * benignShare
        end
        event.probability = probability
        event.label = HumanizeAutoEventKey(event.key)
        events[#events + 1] = event
    end
    for _, event in ipairs(impactful) do
        local probability = 0
        if impactfulTotal > 0 and (event.weight or 0) > 0 and impactfulShare > 0 then
            probability = (event.weight / impactfulTotal) * impactfulShare
        end
        event.probability = probability
        event.label = HumanizeAutoEventKey(event.key)
        events[#events + 1] = event
    end

    table.sort(events, function(a, b)
        return (a.probability or 0) > (b.probability or 0)
    end)
    return events
end

function MM:TriggerAutonomousBehaviorByKey(eventKey)
    local pet = self:GetPet()
    if pet.stage ~= "adolescent" then
        return false, "Auto behavior available after hatching."
    end

    local behavior = pet.behavior or {}
    pet.behavior = behavior
    local events = self:GetAutonomousBehaviorOptions(false)
    local chosen = nil
    for _, event in ipairs(events) do
        if event.key == eventKey then
            chosen = event
            break
        end
    end

    if not chosen then
        return false, "Unknown autonomous behavior."
    end

    if chosen.run then
        chosen.run()
        if self.PlayAutonomousActionSound then
            self:PlayAutonomousActionSound(chosen.polarity)
        end
        self:RecordActionStat("autonomous", 1)
        behavior.lastEventKey = chosen.key
        behavior.lastActionType = tostring(chosen.actionType or "")
        behavior.lastPolarity = tostring(chosen.polarity or "")
        behavior.nextDecisionTick = (pet.ageMinutes or 0) + math.random(1, 3)
        self:RefreshUI()
        return true, chosen.key
    end

    return false, "Selected autonomous behavior is not runnable."
end

function MM:TryAutonomousBehavior()
    local pet = self:GetPet()
    if pet.stage ~= "adolescent" then
        return
    end

    local behavior = pet.behavior or {}
    pet.behavior = behavior

    if (pet.ageMinutes or 0) < (behavior.nextDecisionTick or 0) then
        return
    end

    local categoryRoll = math.random()
    if categoryRoll <= AUTON_BUCKET_NO_ACTION then
        behavior.nextDecisionTick = (pet.ageMinutes or 0) + math.random(1, 2)
        return
    end

    local context = self:GetAutonomousBehaviorContext()
    local pools = self:BuildAutonomousBehaviorPools(context)
    local benign = pools.benign or {}
    local impactful = pools.impactful or {}
    local desiredPool = (categoryRoll <= (AUTON_BUCKET_NO_ACTION + AUTON_BUCKET_BENIGN)) and benign or impactful
    local fallbackPool = (desiredPool == benign) and impactful or benign

    if behavior.lastEventKey and behavior.lastEventKey ~= "" then
        ApplyRepeatPenalty(desiredPool, behavior.lastEventKey)
        ApplyRepeatPenalty(fallbackPool, behavior.lastEventKey)
    end

    local chosen = WeightedRandomPick(desiredPool)
    if not chosen then
        chosen = WeightedRandomPick(fallbackPool)
    end

    if chosen and chosen.run then
        chosen.run()
        if self.PlayAutonomousActionSound then
            self:PlayAutonomousActionSound(chosen.polarity)
        end
        self:RecordActionStat("autonomous", 1)
        behavior.lastEventKey = chosen.key
        behavior.lastActionType = tostring(chosen.actionType or "")
        behavior.lastPolarity = tostring(chosen.polarity or "")
    end

    behavior.nextDecisionTick = (pet.ageMinutes or 0) + math.random(1, 3)
end

function MM:GetAutonomousBehaviorDebugText()
    local pet = self:GetPet()
    if pet.stage ~= "adolescent" then
        return "Behavior debug available after hatching."
    end

    local context = self:GetAutonomousBehaviorContext()
    local working = context.working
    local lowHunger = context.lowHunger
    local lowJoy = context.lowJoy
    local lowEnergy = context.lowEnergy
    local lowClean = context.lowClean
    local weights = self:BuildAllAutonomousBehaviorEvents(context)

    table.sort(weights, function(a, b)
        return (a.weight or 0) > (b.weight or 0)
    end)

    local behavior = pet.behavior or {}
    local noActionPct = math.floor((AUTON_BUCKET_NO_ACTION * 100) + 0.5)
    local benignPct = math.floor((AUTON_BUCKET_BENIGN * 100) + 0.5)
    local impactfulPct = math.floor((AUTON_BUCKET_IMPACTFUL * 100) + 0.5)
    local lines = {
        string.format("Traits: %s | Scavenging: %s", self:GetTraitDisplayText(pet), working and "yes" or "no"),
        string.format("Category Roll: %d%% no action / %d%% benign / %d%% effect-driven | Next Check Minute: %d",
            noActionPct, benignPct, impactfulPct, tonumber(behavior.nextDecisionTick) or 0),
        string.format("Need Urgency H/J/E/C: %.2f / %.2f / %.2f / %.2f", lowHunger, lowJoy, lowEnergy, lowClean),
        "Top Event Weights:",
    }

    for i = 1, math.min(6, #weights) do
        local entry = weights[i]
        lines[#lines + 1] = string.format("- %s: %.2f", entry.key, entry.weight or 0)
    end

    return table.concat(lines, "\n")
end

function MM:CareForEgg(action)
    local pet = self:GetPet()
    if pet.stage ~= "egg" then
        return false, "This action is for eggs only."
    end

    pet.eggCare = pet.eggCare or {
        careProgress = 0,
        lastWarmAt = 0,
        lastCleanAt = 0,
        lastSootheAt = 0,
    }

    local actionMap = {
        warm = { key = "lastWarmAt", hatch = 1.0, text = "You warmed the egg near a tidepool lantern.", challengeType = "warm" },
        clean = { key = "lastCleanAt", hatch = 1.0, text = "You cleaned and polished the egg shell.", challengeType = "clean" },
        soothe = { key = "lastSootheAt", hatch = 1.0, text = "You hummed to the egg and kept watch.", challengeType = "soothe" },
    }

    local data = actionMap[action]
    if not data then
        return false, "Unknown egg care action."
    end

    local now = time() or 0
    local lastAt = tonumber(pet.eggCare[data.key]) or 0
    if lastAt > (now + EGG_CARE_COOLDOWN) then
        lastAt = 0
        pet.eggCare[data.key] = 0
    end
    if lastAt > 0 and (now - lastAt) < EGG_CARE_COOLDOWN then
        local remaining = math.ceil(EGG_CARE_COOLDOWN - (now - lastAt))
        return false, string.format("That egg care action is resting. Try again in %ds.", remaining)
    end
    local currentCareProgress = tonumber(pet.eggCare.careProgress) or 0
    local allowed = tonumber(data.hatch) or 0
    pet.eggCare.careProgress = currentCareProgress + allowed
    pet.eggCare[data.key] = now

    if allowed > 0 then
        self:IncreaseHatchProgress(allowed)
        self:PushActivity(data.text)
    end
    self:RecordActionStat("eggCare", 1)
    self:AdvanceChallenges(data.challengeType, 1, { zone = GetRealZoneText() }, true)
    self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
    self:RefreshUI()
    return true, "Egg cared for."
end

function MM:GetEggCareCooldown(action)
    local pet = self:GetPet()
    local keyByAction = {
        warm = "lastWarmAt",
        clean = "lastCleanAt",
        soothe = "lastSootheAt",
    }
    local key = keyByAction[action]
    if not key then
        return 0, EGG_CARE_COOLDOWN
    end

    pet.eggCare = pet.eggCare or {}
    local lastAt = tonumber(pet.eggCare[key]) or 0
    if lastAt <= 0 then
        return 0, EGG_CARE_COOLDOWN
    end
    local now = time() or 0
    if lastAt > (now + EGG_CARE_COOLDOWN) then
        pet.eggCare[key] = 0
        return 0, EGG_CARE_COOLDOWN
    end
    local remaining = math.max(0, (lastAt + EGG_CARE_COOLDOWN) - now)
    return remaining, EGG_CARE_COOLDOWN
end

function MM:GetFoodItems()
    return FOOD_ITEMS
end

function MM:GetPet()
    return self.db.profile.pet
end

function MM:GetStore()
    return self.db.profile.store
end

function MM:ApplyActivityPetTitleToMessage(message)
    local text = TrimString(message or "")
    if text == "" then
        return text
    end

    local pet = self:GetPet()
    if not pet or tostring(pet.stage or "") == "egg" then
        return text
    end

    local baseName = TrimString(pet.name or "")
    if baseName == "" then
        return text
    end

    local titledName = TrimString(self:GetTitledPetName(baseName) or "")
    if titledName == "" or titledName == baseName then
        return text
    end
    if text:find(titledName, 1, true) then
        return text
    end
    if not text:find(baseName, 1, true) then
        return text
    end

    local pattern = baseName:gsub("(%W)", "%%%1")
    return (text:gsub(pattern, titledName))
end

local MAJOR_CITY_ZONES = {
    ["Stormwind City"] = true,
    ["Orgrimmar"] = true,
    ["Ironforge"] = true,
    ["Darnassus"] = true,
    ["Undercity"] = true,
    ["Thunder Bluff"] = true,
    ["Silvermoon City"] = true,
    ["Exodar"] = true,
    ["Shattrath City"] = true,
    ["Dalaran"] = true,
    ["Boralus"] = true,
    ["Dazar'alor"] = true,
    ["Valdrakken"] = true,
    ["Dornogal"] = true,
}

function MM:IsMajorCityZone(zone)
    return MAJOR_CITY_ZONES[tostring(zone or "")] == true
end

local function NormalizeInstanceState(inInstance, instanceType)
    local rawInInstance = inInstance
    local rawInstanceType = instanceType

    if type(rawInInstance) == "string" and type(rawInstanceType) == "boolean" then
        rawInInstance, rawInstanceType = rawInstanceType, rawInInstance
    elseif type(rawInInstance) == "string" and rawInstanceType == nil then
        rawInInstance, rawInstanceType = nil, rawInInstance
    end

    local normalizedType = TrimString(tostring(rawInstanceType or ""))
    local isInstance = rawInInstance == true
    if not isInstance and normalizedType ~= "" then
        local lowered = string.lower(normalizedType)
        if lowered ~= "none" and lowered ~= "world" and lowered ~= "unknown" then
            isInstance = true
        end
    end

    return isInstance, normalizedType
end

function MM:GetInstanceState()
    if C_InstanceInfo and C_InstanceInfo.IsInInstance then
        local ok, inInstance, instanceType = pcall(C_InstanceInfo.IsInInstance)
        if ok then
            return NormalizeInstanceState(inInstance, instanceType)
        end
    end
    if IsInInstance then
        local ok, inInstance, instanceType = pcall(IsInInstance)
        if ok then
            return NormalizeInstanceState(inInstance, instanceType)
        end
    end
    return false, ""
end

function MM:IsNeedFreezeInstanceContent()
    local inInstance, instanceType = self:GetInstanceState()
    if not inInstance then
        return false, ""
    end
    return true, instanceType
end

function MM:UpdateScavengePauseForSafeZone()
    local pet = self:GetPet()
    local economy = self:GetEconomy()
    local work = economy.work or {}
    local zone = GetRealZoneText()
    local resting = ((IsResting and IsResting()) or false)
    local inMajorCity = self:IsMajorCityZone(zone)
    local frozenInInstance = self:IsNeedFreezeInstanceContent()
    local blocked = resting or inMajorCity or frozenInInstance
    local pauseReason = frozenInInstance and "instanced content" or "a safe zone"

    if blocked then
        if work.active then
            work.active = false
            work.pausedForSafeZone = true
            do
                local stats = self:GetInteractionStats()
                stats.scavenge.autoPauses = (tonumber(stats.scavenge.autoPauses) or 0) + 1
            end
            self:PushActivity(string.format("%s paused scavenging while you are in %s.", pet.name, pauseReason))
            return true
        end
        return false
    end

    if work.pausedForSafeZone and pet.stage ~= "egg" then
        work.pausedForSafeZone = false
        work.active = true
        do
            local stats = self:GetInteractionStats()
            stats.scavenge.autoResumes = (tonumber(stats.scavenge.autoResumes) or 0) + 1
        end
        self:PushActivity(string.format("%s resumed scavenging after leaving the safe zone or instance.", pet.name))
        return true
    end

    work.pausedForSafeZone = false
    return false
end

function MM:TryAutoCompleteMajorCityChallenge(suppressRefresh)
    local zone = GetRealZoneText()
    if not self:IsMajorCityZone(zone) then
        return false
    end

    local economy = self:GetEconomy()
    local active = economy and economy.challenges and economy.challenges.active
    if type(active) ~= "table" or #active == 0 then
        return false
    end

    local hasPending = false
    for _, challenge in ipairs(active) do
        if challenge
            and challenge.type == "visit_major_city"
            and (tonumber(challenge.progress) or 0) < (tonumber(challenge.goal) or 1)
        then
            hasPending = true
            break
        end
    end

    if not hasPending then
        return false
    end

    self:AdvanceChallenges("visit_major_city", 1, { zone = zone }, suppressRefresh == true)
    return true
end

function MM:TryAutoCompleteVisitInnChallenge(suppressRefresh)
    local resting = (IsResting and IsResting()) or false
    if not resting then
        return false
    end

    local economy = self:GetEconomy()
    local active = economy and economy.challenges and economy.challenges.active
    if type(active) ~= "table" or #active == 0 then
        return false
    end

    local hasPending = false
    for _, challenge in ipairs(active) do
        if challenge
            and challenge.type == "visit_inn"
            and (tonumber(challenge.progress) or 0) < (tonumber(challenge.goal) or 1)
        then
            hasPending = true
            break
        end
    end

    if not hasPending then
        return false
    end

    self:AdvanceChallenges("visit_inn", 1, { zone = GetRealZoneText() }, suppressRefresh == true)
    return true
end

function MM:RegisterEconomyEvents()
    if self.economyEventFrame then
        return
    end

    local frame = CreateFrame("Frame")
    frame:RegisterEvent("QUEST_TURNED_IN")
    frame:RegisterEvent("LOOT_OPENED")
    frame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
    frame:RegisterEvent("CHAT_MSG_TEXT_EMOTE")
    frame:RegisterEvent("CHAT_MSG_EMOTE")
    frame:RegisterEvent("PLAYER_UPDATE_RESTING")
    frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    frame:RegisterEvent("ZONE_CHANGED")
    frame:RegisterEvent("ZONE_CHANGED_INDOORS")
    frame:RegisterEvent("LFG_COMPLETION_REWARD")
    frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    frame:RegisterEvent("ENCOUNTER_END")
    frame:RegisterEvent("PLAYER_MOUNT_DISPLAY_CHANGED")
    frame:RegisterEvent("COMPANION_UPDATE")
    frame:RegisterEvent("UNIT_AURA")
    frame:RegisterEvent("PLAYER_ENTERING_WORLD")
    frame:RegisterEvent("PLAYER_REGEN_DISABLED")
    frame:RegisterEvent("PLAYER_REGEN_ENABLED")
    frame:RegisterEvent("CHAT_MSG_LOOT")
    frame:RegisterEvent("TOYS_UPDATED")

    self.playerAuraSnapshot = self.playerAuraSnapshot or {}
    self._mmWasResting = (IsResting and IsResting()) or false
    self._mmLastMajorCityZone = nil
    self._mmPauseAuraScan = (InCombatLockdown and InCombatLockdown()) or false
    self._mmRecentFishingCastAt = 0
    self._mmDungeonCompletionState = self._mmDungeonCompletionState or {
        lastAwardAt = 0,
        recentByKey = {},
    }
    RefreshToySpellLookup(self)

    local function hasPendingDungeonChallenge()
        local economy = self:GetEconomy()
        local active = economy and economy.challenges and economy.challenges.active
        if type(active) ~= "table" then
            return false
        end
        for _, challenge in ipairs(active) do
            if type(challenge) == "table"
                and challenge.type == "dungeon_complete"
                and (tonumber(challenge.progress) or 0) < math.max(1, tonumber(challenge.goal) or 1)
            then
                return true
            end
        end
        return false
    end

    local function SafeLowerChatText(value)
        if type(value) ~= "string" then
            return ""
        end
        local ok, lowered = pcall(string.lower, value)
        if ok and type(lowered) == "string" then
            return lowered
        end
        if type(securecallfunction) == "function" then
            ok, lowered = pcall(securecallfunction, string.lower, value)
            if ok and type(lowered) == "string" then
                return lowered
            end
        end
        return ""
    end

    local function SenderMatchesPlayer(senderName, playerName)
        if type(senderName) ~= "string" or type(playerName) ~= "string" or playerName == "" then
            return false
        end
        local ok, found = pcall(string.find, senderName, playerName, 1, true)
        if ok then
            return found ~= nil
        end
        if type(securecallfunction) == "function" then
            ok, found = pcall(securecallfunction, string.find, senderName, playerName, 1, true)
            if ok then
                return found ~= nil
            end
        end
        return false
    end

    local function safeIsInInstance()
        return self:GetInstanceState()
    end

    local function handleRestingStateChange()
        local resting = (IsResting and IsResting()) or false
        local enteredResting = resting and not self._mmWasResting
        local leftResting = (not resting) and self._mmWasResting

        if enteredResting then
            self:AdvanceChallenges("visit_inn", 1, { zone = GetRealZoneText() }, true)
            local pet = self:GetPet()
            local petName = TrimString((pet and pet.name) or "")
            if petName == "" then
                petName = "your murloc"
            end
            self:PushActivity(string.format("%s curled up in cozy tidepool calm. Need drains are halved here, and scavenging pauses in safe zones.", petName))
        elseif leftResting then
            local pet = self:GetPet()
            local petName = TrimString((pet and pet.name) or "")
            if petName == "" then
                petName = "your murloc"
            end
            self:PushActivity(string.format("%s perked up as you left the rested zone. Need drains are back to normal, and scavenging can resume outside safe zones.", petName))
        end

        self._mmWasResting = resting
        self:UpdateScavengePauseForSafeZone()
        return enteredResting, resting
    end

    local function safeGetInstanceInfo()
        local fetcher = nil
        if C_InstanceInfo and C_InstanceInfo.GetInstanceInfo then
            fetcher = C_InstanceInfo.GetInstanceInfo
        elseif GetInstanceInfo then
            fetcher = GetInstanceInfo
        end
        if not fetcher then
            return "", "", 0, "", 0, 0, false, 0, 0, 0
        end
        local ok, name, instanceType, difficultyID, difficultyName, maxPlayers, dynamicDifficulty, isDynamic, instanceID, instanceGroupSize, lfgDungeonID = pcall(fetcher)
        if not ok then
            return "", "", 0, "", 0, 0, false, 0, 0, 0
        end
        return
            tostring(name or ""),
            tostring(instanceType or ""),
            tonumber(difficultyID) or 0,
            tostring(difficultyName or ""),
            tonumber(maxPlayers) or 0,
            tonumber(dynamicDifficulty) or 0,
            isDynamic == true,
            tonumber(instanceID) or 0,
            tonumber(instanceGroupSize) or 0,
            tonumber(lfgDungeonID) or 0
    end

    local function safeGetInstanceLockProgress()
        local fetcher = nil
        if C_InstanceInfo and C_InstanceInfo.GetInstanceLockTimeRemaining then
            fetcher = C_InstanceInfo.GetInstanceLockTimeRemaining
        elseif GetInstanceLockTimeRemaining then
            fetcher = GetInstanceLockTimeRemaining
        end
        if not fetcher then
            return 0, 0
        end
        local ok, _, _, total, completed = pcall(fetcher)
        if not ok then
            return 0, 0
        end
        return tonumber(total) or 0, tonumber(completed) or 0
    end

    local function buildDungeonRunKey(sourceTag)
        local inInstance, instanceType = safeIsInInstance()
        local name, infoType, difficultyID, _, maxPlayers, _, _, instanceID, groupSize, lfgDungeonID = safeGetInstanceInfo()
        local resolvedType = instanceType ~= "" and instanceType or infoType
        if (sourceTag ~= "lfg" and sourceTag ~= "challenge_mode") and (not inInstance or resolvedType ~= "party") then
            return nil
        end
        local trimmedName = TrimString(name)
        if trimmedName == "" and instanceID <= 0 and lfgDungeonID <= 0 then
            return nil
        end
        return string.format("%d:%d:%d:%d:%s", instanceID, difficultyID, lfgDungeonID, groupSize > 0 and groupSize or maxPlayers, trimmedName)
    end

    local function awardDungeonCompletion(reason, dedupeKey)
        if not hasPendingDungeonChallenge() then
            return false
        end
        local state = self._mmDungeonCompletionState or {}
        self._mmDungeonCompletionState = state
        state.recentByKey = state.recentByKey or {}
        local now = GetTime() or 0

        for key, awardedAt in pairs(state.recentByKey) do
            if (now - (tonumber(awardedAt) or 0)) > 300 then
                state.recentByKey[key] = nil
            end
        end

        if (now - (tonumber(state.lastAwardAt) or 0)) < 2 then
            return false
        end

        if dedupeKey then
            local previous = tonumber(state.recentByKey[dedupeKey]) or 0
            if previous > 0 and (now - previous) < 300 then
                return false
            end
            state.recentByKey[dedupeKey] = now
        end

        state.lastAwardAt = now
        state.lastReason = tostring(reason or "")
        self:AdvanceChallenges("dungeon_complete", 1, { zone = GetRealZoneText() }, true)
        self:RefreshUI()
        return true
    end

    local function tryAwardDungeonFromEncounterEnd(encounterID, encounterName, difficultyID, groupSize, success)
        if tonumber(success) ~= 1 then
            return
        end
        if not hasPendingDungeonChallenge() then
            return
        end

        local inInstance, instanceType = safeIsInInstance()
        if not inInstance or instanceType ~= "party" then
            return
        end

        local function checkCompletionAfterLockUpdate()
            if not hasPendingDungeonChallenge() then
                return
            end
            local stillInInstance, stillType = safeIsInInstance()
            if not stillInInstance or stillType ~= "party" then
                return
            end
            local encountersTotal, encountersCompleted = safeGetInstanceLockProgress()
            if encountersTotal > 0 and encountersCompleted >= encountersTotal then
                local key = buildDungeonRunKey("encounter_end")
                awardDungeonCompletion("encounter_end", key)
            end
        end

        if C_Timer and C_Timer.After then
            C_Timer.After(0.35, checkCompletionAfterLockUpdate)
            C_Timer.After(1.0, checkCompletionAfterLockUpdate)
        else
            checkCompletionAfterLockUpdate()
        end
    end

    local function tryAwardDungeonFromLFG()
        local key = buildDungeonRunKey("lfg")
        if not key and GetLFGCompletionReward then
            local ok, name = pcall(GetLFGCompletionReward)
            if ok and name then
                key = string.format("lfg:%s", TrimString(tostring(name)))
            end
        end
        awardDungeonCompletion("lfg_completion_reward", key)
    end

    local function tryAwardDungeonFromChallengeMode()
        local key = nil
        if C_ChallengeMode and C_ChallengeMode.GetChallengeCompletionInfo then
            local ok, info = pcall(C_ChallengeMode.GetChallengeCompletionInfo)
            if ok and type(info) == "table" and info.practiceRun ~= true then
                key = string.format("challenge_mode:%d:%d:%d",
                    tonumber(info.mapChallengeModeID) or 0,
                    tonumber(info.level) or 0,
                    tonumber(info.time) or 0
                )
            end
        end
        if not key and C_ChallengeMode and C_ChallengeMode.GetActiveChallengeMapID then
            local ok, mapID = pcall(C_ChallengeMode.GetActiveChallengeMapID)
            if ok and tonumber(mapID) and tonumber(mapID) > 0 then
                key = string.format("challenge_mode:%d", tonumber(mapID) or 0)
            end
        end
        if key then
            awardDungeonCompletion("challenge_mode_completed", key)
        end
    end

    local function updatePlayerAuras(awardProgress)
        local seen = {}
        for i = 1, 80 do
            local name, _, hasAura = GetHelpfulAuraNameAndSpellID("player", i)
            if not hasAura then
                break
            end

            local slotKey = "slot:" .. i
            seen[slotKey] = true

            if awardProgress and not self.playerAuraSnapshot[slotKey] then
                local shouldAwardEatFood = false
                local ok = pcall(function()
                    if NameHasKeyword(name, { "well fed", "feast", "refreshment", "food" }) then
                        shouldAwardEatFood = true
                    end
                end)
                if ok and shouldAwardEatFood then
                    self:AdvanceChallenges("eat_food", 1, { zone = GetRealZoneText() }, true)
                end
            end
        end
        self.playerAuraSnapshot = seen
    end

    frame:SetScript("OnEvent", function(_, event, ...)
        if event == "QUEST_TURNED_IN" then
            self:AdvanceChallenges("quest_turnin", 1, { zone = GetRealZoneText() })
        elseif event == "LOOT_OPENED" then
            self:AdvanceChallenges("loot_any", 1, { zone = GetRealZoneText() }, true)
            local fishingByLootAPI = (IsFishingLoot and IsFishingLoot()) or false
            local recentFishingCast = ((GetTime() or 0) - (tonumber(self._mmRecentFishingCastAt) or 0)) <= 25
            if fishingByLootAPI or recentFishingCast then
                self:AdvanceChallenges("fish_zone", 1, { zone = GetRealZoneText() }, true)
            end
            self:RefreshUI()
        elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
            local unit, _, spellID = ...
            if unit == "player" and spellID then
                local spellName
                if C_Spell and C_Spell.GetSpellInfo then
                    local spellInfo = C_Spell.GetSpellInfo(spellID)
                    if type(spellInfo) == "table" then
                        spellName = spellInfo.name
                    else
                        spellName = spellInfo
                    end
                elseif GetSpellInfo then
                    spellName = GetSpellInfo(spellID)
                end
                if NameHasKeyword(spellName, { "potion", "phial", "elixir", "draught", "tonic" }) then
                    self:AdvanceChallenges("use_potion", 1, { zone = GetRealZoneText() })
                end
                if NameHasKeyword(spellName, { "drink", "ale", "tea", "coffee", "brew", "waterskin" }) then
                    self:AdvanceChallenges("drink_any", 1, { zone = GetRealZoneText() }, true)
                end
                if NameHasKeyword(spellName, { "campfire", "fire" }) then
                    self:AdvanceChallenges("create_fire", 1, { zone = GetRealZoneText() }, true)
                end
                if NameHasKeyword(spellName, { "summon" }) then
                    self:AdvanceChallenges("summon_companion", 1, { zone = GetRealZoneText() }, true)
                end
                if NameHasKeyword(spellName, { "fishing", "cast line" }) then
                    self._mmRecentFishingCastAt = GetTime() or 0
                end
                if IsToySpellCast(self, spellID, spellName) then
                    self:AdvanceChallenges("use_toy", 1, { zone = GetRealZoneText() })
                end
            end
        elseif event == "CHAT_MSG_LOOT" then
            local messageText = ...
            if type(messageText) == "string" then
                local lower = SafeLowerChatText(messageText)
                if string.find(lower, "fish", 1, true) then
                    self:AdvanceChallenges("fish_zone", 1, { zone = GetRealZoneText() }, true)
                    self:RefreshUI()
                end
            end
        elseif event == "CHAT_MSG_TEXT_EMOTE" or event == "CHAT_MSG_EMOTE" then
            local messageText, sender = ...
            local playerName = UnitName("player")
            if SenderMatchesPlayer(sender, playerName) then
                self:AdvanceChallenges("emote", 1, { zone = GetRealZoneText() })
                local lowerMessage = SafeLowerChatText(messageText)
                local resting = (IsResting and IsResting()) or false
                for challengeType, rule in pairs(CHALLENGE_EMOTE_MATCH_RULES) do
                    if (not rule.requiresResting or resting) and MessageContainsKeyword(lowerMessage, rule.keywords) then
                        self:AdvanceChallenges(challengeType, 1, { zone = GetRealZoneText() }, true)
                    end
                end
            end
        elseif event == "PLAYER_UPDATE_RESTING" then
            handleRestingStateChange()
            self:RefreshUI()
        elseif event == "ZONE_CHANGED_NEW_AREA" or event == "ZONE_CHANGED" or event == "ZONE_CHANGED_INDOORS" then
            local zone = GetRealZoneText()
            if self:IsMajorCityZone(zone) and zone ~= self._mmLastMajorCityZone then
                self:AdvanceChallenges("visit_major_city", 1, { zone = zone }, true)
                self._mmLastMajorCityZone = zone
            elseif not self:IsMajorCityZone(zone) then
                self._mmLastMajorCityZone = nil
            end
            local enteredResting, resting = handleRestingStateChange()
            if resting and not enteredResting then
                self:TryAutoCompleteVisitInnChallenge(true)
            end
            self:RefreshUI()
        elseif event == "LFG_COMPLETION_REWARD" then
            tryAwardDungeonFromLFG()
        elseif event == "CHALLENGE_MODE_COMPLETED" then
            tryAwardDungeonFromChallengeMode()
        elseif event == "ENCOUNTER_END" then
            tryAwardDungeonFromEncounterEnd(...)
        elseif event == "PLAYER_MOUNT_DISPLAY_CHANGED" then
            if IsMounted and IsMounted() then
                self:AdvanceChallenges("summon_companion", 1, { zone = GetRealZoneText() }, true)
            end
            self:RefreshUI()
        elseif event == "COMPANION_UPDATE" then
            self:AdvanceChallenges("summon_companion", 1, { zone = GetRealZoneText() }, true)
            self:RefreshUI()
        elseif event == "PLAYER_REGEN_DISABLED" then
            self._mmPauseAuraScan = true
        elseif event == "PLAYER_REGEN_ENABLED" then
            self._mmPauseAuraScan = false
            updatePlayerAuras(true)
            self:RefreshUI()
        elseif event == "UNIT_AURA" then
            local unit = ...
            if unit == "player" and not self._mmPauseAuraScan then
                updatePlayerAuras(true)
                self:RefreshUI()
            end
        elseif event == "PLAYER_ENTERING_WORLD" then
            self._mmPauseAuraScan = (InCombatLockdown and InCombatLockdown()) or false
            updatePlayerAuras(false)
            RefreshToySpellLookup(self)
            self._mmWasResting = (IsResting and IsResting()) or false
            local zone = GetRealZoneText()
            if self:IsMajorCityZone(zone) then
                self._mmLastMajorCityZone = zone
            else
                self._mmLastMajorCityZone = nil
            end
            self:TryAutoCompleteMajorCityChallenge(true)
            self:TryAutoCompleteVisitInnChallenge(true)
            self:UpdateScavengePauseForSafeZone()
            self:RefreshUI()
        elseif event == "TOYS_UPDATED" then
            RefreshToySpellLookup(self)
        end
    end)

    self.economyEventFrame = frame
end

function MM:StartNeedsDecay()
    if self.decayTimer then
        self:CancelTimer(self.decayTimer)
    end
    if self.swimTimer then
        self:CancelTimer(self.swimTimer)
    end

    self:RegisterEconomyEvents()
    self.decayTimer = self:ScheduleRepeatingTimer("OnNeedsDecayTick", NEEDS_DECAY_TICK_SECONDS)
    self.swimTimer = self:ScheduleRepeatingTimer("OnSwimCheckTick", 1)
end

function MM:OnSwimCheckTick()
    self:UpdateSwimmingChallengeProgress(false)
end

function MM:UpdateSwimmingChallengeProgress(fromDecayTick)
    local state = self._mmSwimState
    if type(state) ~= "table" then
        state = {
            wasSwimming = false,
            startedAt = 0,
            lastAwardedSecond = 0,
        }
        self._mmSwimState = state
    end

    local swimming = (IsSwimming and IsSwimming()) or false
    local now = GetTime() or 0
    local didProgress = false

    if swimming and not state.wasSwimming then
        state.wasSwimming = true
        state.startedAt = now
        state.lastAwardedSecond = 0
        self:AdvanceChallenges("swim", 1, { zone = GetRealZoneText() }, true)
        didProgress = true
    elseif swimming and state.wasSwimming then
        local elapsedSeconds = math.max(0, math.floor(now - (tonumber(state.startedAt) or now)))
        local lastAwarded = math.max(0, math.floor(tonumber(state.lastAwardedSecond) or 0))
        local deltaSeconds = elapsedSeconds - lastAwarded
        if deltaSeconds > 0 then
            self:AdvanceChallenges("swim_short", deltaSeconds, { zone = GetRealZoneText() }, true)
            self:AdvanceChallenges("swim_long", deltaSeconds, { zone = GetRealZoneText() }, true)
            state.lastAwardedSecond = elapsedSeconds
            didProgress = true
        end
    elseif (not swimming) and state.wasSwimming then
        local elapsedSeconds = math.max(0, math.floor(now - (tonumber(state.startedAt) or now)))
        local lastAwarded = math.max(0, math.floor(tonumber(state.lastAwardedSecond) or 0))
        local deltaSeconds = elapsedSeconds - lastAwarded
        if deltaSeconds > 0 then
            self:AdvanceChallenges("swim_short", deltaSeconds, { zone = GetRealZoneText() }, true)
            self:AdvanceChallenges("swim_long", deltaSeconds, { zone = GetRealZoneText() }, true)
            didProgress = true
        end
        state.wasSwimming = false
        state.startedAt = 0
        state.lastAwardedSecond = 0
    end

    if didProgress and not fromDecayTick then
        self:RefreshUI()
    end
end

function MM:OnNeedsDecayTick()
    local pet = self:GetPet()
    local economy = self:GetEconomy()
    local traits = self:GetPersonalityWeights()
    local frozenInInstance = self:IsNeedFreezeInstanceContent()

    if (not frozenInInstance) and self.UpdateAchievementStreaksOnTick then
        self:UpdateAchievementStreaksOnTick()
    end

    if not frozenInInstance then
        pet.ageMinutes = (tonumber(pet.ageMinutes) or 0) + 1
        do
            local stats = self:GetInteractionStats()
            stats.petAgeMinutesTotal = (tonumber(stats.petAgeMinutesTotal) or 0) + 1
        end
    end
    self:UpdateScavengePauseForSafeZone()

    if pet.stage == "egg" then
        if economy.work.active then
            self:StopWorkShift("Scavenging stopped: eggs need tending, not shifts.")
        end
        self:IncreaseHatchProgress(EGG_NATURAL_HATCH_PER_TICK)
        if self.EvaluateAchievements then
            self:EvaluateAchievements({ source = "decay_tick_egg" })
        end
        self:RefreshUI()
        return
    end

    if frozenInInstance then
        self:UpdateSwimmingChallengeProgress(true)
        if self.EvaluateAchievements then
            self:EvaluateAchievements({ source = "decay_tick_frozen" })
        end
        self:RefreshUI()
        return
    end

    local restedNeedDrainScale = ((IsResting and IsResting()) and 0.5) or 1.0
    local joyDrainFlatOffset = self:HasStoreUpgrade("coralFountain") and -(BASE_NEED_DECAY_PER_TICK.joy * 0.10) or 0
    local cleanDrainFlatOffset = self:HasStoreUpgrade("tidepoolFilter") and -(BASE_NEED_DECAY_PER_TICK.cleanliness * 0.12) or 0
    local hungerTraitFlat = GetTraitNeedDrainFlatDelta(traits, "hunger")
    local joyTraitFlat = GetTraitNeedDrainFlatDelta(traits, "joy")
    local energyTraitFlat = GetTraitNeedDrainFlatDelta(traits, "energy")
    local cleanTraitFlat = GetTraitNeedDrainFlatDelta(traits, "cleanliness")

    local hungerBaseWithTrait = math.max(MIN_BASE_NEED_DRAIN_PER_TICK, BASE_NEED_DECAY_PER_TICK.hunger + hungerTraitFlat)
    local joyBase = math.max(0, BASE_NEED_DECAY_PER_TICK.joy + joyDrainFlatOffset)
    local joyBaseWithTrait = math.max(MIN_BASE_NEED_DRAIN_PER_TICK, joyBase + joyTraitFlat)
    local energyBaseWithTrait = math.max(MIN_BASE_NEED_DRAIN_PER_TICK, BASE_NEED_DECAY_PER_TICK.energy + energyTraitFlat)
    local cleanBase = math.max(0, BASE_NEED_DECAY_PER_TICK.cleanliness + cleanDrainFlatOffset)
    local cleanBaseWithTrait = math.max(MIN_BASE_NEED_DRAIN_PER_TICK, cleanBase + cleanTraitFlat)

    local hungerDrain = hungerBaseWithTrait * GetNeedDrainMultiplier(pet.hunger) * restedNeedDrainScale
    local joyDrain = joyBaseWithTrait * GetNeedDrainMultiplier(pet.joy) * restedNeedDrainScale
    local energyDrain = energyBaseWithTrait * GetNeedDrainMultiplier(pet.energy) * restedNeedDrainScale
    local cleanDrain = cleanBaseWithTrait * GetNeedDrainMultiplier(pet.cleanliness) * restedNeedDrainScale

    pet.hunger = ClampNeedValue(self, pet.hunger - hungerDrain)
    pet.joy = ClampNeedValue(self, pet.joy - joyDrain)
    pet.energy = ClampNeedValue(self, pet.energy - energyDrain)
    pet.cleanliness = ClampNeedValue(self, pet.cleanliness - cleanDrain)

    if economy.work.active then
        economy.work.totalTicks = economy.work.totalTicks + 1
        economy.work.currentShiftTicks = (tonumber(economy.work.currentShiftTicks) or 0) + 1
        self:AdvanceChallenges("work_ticks", 1, { zone = GetRealZoneText() }, true)

        local skipped = tonumber(economy.work.pendingSkippedPayoutTicks) or 0
        if skipped > 0 then
            economy.work.pendingSkippedPayoutTicks = skipped - 1
        else
            local payout = self:GetCurrentScavengeTickPayout()
            self:AddShells(payout)
            economy.work.totalEarned = economy.work.totalEarned + payout
            self:AdvanceChallenges("earn_shells", payout, { zone = GetRealZoneText() }, true)
        end

        local scavengeHungerExtra = hungerDrain * SCAVENGE_DRAIN_BONUS_MULT
        local scavengeJoyExtra = joyDrain * SCAVENGE_DRAIN_BONUS_MULT
        local scavengeEnergyExtra = energyDrain * SCAVENGE_DRAIN_BONUS_MULT
        local scavengeCleanExtra = cleanDrain * SCAVENGE_DRAIN_BONUS_MULT

        pet.hunger = ClampNeedValue(self, pet.hunger - scavengeHungerExtra)
        pet.joy = ClampNeedValue(self, pet.joy - scavengeJoyExtra)
        pet.energy = ClampNeedValue(self, pet.energy - scavengeEnergyExtra)
        pet.cleanliness = ClampNeedValue(self, pet.cleanliness - scavengeCleanExtra)

        if economy.work.totalTicks % 5 == 0 then
            self:PushActivity(string.format("Scavenging paid %d shells so far.", economy.work.totalEarned))
        end

        if pet.energy <= NeedPercentToPoints(4) or pet.joy <= NeedPercentToPoints(4) or pet.cleanliness <= NeedPercentToPoints(4) then
            local petName = TrimString((pet.name or ""))
            if petName == "" then
                petName = "the hatchling"
            end
            self:StopWorkShift(string.format("Scavenging stopped: %s needs care.", petName))
        end
    end

    self:UpdateSwimmingChallengeProgress(true)

    self:TryAutonomousBehavior()

    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "decay_tick" })
    end

    self:RefreshUI()
end

function MM:IncreaseHatchProgress(amount)
    local pet = self:GetPet()
    if pet.stage ~= "egg" then
        return false
    end

    if pet.readyToHatch then
        return false
    end

    pet.hatchProgress = self:Clamp(pet.hatchProgress + amount, 0, 100)
    if pet.hatchProgress >= 100 then
        pet.hatchProgress = 100
        pet.readyToHatch = true
        self:PushActivity("The egg is ready to hatch. Press Hatch Me!")
        return true
    end

    return false
end

function MM:HatchEggNow()
    local pet = self:GetPet()
    if pet.stage ~= "egg" or not pet.readyToHatch then
        return false, "Egg is not ready yet."
    end

    pet.stage = "adolescent"
    pet.readyToHatch = false
    pet.hatchProgress = 100
    pet.hunger = NEED_POINTS_MAX
    pet.joy = NEED_POINTS_MAX
    pet.energy = NEED_POINTS_MAX
    pet.cleanliness = NEED_POINTS_MAX
    pet.needScaleVersion = 2
    pet.eggCare = nil

    self.db.profile.dev = self.db.profile.dev or {}
    local forcedDisplayID = tonumber(self.db.profile.dev.selectedHatchDisplayID)
    local modelEntry = forcedDisplayID and self:GetMurlocModelByDisplayID(forcedDisplayID) or nil
    if not modelEntry then
        modelEntry = self:GetWeightedRandomMurlocModel()
    end
    if modelEntry then
        pet.modelDisplayID = tonumber(modelEntry.displayID) or pet.modelDisplayID
        pet.modelRarity = modelEntry.rarity or "common"
        pet.modelLabel = modelEntry.label or ("Display " .. tostring(pet.modelDisplayID or "Unknown"))
    else
        pet.modelDisplayID = pet.modelDisplayID or 617
        pet.modelRarity = pet.modelRarity or "common"
    end

    self:GenerateChallenges(true)
    if self.EvaluateAchievements then
        self:EvaluateAchievements({ source = "hatch" })
    end
    local hatchName = TrimString((pet.name or ""))
    if hatchName ~= "" then
        self:PushActivity(string.format("The egg cracked. %s has hatched!", hatchName))
    else
        self:PushActivity("The egg cracked. A hatchling emerged!")
    end
    do
        local rarityLabel = self:GetMurlocRarityLabel(pet.modelRarity)
        local rr, gg, bb = self:GetMurlocRarityColor(pet.modelRarity)
        local rarityHex = string.format("|cff%02x%02x%02x%s|r", math.floor(rr * 255 + 0.5), math.floor(gg * 255 + 0.5), math.floor(bb * 255 + 0.5), rarityLabel)
        local modelLabel = pet.modelLabel or ("Display " .. tostring(pet.modelDisplayID or "Unknown"))
        self:PushActivity(string.format("Hatch Type: %s %s (Display %d).", rarityHex, modelLabel, tonumber(pet.modelDisplayID) or 0))
    end
    self:PushActivity(self:GetDirectionalHatchMessage(pet))
    if self.ShowNameMurlocPrompt then
        self:ShowNameMurlocPrompt()
    end
    if self.ResolveAssignedSoundEntry and self.PlayAddonSound then
        local hatchSound = self:ResolveAssignedSoundEntry({ index = HATCH_SOUND_INDEX })
        if hatchSound then
            self:PlayAddonSound(hatchSound.id, hatchSound.mode, hatchSound.path)
        end
    end
    self:PlayHatchSequence()
    self:RefreshUI()
    return true, "Hatched"
end

function MM:BuyFood(itemKey)
    local store = self:GetStore()
    local economy = self:GetEconomy()
    if self:GetPet().stage == "egg" then
        return false, "Store purchases unlock after hatching."
    end

    for _, item in ipairs(FOOD_ITEMS) do
        if item.key == itemKey then
            if store.shells < item.price then
                return false, "Not enough shells."
            end

            store.shells = store.shells - item.price
            economy.shellsSpent = economy.shellsSpent + item.price
            store.inventory[item.key] = (store.inventory[item.key] or 0) + 1
            self:RecordPurchaseStat(item.key, 1)
            self:PushActivity(string.format("Bought %s.", item.label))
            return true, "Purchased."
        end
    end

    return false, "Unknown item."
end

function MM:FeedFood(itemKey)
    local pet = self:GetPet()
    local store = self:GetStore()

    if pet.stage == "egg" then
        return false, "The egg cannot eat yet."
    end

    local feedRemaining = self:GetAdolescentActionCooldown("feed")
    if feedRemaining > 0 then
        return false, string.format("Feed ready in %.1fs.", feedRemaining)
    end

    if itemKey == FREE_ACTION_KEY then
        local basicFeedGain = BASIC_ACTION_VALUE
        do
            local foodMultiplier = self:GetFoodEffectMultiplier()
            local hungerFlat = self:HasStoreUpgrade("tideLadle") and 4 or 0
            basicFeedGain = (BASIC_ACTION_VALUE * foodMultiplier) + hungerFlat
        end
        pet.hunger = ClampNeedValue(self, (pet.hunger or 0) + NeedPercentToPoints(basicFeedGain))
        self:ApplyBasicActionTraitEffects("feed")
        self:RecordActionStat("feed", 1)
        self:PushActivity(GetCareActionMessage("feed", FREE_ACTION_KEY, pet.name))
        self:AdvanceChallenges("feed", 1, { zone = GetRealZoneText() }, true)
        self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
        self:TriggerAdolescentActionCooldown("feed")
        self:RefreshUI()
        return true, "Fed."
    end

    for _, item in ipairs(FOOD_ITEMS) do
        if item.key == itemKey then
            local count = store.inventory[item.key] or 0
            if count <= 0 then
                return false, "You do not own that food."
            end

            local keepItem = self:HasStoreUpgrade("rationLedger") and RollPercentChance(0.12)
            if not keepItem then
                store.inventory[item.key] = count - 1
            end

            local foodMultiplier = self:GetFoodEffectMultiplier()
            local hungerFlat = self:HasStoreUpgrade("tideLadle") and 4 or 0
            local joyFlat = self:GetFoodJoyDelta()
            local cleanDelta = self:GetFoodCleanlinessDelta()

            pet.hunger = ClampNeedValue(self, pet.hunger + NeedPercentToPoints((item.hunger * foodMultiplier) + hungerFlat))
            pet.joy = ClampNeedValue(self, pet.joy + NeedPercentToPoints(joyFlat))
            if cleanDelta ~= 0 then
                pet.cleanliness = ClampNeedValue(self, (pet.cleanliness or 0) + NeedPercentToPoints(cleanDelta))
            end
            self:ApplyTraitTriggerEffects("feed")
            self:RecordActionStat("feed", 1)

            local feedMessage = GetCareActionMessage("feed", item.key, pet.name)
            if feedMessage and feedMessage ~= "" then
                self:PushActivity(feedMessage)
            else
                self:PushActivity(string.format("Fed %s to %s.", item.label, pet.name))
            end
            if keepItem then
                self:PushActivity("Ration Ledger saved that snack from being consumed.")
            end

            self:AdvanceChallenges("feed", 1, { zone = GetRealZoneText() }, true)
            self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
            self:TriggerAdolescentActionCooldown("feed")
            self:RefreshUI()
            return true, "Fed."
        end
    end

    return false, "Unknown item."
end

local function GetBestOwnedStoreItem(addon, category, bonusField)
    local bestEntry
    local bestBonus = -math.huge

    for _, entry in ipairs(addon:GetStoreCatalog()) do
        if entry.category == category then
            local owned = addon:GetStoreItemCount(entry.key)
            if owned > 0 then
                local bonus = tonumber(entry[bonusField]) or 0
                if bonus > bestBonus then
                    bestBonus = bonus
                    bestEntry = entry
                end
            end
        end
    end

    return bestEntry
end

local function GetOwnedStoreItemCount(store, itemKey)
    if not store or not store.items or not itemKey then
        return 0
    end
    return CoerceOwnedCount(store.items[itemKey])
end

local function RollStoreItemWear(addon, store, entry, chanceMultiplier)
    if not addon or not store or type(entry) ~= "table" then
        return false, 0
    end

    local owned = GetOwnedStoreItemCount(store, entry.key)
    if owned <= 0 then
        return false, 0
    end

    store.itemUseCounts = store.itemUseCounts or {}
    local uses = tonumber(store.itemUseCounts[entry.key]) or 0
    local baseChance = tonumber(entry.riskChance) or 0.03
    local increasePerUse = tonumber(entry.riskIncreasePerUse) or 0.006
    local maxChance = tonumber(entry.riskMaxChance) or 0.55
    local multiplier = tonumber(chanceMultiplier) or 1.0
    local failChance = math.min(maxChance, (baseChance + (uses * increasePerUse)) * multiplier)

    if RollPercentChance(failChance) then
        store.items[entry.key] = math.max(0, owned - 1)
        store.itemUseCounts[entry.key] = 0
        addon:RecordLossStat(entry.key, 1)
        return true, failChance
    end

    store.itemUseCounts[entry.key] = uses + 1
    return false, failChance
end

function MM:PlayWithMurloc(selectedToyKey)
    local pet = self:GetPet()

    if pet.stage == "egg" then
        return self:CareForEgg("soothe")
    end

    local playRemaining = self:GetAdolescentActionCooldown("play")
    if playRemaining > 0 then
        return false, string.format("Play ready in %.1fs.", playRemaining)
    end

    local store = self:GetStore()
    local joyGain = BASIC_ACTION_VALUE
    local brokeOrLostText
    local toyRiskMultiplier = self:HasStoreUpgrade("toyChest") and 0.55 or 1.0
    local trinketPlayMultiplier = self:HasStoreUpgrade("playBell") and 1.10 or 1.0

    local bestToy
    if selectedToyKey and selectedToyKey ~= "" and selectedToyKey ~= FREE_ACTION_KEY then
        local selectedEntry = self:GetStoreEntry(selectedToyKey)
        if not selectedEntry or selectedEntry.category ~= "play" then
            return false, "That toy cannot be used for play."
        end
        if self:GetStoreItemCount(selectedToyKey) <= 0 then
            return false, "You do not own that play item."
        end
        bestToy = selectedEntry
    elseif not selectedToyKey or selectedToyKey == "" then
        bestToy = GetBestOwnedStoreItem(self, "play", "joyBonus")
    else
        bestToy = nil
    end
    if bestToy then
        if GetOwnedStoreItemCount(store, bestToy.key) <= 0 then
            return false, "You do not own that play item."
        end
        joyGain = tonumber(bestToy.joyBonus) or BASIC_ACTION_VALUE

        local broke = RollStoreItemWear(self, store, bestToy, toyRiskMultiplier)
        if broke then
            brokeOrLostText = bestToy.breakText or (bestToy.label .. " was lost during play.")
        end
    end

    pet.joy = ClampNeedValue(self, pet.joy + NeedPercentToPoints(joyGain * trinketPlayMultiplier))
    if bestToy then
        pet.energy = ClampNeedValue(self, pet.energy - NeedPercentToPoints(2))
        pet.cleanliness = ClampNeedValue(self, pet.cleanliness - NeedPercentToPoints(2))
        self:ApplyTraitTriggerEffects("play")
    else
        self:ApplyBasicActionTraitEffects("play")
    end

    local playMessage = GetCareActionMessage("play", bestToy and bestToy.key or FREE_ACTION_KEY, pet.name)
    if playMessage and playMessage ~= "" then
        self:PushActivity(playMessage)
    elseif bestToy then
        self:PushActivity(string.format("Played splash-tag with %s using %s.", pet.name, bestToy.label))
    else
        self:PushActivity(string.format("Played splash-tag with %s.", pet.name))
    end
    self:RecordActionStat("play", 1)
    if brokeOrLostText then
        self:PushActivity(brokeOrLostText)
    end
    self:AdvanceChallenges("play", 1, { zone = GetRealZoneText() }, true)
    self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
    self:TriggerAdolescentActionCooldown("play")
    self:RefreshUI()
    return true, "Played."
end

function MM:CleanHabitat(selectedCleanKey)
    local pet = self:GetPet()
    if pet.stage == "egg" then
        return self:CareForEgg("clean")
    end

    local cleanRemaining = self:GetAdolescentActionCooldown("clean")
    if cleanRemaining > 0 then
        return false, string.format("Clean ready in %.1fs.", cleanRemaining)
    end
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local cleanGain = BASIC_ACTION_VALUE
    local cleanMultiplier = self:HasStoreUpgrade("washStation") and 1.20 or 1.0

    local bestCleaner
    if selectedCleanKey and selectedCleanKey ~= "" and selectedCleanKey ~= FREE_ACTION_KEY then
        local selectedEntry = self:GetStoreEntry(selectedCleanKey)
        if not selectedEntry or selectedEntry.category ~= "clean" then
            return false, "That item cannot be used for cleaning."
        end
        if self:GetStoreItemCount(selectedCleanKey) <= 0 then
            return false, "You do not own that cleaning item."
        end
        bestCleaner = selectedEntry
    elseif not selectedCleanKey or selectedCleanKey == "" then
        bestCleaner = GetBestOwnedStoreItem(self, "clean", "cleanBonus")
    else
        bestCleaner = nil
    end
    local cleanerBreakText
    if bestCleaner then
        if GetOwnedStoreItemCount(store, bestCleaner.key) <= 0 then
            return false, "You do not own that cleaning item."
        end
        cleanGain = (tonumber(bestCleaner.cleanBonus) or BASIC_ACTION_VALUE) * cleanMultiplier
        local broke = RollStoreItemWear(self, store, bestCleaner, 1.0)
        if broke then
            cleanerBreakText = bestCleaner.breakText or (bestCleaner.label .. " was used up.")
        end
    end

    if not bestCleaner then
        cleanGain = cleanGain * cleanMultiplier
    end

    pet.cleanliness = ClampNeedValue(self, pet.cleanliness + NeedPercentToPoints(cleanGain))
    if bestCleaner then
        pet.joy = ClampNeedValue(self, pet.joy + NeedPercentToPoints(self:GetCleanJoyDelta()))
        self:ApplyTraitTriggerEffects("clean")
    else
        self:ApplyBasicActionTraitEffects("clean")
    end
    if economy.work.active then
        economy.work.pendingSkippedPayoutTicks = (tonumber(economy.work.pendingSkippedPayoutTicks) or 0) + 1
    end
    local cleanMessage = GetCareActionMessage("clean", bestCleaner and bestCleaner.key or FREE_ACTION_KEY, pet.name)
    if cleanMessage and cleanMessage ~= "" then
        self:PushActivity(cleanMessage)
    elseif bestCleaner then
        self:PushActivity(string.format("You tidied the tidepool and polished shells using %s.", bestCleaner.label))
    else
        self:PushActivity("You tidied the tidepool and polished shells.")
    end
    self:RecordActionStat("clean", 1)
    if cleanerBreakText then
        self:PushActivity(cleanerBreakText)
    end
    self:AdvanceChallenges("clean", 1, { zone = GetRealZoneText() }, true)
    self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
    self:TriggerAdolescentActionCooldown("clean")
    self:RefreshUI()
    return true, "Cleaned."
end

function MM:GetCleanJoyDelta()
    return -1
end

function MM:RestMurloc(selectedRestKey)
    local pet = self:GetPet()
    if pet.stage == "egg" then
        return self:CareForEgg("warm")
    end

    local restRemaining = self:GetAdolescentActionCooldown("rest")
    if restRemaining > 0 then
        return false, string.format("Rest ready in %.1fs.", restRemaining)
    end
    local economy = self:GetEconomy()
    local store = self:GetStore()
    local energyGain = BASIC_ACTION_VALUE
    local restMultiplier = self:HasStoreUpgrade("napWhistle") and 1.10 or 1.0

    local bestRestItem
    if selectedRestKey and selectedRestKey ~= "" and selectedRestKey ~= FREE_ACTION_KEY then
        local selectedEntry = self:GetStoreEntry(selectedRestKey)
        if not selectedEntry or selectedEntry.category ~= "rest" then
            return false, "That item cannot be used for resting."
        end
        if self:GetStoreItemCount(selectedRestKey) <= 0 then
            return false, "You do not own that rest item."
        end
        bestRestItem = selectedEntry
    elseif not selectedRestKey or selectedRestKey == "" then
        bestRestItem = GetBestOwnedStoreItem(self, "rest", "restBonus")
    else
        bestRestItem = nil
    end
    local restBreakText
    if bestRestItem then
        if GetOwnedStoreItemCount(store, bestRestItem.key) <= 0 then
            return false, "You do not own that rest item."
        end
        local itemMultiplier = self:HasStoreUpgrade("cozyNest") and 1.20 or 1.0
        energyGain = (tonumber(bestRestItem.restBonus) or BASIC_ACTION_VALUE) * itemMultiplier
        local broke = RollStoreItemWear(self, store, bestRestItem, 1.0)
        if broke then
            restBreakText = bestRestItem.breakText or (bestRestItem.label .. " was used up.")
        end
    end

    pet.energy = ClampNeedValue(self, pet.energy + NeedPercentToPoints(energyGain * restMultiplier))
    if bestRestItem then
        pet.hunger = ClampNeedValue(self, pet.hunger - NeedPercentToPoints(2))
        self:ApplyTraitTriggerEffects("rest")
    else
        self:ApplyBasicActionTraitEffects("rest")
    end
    if economy.work.active then
        economy.work.pendingSkippedPayoutTicks = (tonumber(economy.work.pendingSkippedPayoutTicks) or 0) + 1
    end

    if pet.stage == "egg" then
        self:IncreaseHatchProgress(3)
    end

    local restMessage = GetCareActionMessage("rest", bestRestItem and bestRestItem.key or FREE_ACTION_KEY, pet.name)
    if restMessage and restMessage ~= "" then
        self:PushActivity(restMessage)
    elseif bestRestItem then
        self:PushActivity(string.format("Rest time. The tidepool got quiet using %s.", bestRestItem.label))
    else
        self:PushActivity("Rest time. The tidepool got quiet.")
    end
    self:RecordActionStat("rest", 1)
    if restBreakText then
        self:PushActivity(restBreakText)
    end
    self:AdvanceChallenges("rest", 1, { zone = GetRealZoneText() }, true)
    self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
    self:TriggerAdolescentActionCooldown("rest")
    self:RefreshUI()
    return true, "Rested."
end

function MM:FindSupplies(actionKey)
    local pet = self:GetPet()
    if pet.stage == "egg" then
        return false, "The egg cannot forage yet."
    end

    local remaining = self:GetAdolescentActionCooldown("find")
    if remaining > 0 then
        return false, string.format("Find ready in %.1fs.", remaining)
    end

    local foodFound = {}
    local itemFound = {}
    local shellsFound = 0

    local function grantRewardItem(itemKey)
        if not itemKey or itemKey == "" then
            return false
        end
        local added = self:AdjustRewardItemCount(itemKey, 1)
        if added <= 0 then
            return false
        end
        if FOOD_ITEM_BY_KEY[itemKey] then
            foodFound[#foodFound + 1] = itemKey
        else
            itemFound[#itemFound + 1] = itemKey
        end
        return true
    end

    if actionKey == "forage_food" then
        grantRewardItem(self:RollRandomFindItem({ foodOnly = true }))
        if math.random() < 0.45 then
            grantRewardItem(self:RollRandomFindItem({ foodOnly = true }))
        end
        if math.random() < 0.35 then
            shellsFound = shellsFound + math.random(1, 4)
        end
    else
        shellsFound = shellsFound + math.random(2, 8)
        if math.random() < 0.7 then
            grantRewardItem(self:RollRandomFindItem())
        end
        if math.random() < 0.4 then
            grantRewardItem(self:RollRandomFindItem({ foodOnly = true }))
        end
    end

    if shellsFound > 0 then
        self:AddShells(shellsFound)
    end

    local function labelForKey(itemKey)
        for _, food in ipairs(self:GetFoodItems()) do
            if food.key == itemKey then
                return food.label
            end
        end
        local entry = self:GetStoreEntry(itemKey)
        return entry and entry.label or itemKey
    end

    if actionKey == "forage_food" then
        self:PushActivity(string.format("%s searched tidepools for snacks.", pet.name))
    else
        self:PushActivity(string.format("%s scavenged along the shoreline.", pet.name))
    end
    self:RecordActionStat("find", 1)

    for _, key in ipairs(foodFound) do
        self:PushActivity(string.format("Found food: %s.", labelForKey(key)))
    end
    for _, key in ipairs(itemFound) do
        self:PushActivity(string.format("Found item: %s.", labelForKey(key)))
    end
    if shellsFound > 0 then
        self:PushActivity(string.format("Found %d shells.", shellsFound))
    end

    self:AdvanceChallenges("zone_activity", 1, { zone = GetRealZoneText() }, true)
    self:TriggerAdolescentActionCooldown("find")
    self:RefreshUI()
    return true, "Scavenged."
end
