content: -- | Display atomic update commands received by the client.
  module Game.LambdaHack.Client.UI.Watch.WatchUpdAtomicM
    ( watchRespUpdAtomicUI
  #ifdef EXPOSE_INTERNAL
      -- * Internal operations
    , assignItemRole, Threat, createActorUI, destroyActorUI, spotItemBag
    , recordItemLid, moveActor, displaceActorUI, moveItemUI
    , discover, ppHearMsg, ppHearDistanceAdjective, ppHearDistanceAdverb
  #endif
    ) where
  
  import Prelude ()
  
  import Game.LambdaHack.Core.Prelude
  
  import           Control.Concurrent (threadDelay)
  import qualified Data.Char as Char
  import qualified Data.EnumMap.Strict as EM
  import qualified Data.EnumSet as ES
  import qualified Data.Map.Strict as M
  import qualified Data.Text as T
  import           GHC.Exts (inline)
  import qualified NLP.Miniutter.English as MU
  
  import           Game.LambdaHack.Atomic
  import           Game.LambdaHack.Client.MonadClient
  import           Game.LambdaHack.Client.State
  import           Game.LambdaHack.Client.UI.ActorUI
  import           Game.LambdaHack.Client.UI.Animation
  import           Game.LambdaHack.Client.UI.Content.Screen
  import           Game.LambdaHack.Client.UI.ContentClientUI
  import           Game.LambdaHack.Client.UI.DrawM
  import           Game.LambdaHack.Client.UI.Frame
  import           Game.LambdaHack.Client.UI.FrameM
  import           Game.LambdaHack.Client.UI.HandleHelperM
  import           Game.LambdaHack.Client.UI.ItemDescription
  import qualified Game.LambdaHack.Client.UI.Key as K
  import           Game.LambdaHack.Client.UI.MonadClientUI
  import           Game.LambdaHack.Client.UI.Msg
  import           Game.LambdaHack.Client.UI.MsgM
  import           Game.LambdaHack.Client.UI.SessionUI
  import           Game.LambdaHack.Client.UI.SlideshowM
  import           Game.LambdaHack.Client.UI.TutorialHints (TutorialHints (..))
  import           Game.LambdaHack.Client.UI.UIOptions
  import           Game.LambdaHack.Client.UI.Watch.WatchCommonM
  import           Game.LambdaHack.Client.UI.Watch.WatchQuitM
  import           Game.LambdaHack.Common.Actor
  import           Game.LambdaHack.Common.ActorState
  import           Game.LambdaHack.Common.Faction
  import           Game.LambdaHack.Common.Item
  import qualified Game.LambdaHack.Common.ItemAspect as IA
  import           Game.LambdaHack.Common.Kind
  import           Game.LambdaHack.Common.Level
  import           Game.LambdaHack.Common.Misc
  import           Game.LambdaHack.Common.MonadStateRead
  import           Game.LambdaHack.Common.Point
  import           Game.LambdaHack.Common.State
  import qualified Game.LambdaHack.Common.Tile as Tile
  import           Game.LambdaHack.Common.Time
  import           Game.LambdaHack.Common.Types
  import           Game.LambdaHack.Content.CaveKind (cdesc)
  import           Game.LambdaHack.Content.FactionKind
  import qualified Game.LambdaHack.Content.ItemKind as IK
  import           Game.LambdaHack.Content.ModeKind
  import qualified Game.LambdaHack.Content.ModeKind as MK
  import           Game.LambdaHack.Content.RuleKind
  import qualified Game.LambdaHack.Content.TileKind as TK
  import           Game.LambdaHack.Core.Random
  import qualified Game.LambdaHack.Definition.Ability as Ability
  import qualified Game.LambdaHack.Definition.Color as Color
  import           Game.LambdaHack.Definition.Defs
  import           Game.LambdaHack.Definition.Flavour
  
  -- | Visualize atomic updates sent to the client. This is done
  -- in the global state after the command is executed and after
  -- the client state is modified by the command.
  -- Doesn't modify client state (except a few fields), but only client
  -- session (e.g., by displaying messages). This is enforced by types.
  watchRespUpdAtomicUI :: MonadClientUI m => UpdAtomic -> m ()
  {-# INLINE watchRespUpdAtomicUI #-}
  watchRespUpdAtomicUI cmd = case cmd of
    -- Create/destroy actors and items.
    UpdRegisterItems{} -> return ()
    UpdCreateActor aid body _ -> createActorUI True aid body
    UpdDestroyActor aid body _ -> destroyActorUI True aid body
    UpdCreateItem verbose iid _ kit@(kAdd, _) c -> do
      recordItemLid iid c
      assignItemRole c iid
      when verbose $ case c of
        CActor aid store -> do
          b <- getsState $ getActorBody aid
          case store of
            _ | bproj b ->
              itemVerbMU MsgItemCreation iid kit "appear" c
            COrgan -> do
              localTime <- getsState $ getLocalTime (blid b)
              arItem <- getsState $ aspectRecordFromIid iid
              if | IA.checkFlag Ability.Blast arItem -> return ()
                 | IA.checkFlag Ability.Condition arItem -> do
                   side <- getsClient sside
                   discoBenefit <- getsClient sdiscoBenefit
                   bag <- getsState $ getContainerBag c
                   itemKind <- getsState $ getIidKind iid
                   let more = case EM.lookup iid bag of
                         Just (kTotal, _) | kTotal /= kAdd -> Just kTotal
                         _ -> Nothing
                       verbShow = MU.Text $
                         "become"
                         <+> case kit of
                           (1, _ : _) -> "somewhat"
                           (1, []) | isNothing more -> ""
                           _ | isNothing more -> "many-fold"
                           _ -> "additionally"
                       verbSave = MU.Text $
                         "become"
                         <+> case kit of
                           (1, t:_) ->  -- only exceptionally not singleton list
                                        -- or even more than one copy total
                             let total = deltaOfItemTimer localTime t
                             in timeDeltaInSecondsText total
                           (1, []) | isNothing more -> ""
                           (k, _) ->  -- usually the list empty; ignore anyway
                             (if isJust more then "additionally" else "")
                             <+> tshow k <> "-fold"
                             <+> case more of
                                   Nothing -> ""
                                   Just kTotal ->
                                     "(total:" <+> tshow kTotal <> "-fold)"
                       good = benInEqp (discoBenefit EM.! iid)
                       msgClass = case lookup IK.S_ASLEEP $ IK.ifreq itemKind of
                         Just n | n > 0 -> MsgStatusSleep
                         _ -> if | bfid b /= side -> MsgStatusOthers
                                 | good -> MsgStatusGoodUs
                                 | otherwise -> MsgStatusBadUs
                   -- This describes all such items already among organs,
                   -- which is useful, because it shows "charging".
                   itemAidDistinctMU msgClass aid verbShow verbSave iid
                   when (bfid b == side && not good) $
                     -- Others get conditions too often and good ones are not
                     -- dire enough and also too common.
                     tutorialHintMsgAdd TemporaryConditions
                 | otherwise -> do
                   wown <- ppContainerWownW partActorLeader True c
                   itemVerbMU MsgItemCreation iid kit
                              (MU.Text $ makePhrase $ "grow" : wown) c
            _ -> do
              wown <- ppContainerWownW partActorLeader True c
              itemVerbMU MsgItemCreation iid kit
                         (MU.Text $ makePhrase $ "appear" : wown) c
        CEmbed{} -> return ()  -- not visible so can't delay even if important
        CFloor lid _ -> do
          factionD <- getsState sfactionD
          itemVerbMU MsgItemCreation iid kit
                     (MU.Text $ "appear" <+> ppContainer factionD c) c
          markDisplayNeeded lid
        CTrunk{} -> return ()
    UpdDestroyItem verbose iid _ kit c ->
      when verbose $ case c of
        CActor aid _  -> do
          b <- getsState $ getActorBody aid
          if bproj b then
            itemVerbMUShort MsgItemRuination iid kit "break" c
          else do
            ownW <- ppContainerWownW partActorLeader False c
            let verb = MU.Text $ makePhrase $ "vanish from" : ownW
            itemVerbMUShort MsgItemRuination iid kit verb c
        CEmbed{} -> return ()  -- not visible so can't delay even if important
        CFloor lid _ -> do
          factionD <- getsState sfactionD
          itemVerbMUShort MsgItemRuination iid kit
                          (MU.Text $ "break" <+> ppContainer factionD c) c
          markDisplayNeeded lid
        CTrunk{} -> return ()
    UpdSpotActor aid body -> createActorUI False aid body
    UpdLoseActor aid body -> destroyActorUI False aid body
    UpdSpotItem verbose iid kit c -> spotItemBag verbose c $ EM.singleton iid kit
    UpdLoseItem True iid kit c@(CActor aid _) -> do
      b <- getsState $ getActorBody aid
      when (not (bproj b) && bhp b > 0) $ do  -- don't spam
        ownW <- ppContainerWownW partActorLeader False c
        let verb = MU.Text $ makePhrase $ "be removed from" : ownW
        itemVerbMUShort MsgItemMovement iid kit verb c
    UpdLoseItem{} -> return ()
    UpdSpotItemBag verbose c bag -> spotItemBag verbose c bag
    UpdLoseItemBag{} -> return ()  -- rarely interesting and can be very long
    -- Move actors and items.
    UpdMoveActor aid source target -> moveActor aid source target
    UpdWaitActor aid WSleep _ -> do
      aidVerbMU MsgStatusWakeup aid "wake up"
      tutorialHintMsgAdd WokenUpActors
    UpdWaitActor aid WWake _ -> do
      side <- getsClient sside
      b <- getsState $ getActorBody aid
      unless (bfid b == side) $
        tutorialHintMsgAdd AvoidWalkingEnemies
    UpdWaitActor{} -> return ()  -- falling asleep handled uniformly elsewhere
    UpdDisplaceActor source target -> displaceActorUI source target
    UpdMoveItem iid k aid c1 c2 -> moveItemUI iid k aid c1 c2
    -- Change actor attributes.
    UpdRefillHP _ 0 -> return ()
    UpdRefillHP aid hpDelta -> do
      let coarseDelta = abs hpDelta `div` oneM
          tDelta = if coarseDelta == 0
                   then if hpDelta > 0 then "a little" else "a fraction of an HP"
                   else tshow coarseDelta <+> "HP"
      b <- getsState $ getActorBody aid
      unless (bproj b) $
        aidVerbMU MsgNumericReport aid $ MU.Text
                  ((if hpDelta > 0 then "heal" else "lose") <+> tDelta)
      arena <- getArenaUI
      side <- getsClient sside
      if | bproj b && (EM.null (beqp b) || isNothing (btrajectory b)) ->
             return ()  -- ignore caught proj or one hitting a wall
         | bhp b <= 0 && hpDelta < 0
           && (bfid b == side && not (bproj b) || arena == blid b) -> do
           let (firstFall, hurtExtra) = case (bfid b == side, bproj b) of
                 (True, True) -> ("drop down", "tumble down")
                 (True, False) -> ("fall down", "suffer woeful mutilation")
                 (False, True) -> ("plummet", "crash")
                 (False, False) -> ("collapse", "be reduced to a bloody pulp")
               verbDie = if alreadyDeadBefore then hurtExtra else firstFall
               -- Rarely, this is wrong, because 2 other actors hit the victim
               -- at exactly the same time. No big problem. Doubled "dies"
               -- messages appears instead of "dies; is mutilated".
               alreadyDeadBefore = bhp b - hpDelta <= 0
           tfact <- getsState $ (EM.! bfid b) . sfactionD
           bUI <- getsSession $ getActorUI aid
           subjectRaw <- partActorLeader aid
           let subject = if alreadyDeadBefore || subjectRaw == "you"
                         then subjectRaw
                         else partActor bUI  -- avoid "fallen"
               msgDie = makeSentence [MU.SubjectVerbSg subject verbDie]
               targetIsFoe = isFoe (bfid b) tfact side
               targetIsFriend = isFriend (bfid b) tfact side
               msgClass | bproj b = MsgDeathBoring
                        | targetIsFoe = MsgDeathVictory
                        | targetIsFriend = MsgDeathDeafeat
                        | otherwise = MsgDeathBoring
           if | bproj b -> msgAdd msgClass msgDie
              | bfid b == side -> do
                msgLnAdd msgClass $ msgDie <+> "Alas!"
                displayMore ColorBW ""
              | otherwise -> msgLnAdd msgClass msgDie
           -- We show death anims only if not dead already before this refill.
           let deathAct = if bfid b == side
                          then deathBody (bpos b)
                          else shortDeathBody (bpos b)
           unless (bproj b || alreadyDeadBefore) $ animate (blid b) deathAct
         | otherwise -> do
           when (hpDelta >= bhp b && bhp b > 0) $
             aidVerbMU MsgActionWarning aid "return from the brink of death"
           mleader <- getsClient sleader
           when (Just aid == mleader) $ do
             actorMaxSk <- getsState $ getActorMaxSkills aid
             -- Regenerating actors never stop gaining HP, so we need to stop
             -- reporting it after they reach full HP for the first time.
             -- Also, no spam for non-leaders.
             when (bhp b >= xM (Ability.getSk Ability.SkMaxHP actorMaxSk)
                   && bhp b - hpDelta < xM (Ability.getSk Ability.SkMaxHP
                                                    actorMaxSk)) $
               msgAdd MsgSpecialEvent "You recover your health fully. Any further gains will be transient."
           when (bfid b == side && not (bproj b)) $ do
             when (abs hpDelta >= oneM) $ markDisplayNeeded (blid b)
             when (hpDelta < 0) $ do
               when (hpDelta <= xM (-3)) $ tutorialHintMsgAdd AlotOfDamageFromOneSource
               sUIOptions <- getsSession sUIOptions
               currentWarning <-
                 getsState $ checkWarningHP sUIOptions aid (bhp b)
               when currentWarning $ do
                 previousWarning <-
                   getsState $ checkWarningHP sUIOptions aid (bhp b - hpDelta)
                 unless previousWarning $
                   aidVerbMU MsgRiskOfDeath aid
                             "be down to a dangerous health level"
    UpdRefillCalm _ 0 -> return ()
    UpdRefillCalm aid calmDelta -> do
      side <- getsClient sside
      b <- getsState $ getActorBody aid
      when (bfid b == side && not (bproj b)) $ do
        if | calmDelta > 0 -> do  -- regeneration or effect
             mleader <- getsClient sleader
             when (Just aid == mleader) $ do
               actorMaxSk <- getsState $ getActorMaxSkills aid
               let bPrev = b {bcalm = bcalm b - calmDelta}
               when (calmEnough b actorMaxSk
                     && not (calmEnough bPrev actorMaxSk)) $
                 msgAdd MsgSpecialEvent "You are again calm enough to manage your equipment outfit."
             -- If the leader regenerates Calm more often than once per
             -- standard game turn, this will not be reflected, for smoother
             -- and faster display. However, every halt for keypress
             -- shows Calm, so this only matters for macros, where speed is good.
             when (abs calmDelta > oneM) $ markDisplayNeeded (blid b)
           | calmDelta == minusM1 -> do
             fact <- getsState $ (EM.! side) . sfactionD
             s <- getState
             let closeFoe (!p, aid2) =  -- mimics isHeardFoe
                   let b2 = getActorBody aid2 s
                   in inline chessDist p (bpos b) <= 3
                      && not (actorWaitsOrSleeps b2)  -- uncommon
                      && inline isFoe side fact (bfid b2)  -- costly
                 anyCloseFoes = any closeFoe $ EM.assocs $ lbig
                                             $ sdungeon s EM.! blid b
             unless anyCloseFoes $ do  -- obvious where the feeling comes from
               duplicated <- aidVerbDuplicateMU MsgHeardNearby aid
                                                "hear something"
               unless duplicated stopPlayBack
           | otherwise ->  -- low deltas from hits; displayed elsewhere
             return ()
        when (calmDelta < 0) $ do
          sUIOptions <- getsSession sUIOptions
          currentWarning <-
            getsState $ checkWarningCalm sUIOptions aid (bcalm b)
          when currentWarning $ do
            previousWarning <-
              getsState $ checkWarningCalm sUIOptions aid (bcalm b - calmDelta)
            unless previousWarning $
              -- This messages is not shown if impression happens after
              -- Calm is low enough. However, this is rare and HUD shows the red.
              aidVerbMU MsgRiskOfDeath aid
                        "have grown agitated and impressed enough to be in danger of defecting"
    UpdTrajectory _ _ mt ->  -- if projectile dies just after, force one frame
      when (isNothing mt) $ pushFrame False
    -- Change faction attributes.
    UpdQuitFaction fid _ toSt manalytics -> quitFactionUI fid toSt manalytics
    UpdSpotStashFaction verbose fid lid pos -> do
      side <- getsClient sside
      when verbose $ do
        if fid == side then
          msgLnAdd MsgFactionIntel
                   "You set up the shared inventory stash of your team."
        else do
          fact <- getsState $ (EM.! fid) . sfactionD
          let fidName = MU.Text $ gname fact
          msgAdd MsgFactionIntel $
            makeSentence [ "you have found the current"
                         , MU.WownW fidName "hoard location" ]
      unless (fid == side) $
        animate lid $ actorX pos
    UpdLoseStashFaction verbose fid lid pos -> do
      when verbose $ do
        side <- getsClient sside
        if fid == side then
          msgAdd MsgFactionIntel
                 "You've lost access to your shared inventory stash!"
        else do
          fact <- getsState $ (EM.! fid) . sfactionD
          let fidName = MU.Text $ gname fact
          msgAdd MsgFactionIntel $
            makeSentence [fidName, "no longer control their hoard"]
      animate lid $ vanish pos
    UpdLeadFaction fid (Just source) mtgt@(Just target) -> do
      mleader <- getsClient sleader
      when (mtgt /= mleader) $ do
        fact <- getsState $ (EM.! fid) . sfactionD
        lidV <- viewedLevelUI
        when (gunderAI fact) $ markDisplayNeeded lidV
        -- This faction can't run with multiple actors, so this is not
        -- a leader change while running, but rather server changing
        -- their leader, which the player should be alerted to.
        when (noRunWithMulti fact) stopPlayBack
        actorD <- getsState sactorD
        case EM.lookup source actorD of
          Just sb | bhp sb <= 0 -> assert (not $ bproj sb) $ do
            -- Regardless who the leader is, give proper names here, not 'you'.
            sbUI <- getsSession $ getActorUI source
            tbUI <- getsSession $ getActorUI target
            let subject = partActor tbUI
                object  = partActor sbUI
            msgAdd MsgPointmanSwap $
              makeSentence [ MU.SubjectVerbSg subject "take command"
                           , "from", object ]
          _ -> return ()
        lookAtMove target
    UpdLeadFaction _ Nothing mtgt@(Just target) -> do
      mleader <- getsClient sleader
      when (mtgt /= mleader) $
        lookAtMove target
    UpdLeadFaction{} -> return ()
    UpdDiplFaction fid1 fid2 _ toDipl -> do
      name1 <- getsState $ gname . (EM.! fid1) . sfactionD
      name2 <- getsState $ gname . (EM.! fid2) . sfactionD
      msgAdd MsgFactionIntel $
        name1 <+> "and" <+> name2 <+> "are now" <+> tshowDiplomacy toDipl <> "."
    UpdDoctrineFaction{} -> return ()
    UpdAutoFaction fid b -> do
      side <- getsClient sside
      lidV <- viewedLevelUI
      markDisplayNeeded lidV
      when (fid == side) $ do
        unless b $
          -- Clear macros and invoke a special main menu entrance macro
          -- that sets @swasAutomated@, preparing for AI control at exit.
          modifySession $ \sess ->
            sess { smacroFrame =
                     emptyMacroFrame {keyPending = KeyMacro [K.controlEscKM]}
                 , smacroStack = [] }
        setFrontAutoYes b  -- now can start/stop auto-accepting prompts
    UpdRecordKill{} -> return ()
    -- Alter map.
    UpdAlterTile lid p fromTile toTile -> do
      COps{cotile} <- getsState scops
      markDisplayNeeded lid
      let feats = TK.tfeature $ okind cotile fromTile
          toAlter feat =
            case feat of
              TK.OpenTo tgroup -> Just tgroup
              TK.CloseTo tgroup -> Just tgroup
              TK.ChangeTo tgroup -> Just tgroup
              TK.OpenWith _ _ tgroup -> Just tgroup
              TK.CloseWith _ _ tgroup -> Just tgroup
              TK.ChangeWith _ _ tgroup -> Just tgroup
              _ -> Nothing
          groupsToAlterTo = mapMaybe toAlter feats
          freq = map fst $ filter (\(_, q) -> q > 0)
                 $ TK.tfreq $ okind cotile toTile
          unexpected = null $ intersect freq groupsToAlterTo
      mactorAtPos <- getsState $ posToBig p lid
      mleader <- getsClient sleader
      when (unexpected || isJust mactorAtPos && mactorAtPos /= mleader) $ do
        -- Faction notices @fromTile@ can't be altered into @toTIle@,
        -- which is uncanny, so we produce a message.
        -- This happens when the player missed an earlier search of the tile
        -- performed by another faction.
        let subject = ""  -- a hack, because we don't handle adverbs well
            verb = "turn into"
            msg = makeSentence $
              [ "the", MU.Text $ TK.tname $ okind cotile fromTile
              , "at position", MU.Text $ tshow p ]
              ++ ["suddenly" | unexpected]  -- adverb
              ++ [ MU.SubjectVerbSg subject verb
                 , MU.AW $ MU.Text $ TK.tname $ okind cotile toTile ]
        msgAdd (if unexpected then MsgSpecialEvent else MsgNeutralEvent) msg
    UpdAlterExplorable lid _ -> markDisplayNeeded lid
    UpdAlterGold{} -> return ()  -- not displayed on HUD
    UpdSearchTile aid _p toTile -> do
      COps{cotile} <- getsState scops
      subject <- partActorLeader aid
      let fromTile = fromMaybe (error $ show toTile) $ Tile.hideAs cotile toTile
          subject2 = MU.Text $ TK.tname $ okind cotile fromTile
          object = MU.Text $ TK.tname $ okind cotile toTile
      let msg = makeSentence [ MU.SubjectVerbSg subject "reveal"
                             , "that the"
                             , MU.SubjectVerbSg subject2 "be"
                             , MU.AW object ]
      unless (subject2 == object) $ do
        msgAdd MsgTerrainReveal msg
        tutorialHintMsgAdd TerrainNotFullyKnown
    UpdHideTile{} -> return ()
    UpdSpotTile{} -> return ()
    UpdLoseTile{} -> return ()
    UpdSpotEntry{} -> return ()
    UpdLoseEntry{} -> return ()
    UpdAlterSmell{} -> return ()
    UpdSpotSmell{} -> return ()
    UpdLoseSmell{} -> return ()
    -- Assorted.
    UpdTimeItem{} -> return ()
    UpdAgeGame{} -> do
      sdisplayNeeded <- getsSession sdisplayNeeded
      sturnDisplayed <- getsSession sturnDisplayed
      time <- getsState stime
      let clipN = time `timeFit` timeClip
          clipMod = clipN `mod` clipsInTurn
          turnPing = clipMod == 0  -- e.g., to see resting counter
      if | sdisplayNeeded -> pushFrame True
             -- adds delay, because it's not an extra animation-like frame,
             -- but showing some real information accumulated up to this point
         | turnPing && not sturnDisplayed -> pushFrame False
         | otherwise -> return ()
      when turnPing $
        modifySession $ \sess -> sess {sturnDisplayed = False}
    UpdUnAgeGame{} -> return ()
    UpdDiscover c iid _ _ -> discover c iid
    UpdCover{} -> return ()  -- don't spam when doing undo
    UpdDiscoverKind{} -> return ()  -- don't spam when server tweaks stuff
    UpdCoverKind{} -> return ()  -- don't spam when doing undo
    UpdDiscoverAspect{} -> return ()  -- don't spam when server tweaks stuff
    UpdCoverAspect{} -> return ()  -- don't spam when doing undo
    UpdDiscoverServer{} -> error "server command leaked to client"
    UpdCoverServer{} -> error "server command leaked to client"
    UpdPerception{} -> return ()
    UpdRestart fid _ _ _ _ srandom -> do
      cops@COps{cocave, comode, corule} <- getsState scops
      oldSess <- getSession
      snxtChal <- getsClient snxtChal
      noConfirmsGame <- isNoConfirmsGame
      let uiOptions = sUIOptions oldSess
          f !acc _p !i _a = i : acc
          modes = zip [0..] $ ofoldlGroup' comode CAMPAIGN_SCENARIO f []
          g :: (Int, ContentId ModeKind) -> Int
          g (_, mode) = case EM.lookup mode (svictories oldSess) of
            Nothing -> 0
            Just cm -> fromMaybe 0 (M.lookup snxtChal cm)
          (snxtScenario, _) = minimumBy (comparing g) modes
          nxtGameTutorial = MK.mtutorial $ snd $ nxtGameMode cops snxtScenario
      putSession $
        (emptySessionUI uiOptions)
          { schanF = schanF oldSess
          , sccui = sccui oldSess
          , shistory = shistory oldSess
          , svictories = svictories oldSess
          , scampings = scampings oldSess
          , srestarts = srestarts oldSess
          , smarkVision = smarkVision oldSess
          , smarkSmell = smarkSmell oldSess
          , snxtScenario
          , scurTutorial = noConfirmsGame || snxtTutorial oldSess
              -- make sure a newbie interrupting a screensaver has ample help
          , snxtTutorial = nxtGameTutorial
          , soverrideTut = soverrideTut oldSess
          , sstart = sstart oldSess
          , sgstart = sgstart oldSess
          , sallTime = sallTime oldSess
          , snframes = snframes oldSess
          , sallNframes = sallNframes oldSess
          , srandomUI = srandom
          }
      when (sstart oldSess == 0) resetSessionStart
      when (lengthHistory (shistory oldSess) == 0) $ do
        -- Generate initial history. Only for UI clients.
        shistory <- defaultHistory
        modifySession $ \sess -> sess {shistory}
        let title = T.pack $ rtitle corule
        msgAdd MsgBookKeeping $ "Welcome to" <+> title <> "!"
      recordHistory  -- to ensure EOL even at creation of history
      lid <- getArenaUI
      lvl <- getLevel lid
      gameMode <- getGameMode
      curChal <- getsClient scurChal
      fact <- getsState $ (EM.! fid) . sfactionD
      let loneMode = case ginitial fact of
            [] -> True
            [(_, 1, _)] -> True
            _ -> False
      msgAdd MsgBookKeeping "-------------------------------------------------"
      recordHistory
      msgAdd MsgPromptGeneric
             "A grand story starts right here! (Press '?' for mode description and help.)"
      if lengthHistory (shistory oldSess) > 1
        then fadeOutOrIn False
        else pushReportFrame  -- show anything ASAP
      msgAdd MsgActionWarning
             ("New game started in" <+> mname gameMode <+> "mode.")
      let desc = cdesc $ okind cocave $ lkind lvl
      unless (T.null desc) $ do
        msgLnAdd MsgBackdropFocus "You take in your surroundings."
        msgAdd MsgBackdropInfo desc
      -- We can fool the player only once (per scenario), but let's not do it
      -- in the same way each time. TODO: PCG
      blurb <- rndToActionUI $ oneOf
        [ "You think you saw movement."
        , "Something catches your peripherial vision."
        , "You think you felt a tremor under your feet."
        , "A whiff of chilly air passes around you."
        , "You notice a draft just when it dies down."
        , "The ground nearby is stained along some faint lines."
        , "Scarce black motes slowly settle on the ground."
        , "The ground in the immediate area is empty, as if just swiped."
        ]
      msgLnAdd MsgBadMiscEvent blurb  -- being here is a bad turn of events
      when (cwolf curChal && not loneMode) $
        msgAdd MsgActionWarning "Being a lone wolf, you begin without companions."
      setFrontAutoYes $ gunderAI fact
      -- Forget the furious keypresses when dying in the previous game.
      resetPressedKeys
    UpdRestartServer{} -> return ()
    UpdResume fid _ -> do
      COps{cocave} <- getsState scops
      resetSessionStart
      fact <- getsState $ (EM.! fid) . sfactionD
      setFrontAutoYes $ gunderAI fact
      unless (gunderAI fact) $ do
        lid <- getArenaUI
        lvl <- getLevel lid
        gameMode <- getGameMode
        msgAdd MsgPromptGeneric
               "Welcome back! (Press '?' for mode description and help.)"
        pushReportFrame  -- show anything ASAP
        msgAdd MsgActionAlert $ "Continuing" <+> mname gameMode <+> "mode."
        let desc = cdesc $ okind cocave $ lkind lvl
        unless (T.null desc) $ do
          msgLnAdd MsgPromptFocus "You remember your surroundings."
          msgAdd MsgPromptGeneric desc
    UpdResumeServer{} -> return ()
    UpdKillExit{} -> do
  #ifdef USE_JSFILE
        -- Some browsers seem to trash Local Storage when page reloaded or closed
        -- or the browser closed, while they still internally finish the saving
        -- in the background, so wait 2s. If the exit is without a save,
        -- the wait is spurious, but it's not supposed to be common.
        -- TODO: replace the @liftIO@ with a @MonadClientUI@ delay function.
      liftIO $ threadDelay 2000000
  #else
      liftIO $ threadDelay 200000
  #endif
      -- The prompt is necessary to force frontend to show this before exiting.
      void $ displayMore ColorBW "Done."  -- in case it follows "Saving..."
      side <- getsClient sside
      debugPossiblyPrintUI $ "Client" <+> tshow side <+> "closing frontend."
      frontendShutdown
      debugPossiblyPrintUI $ "Client" <+> tshow side <+> "closed frontend."
    UpdWriteSave -> msgAdd MsgInnerWorkSpam "Saving backup."
    UpdHearFid _ distance hearMsg -> do
      mleader <- getsClient sleader
      case mleader of
        Just{} -> return ()  -- will flush messages when leader moves
        Nothing -> do
          lidV <- viewedLevelUI
          markDisplayNeeded lidV
          recordHistory
      msg <- ppHearMsg distance hearMsg
      let msgClass = case distance of
            Nothing -> MsgHeardOutside
            Just 0 -> MsgHeardNearby
            Just _ -> MsgHeardFaraway
      msgAdd msgClass msg
      case hearMsg of
        HearUpd UpdDestroyActor{} ->
          tutorialHintMsgAdd OutOfSightEvents
        HearTaunt{} -> do
          globalTime <- getsState stime
          when (globalTime > timeTurn) $  -- avoid too many hints at the start
            tutorialHintMsgAdd HearingRadius
        _ -> return ()
    UpdMuteMessages _ smuteMessages ->
      modifySession $ \sess -> sess {smuteMessages}
  
  assignItemRole :: MonadClientUI m => Container -> ItemId -> m ()
  assignItemRole c iid = do
    arItem <- getsState $ aspectRecordFromIid iid
    let assignSingleRole lore = do
          ItemRoles itemRoles <- getsSession sroles
          let itemRole = itemRoles EM.! lore
          unless (iid `ES.member` itemRole) $ do
            let newRoles = ItemRoles $ EM.adjust (ES.insert iid) lore itemRoles
            modifySession $ \sess -> sess {sroles = newRoles}
        slore = IA.loreFromContainer arItem c
    assignSingleRole slore
    when (slore `elem` [SOrgan, STrunk, SCondition]) $
      assignSingleRole SBody
  
  data Threat =
      ThreatNone
    | ThreatUnarmed
    | ThreatArmed
    | ThreatAnotherUnarmed
    | ThreatAnotherArmed
    deriving Eq
  
  createActorUI :: MonadClientUI m => Bool -> ActorId -> Actor -> m ()
  createActorUI born aid body = do
    CCUI{coscreen=ScreenContent{rwidth}} <- getsSession sccui
    side <- getsClient sside
    factionD <- getsState sfactionD
    let fact = factionD EM.! bfid body
    localTime <- getsState $ getLocalTime $ blid body
    itemFull@ItemFull{itemBase, itemKind} <- getsState $ itemToFull (btrunk body)
    actorUI <- getsSession sactorUI
    let arItem = aspectRecordFull itemFull
    unless (aid `EM.member` actorUI) $ do
      UIOptions{uHeroNames} <- getsSession sUIOptions
      let baseColor = flavourToColor $ jflavour itemBase
          basePronoun | not (bproj body)
                        && IK.isymbol itemKind == '@'
                        && fhasGender (gkind fact) = "he"
                      | otherwise = "it"
          nameFromNumber fn k = if k == 0
                                then makePhrase [MU.Ws $ MU.Text fn, "Captain"]
                                else fn <+> tshow k
          heroNamePronoun k =
            if gcolor fact /= Color.BrWhite
            then (nameFromNumber (fname $ gkind fact) k, "he")
            else fromMaybe (nameFromNumber (fname $ gkind fact) k, "he")
                 $ lookup k uHeroNames
          (n, bsymbol) =
            if | bproj body -> (0, if IA.checkFlag Ability.Blast arItem
                                   then IK.isymbol itemKind
                                   else '*')
               | baseColor /= Color.BrWhite -> (0, IK.isymbol itemKind)
               | otherwise -> case bnumber body of
                   Nothing ->
                     error $ "numbered actor without server-assigned number"
                             `showFailure` (aid, body)
                   Just bn -> (bn, if 0 < bn && bn < 10
                                   then Char.intToDigit bn
                                   else '@')
          (object1, object2) =
            partItemShortest rwidth (bfid body) factionD localTime
                             itemFull quantSingle
          (bname, bpronoun) =
            if | bproj body ->
                 let adj = case btrajectory body of
                       Just (tra, _) | length tra < 5 -> "falling"
                       _ -> "flying"
                 in (makePhrase [adj, object1, object2], basePronoun)
               | baseColor /= Color.BrWhite ->
                 (makePhrase [object1, object2], basePronoun)
               | otherwise -> heroNamePronoun n
          bcolor | bproj body = if IA.checkFlag Ability.Blast arItem
                                then baseColor
                                else Color.BrWhite
                 | baseColor == Color.BrWhite = gcolor fact
                 | otherwise = baseColor
          bUI = ActorUI{..}
      modifySession $ \sess ->
        sess {sactorUI = EM.insert aid bUI actorUI}
    mapM_ (\(iid, store) -> do
             let c = if not (bproj body) && iid == btrunk body
                     then CTrunk (bfid body) (blid body) (bpos body)
                     else CActor aid store
             assignItemRole c iid
             recordItemLid iid c)
          ((btrunk body, CEqp)  -- store will be overwritten, unless projectile
           : filter ((/= btrunk body) . fst) (getCarriedIidCStore body))
    if | bproj body -> do
         when (bfid body /= side)
           stopPlayBack
         pushFrame False  -- make sure first (seen (again)) position displayed
       | bfid body == side -> do
         let upd = ES.insert aid
         modifySession $ \sess -> sess {sselected = upd $ sselected sess}
         unless (EM.null actorUI) $ do  -- don't announce the very first party member
           when born $ do
             let verb = "join you"
             aidVerbMU MsgSpottedActor aid verb
             tutorialHintMsgAdd SwitchTeammate
             animate (blid body) $ actorX (bpos body)
       | otherwise -> do
         -- Don't spam if the actor was already visible
         -- (but, e.g., on a tile that is invisible this turn
         -- (in that case move is broken down to lose+spot)
         -- or on a distant tile, via teleport while the observer
         -- teleported, too).
         lastLost <- getsSession slastLost
         if ES.member aid lastLost
         then markDisplayNeeded (blid body)
         else do
           stopPlayBack
           let verb = if born then "appear suddenly" else "be spotted"
           threat <-
             if isFoe (bfid body) fact side then do
               -- Aim even if nobody can shoot at the enemy.
               -- Let's home in on him and then we can aim or melee.
               -- We set permit to False, because it's technically
               -- very hard to check aimability here, because we are
               -- in-between turns and, e.g., leader's move has not yet
               -- been taken into account.
               xhair <- getsSession sxhair
               case xhair of
                 Just (TVector _) -> return ()  -- explicitly set; keep it
                 _ -> modifySession $ \sess ->
                        sess { sxhair = Just $ TEnemy aid
                             , sitemSel = Nothing } -- reset flinging totally
               foes <- getsState $ foeRegularList side (blid body)
               itemsSize <- getsState $ guardItemSize body
               if length foes <= 1 then
                 if itemsSize == 0 then do
                   msgAdd MsgSpottedThreat "You are not alone!"
                   return ThreatUnarmed
                 else do
                   msgAdd MsgSpottedThreat "Armed intrusion ahead!"
                   return ThreatArmed
               else
                 if itemsSize == 0 then
                   return ThreatAnotherUnarmed
                 else do
                   msgAdd MsgSpottedThreat "Another threat, armed!"
                   return ThreatAnotherArmed
             else return ThreatNone  -- member of neutral faction
           aidVerbMU MsgSpottedActor aid verb
           friendAssocs <- getsState $ friendRegularAssocs side (blid body)
           case threat of
             ThreatNone -> return ()  -- too rare to care ATM
             ThreatUnarmed ->
               tutorialHintMsgAdd MeleeEnemies
             ThreatArmed ->
               tutorialHintMsgAdd UseTerrainEffect
             _ | length friendAssocs <= 1 -> return ()  -- one member on level
             ThreatAnotherUnarmed ->
               tutorialHintMsgAdd SwitchPointmanAndAvoidMeleeAlone
             ThreatAnotherArmed ->
               tutorialHintMsgAdd SwitchPointmanAndSoftenFoes
           animate (blid body) $ actorX (bpos body)
  
  destroyActorUI :: MonadClientUI m => Bool -> ActorId -> Actor -> m ()
  destroyActorUI destroy aid b = do
    trunk <- getsState $ getItemBody $ btrunk b
    let baseColor = flavourToColor $ jflavour trunk
    unless (baseColor == Color.BrWhite) $  -- keep setup for heroes, etc.
      modifySession $ \sess -> sess {sactorUI = EM.delete aid $ sactorUI sess}
    let dummyTarget = TPoint TKnown (blid b) (bpos b)
        affect tgt = case tgt of
          Just (TEnemy a) | a == aid -> Just $
            if destroy then
              -- If *really* nothing more interesting, the actor will
              -- go to last known location to perhaps find other foes.
              dummyTarget
            else
              -- If enemy only hides (or we stepped behind obstacle) find him.
              TPoint (TEnemyPos a) (blid b) (bpos b)
          Just (TNonEnemy a) | a == aid -> Just dummyTarget
          _ -> tgt
    modifySession $ \sess -> sess {sxhair = affect $ sxhair sess}
    unless (bproj b || destroy) $
      modifySession $ \sess -> sess {slastLost = ES.insert aid $ slastLost sess}
    side <- getsClient sside
    fact <- getsState $ (EM.! side) . sfactionD
    let gameOver = isJust $ gquit fact  -- we are the UI faction, so we determine
    unless gameOver $ do
      when (bfid b == side && not (bproj b)) $ do
        stopPlayBack
        let upd = ES.delete aid
        modifySession $ \sess -> sess {sselected = upd $ sselected sess}
        when destroy $ do
          mleader <- getsClient sleader
          when (isJust mleader)
            -- This is especially handy when the dead actor was a leader
            -- on a different level than the new one:
            clearAimMode
      -- If pushed, animate spotting again, to draw attention to pushing.
      markDisplayNeeded (blid b)
  
  spotItemBag :: forall m. MonadClientUI m
              => Bool -> Container -> ItemBag -> m ()
  spotItemBag verbose c bag = do
    -- This is due to a move, or similar, which will be displayed,
    -- so no extra @markDisplayNeeded@ needed here and in similar places.
    CCUI{coscreen=ScreenContent{rwidth}} <- getsSession sccui
    side <- getsClient sside
    getKind <- getsState $ flip getIidKindId
    lid <- getsState $ lidFromC c
    localTime <- getsState $ getLocalTime lid
    factionD <- getsState sfactionD
    -- Queried just once, so many copies of a new item can be reported. OK.
    ItemRoles itemRoles <- getsSession sroles
    sxhairOld <- getsSession sxhair
    let resetXhair = case c of
          CFloor _ p -> case sxhairOld of
            Just TEnemy{} -> return ()  -- probably too important to overwrite
            Just (TPoint TEnemyPos{} _ _) -> return ()
            Just (TPoint TStash{} _ _) -> return ()
            Just (TVector _) -> return ()  -- explicitly set; keep it
            _ -> do
              -- Don't steal xhair if it's only an item on another level.
              -- For enemies, OTOH, capture xhair to alarm player.
              lidV <- viewedLevelUI
              when (lid == lidV) $ do
                bagFloor <- getsState $ getFloorBag lid p
                modifySession $ \sess ->
                  sess { sxhair = Just $ TPoint (TItem bagFloor) lidV p
                       , sitemSel = Nothing }  -- reset flinging totally
          _ -> return ()
        locatedWhere = ppContainer factionD c
        beLocated = MU.Text $
          "be located" <+> if locatedWhere == ppContainer EM.empty c
                           then ""  -- boring
                           else locatedWhere
        subjectMaybe :: (ItemId, ItemQuant) -> m (Maybe (Int, MU.Part, MU.Part))
        subjectMaybe (iid, kit@(k, _)) = do
          recordItemLid iid c
          itemFull <- getsState $ itemToFull iid
          let arItem = aspectRecordFull itemFull
              slore = IA.loreFromContainer arItem c
          if iid `ES.member` (itemRoles EM.! slore)
          then return Nothing  -- this item or another with the same @iid@
                               -- seen already (has a role assigned); old news
          else do  -- never seen or would have a role
            assignItemRole c iid
            case c of
              CFloor{} -> do
                let subjectShort = partItemWsShortest rwidth side factionD k
                                                      localTime itemFull kit
                    subjectLong = partItemWsLong rwidth side factionD k
                                                 localTime itemFull kit
                return $ Just (k, subjectShort, subjectLong)
              _ -> return Nothing
        -- @SortOn@ less efficient here, because function cheap.
        sortItems = sortOn (getKind . fst)
        sortedAssocs = sortItems $ EM.assocs bag
    subjectMaybes <- mapM subjectMaybe sortedAssocs
    let subjects = catMaybes subjectMaybes
        sendMsg plural = do
          let subjectShort = MU.WWandW $ map (\(_, part, _) -> part) subjects
              subjectLong = MU.WWandW $ map (\(_, _, part) -> part) subjects
              msg subject =
                if plural
                then makeSentence [MU.SubjectVerb MU.PlEtc MU.Yes
                                                  subject beLocated]
                else makeSentence [MU.SubjectVerbSg subject beLocated]
              msgShort = msg subjectShort
              msgLong = msg subjectLong
              dotsIfShorter = if msgShort == msgLong then "" else ".."
          resetXhair
          msgAddDistinct MsgSpottedItem (msgShort <> dotsIfShorter, msgLong)
    case subjects of
      [] -> return ()
      [(1, _, _)] -> sendMsg False
      _ -> sendMsg True
    when verbose $ case c of
      CActor aid store -> do
        let verb = MU.Text $ verbCStore store
        b <- getsState $ getActorBody aid
        fact <- getsState $ (EM.! bfid b) . sfactionD
        mleader <- getsClient sleader
        if Just aid == mleader && not (gunderAI fact) then
          manyItemsAidVerbMU MsgItemMovement aid verb sortedAssocs Right
        else when (not (bproj b) && bhp b > 0) $  -- don't announce death drops
          manyItemsAidVerbMU MsgItemMovement aid verb sortedAssocs (Left . Just)
      _ -> return ()
  
  recordItemLid :: MonadClientUI m => ItemId -> Container -> m ()
  recordItemLid iid c = do
    mjlid <- getsSession $ EM.lookup iid . sitemUI
    when (isNothing mjlid) $ do
      lid <- getsState $ lidFromC c
      modifySession $ \sess ->
        sess {sitemUI = EM.insert iid lid $ sitemUI sess}
  
  moveActor :: MonadClientUI m => ActorId -> Point -> Point -> m ()
  moveActor aid source target = do
    -- If source and target tile distant, assume it's a teleportation
    -- and display an animation. Note: jumps and pushes go through all
    -- intervening tiles, so won't be considered. Note: if source or target
    -- not seen, the (half of the) animation would be boring, just a delay,
    -- not really showing a transition, so we skip it (via 'breakUpdAtomic').
    -- The message about teleportation is sometimes shown anyway, just as the X.
    body <- getsState $ getActorBody aid
    if adjacent source target
    then markDisplayNeeded (blid body)
    else do
      let ps = (source, target)
      animate (blid body) $ teleport ps
    lookAtMove aid
    stopAtMove aid
  
  displaceActorUI :: MonadClientUI m => ActorId -> ActorId -> m ()
  displaceActorUI source target = do
    mleader <- getsClient sleader
    sb <- getsState $ getActorBody source
    tb <- getsState $ getActorBody target
    spart <- partActorLeader source
    tpart <- partActorLeader target
    let msgClass = if mleader `elem` map Just [source, target]
                   then MsgActionMajor  -- to interrupt run after a displace;
                   else MsgActionMinor  -- configurable, animation is feedback
        msg = makeSentence [MU.SubjectVerbSg spart "displace", tpart]
    msgAdd msgClass msg
    lookAtMove source
    stopAtMove source
    when (bfid sb /= bfid tb) $ do
      lookAtMove target  -- in case only this one is ours
      stopAtMove target
    side <- getsClient sside
    -- Ours involved, but definitely not requested by player via UI.
    when (side `elem` [bfid sb, bfid tb] && mleader /= Just source) stopPlayBack
    let ps = (bpos tb, bpos sb)
    animate (blid sb) $ swapPlaces ps
  
  -- @UpdMoveItem@ is relatively rare (except within the player's faction),
  -- but it ensures that even if only one of the stores is visible
  -- (e.g., stash floor is not or actor posision is not), some messages
  -- will be printed (via verbose @UpdLoseItem@).
  moveItemUI :: MonadClientUI m
             => ItemId -> Int -> ActorId -> CStore -> CStore
             -> m ()
  moveItemUI iid k aid cstore1 cstore2 = do
    let verb = MU.Text $ verbCStore cstore2
    b <- getsState $ getActorBody aid
    fact <- getsState $ (EM.! bfid b) . sfactionD
    mleader <- getsClient sleader
    ItemRoles itemRoles <- getsSession sroles
    if iid `ES.member` (itemRoles EM.! SItem) then
      -- So far organs can't be put into stash, so no need to call
      -- @assignItemRole@ to add or reassign lore category.
      if cstore1 == CGround && Just aid == mleader && not (gunderAI fact) then
        itemAidVerbMU MsgActionMajor aid verb iid (Right k)
      else when (not (bproj b) && bhp b > 0) $  -- don't announce death drops
        itemAidVerbMU MsgActionMajor aid verb iid (Left k)
    else error $ "" `showFailure` (iid, k, aid, cstore1, cstore2)
  
  -- The item may be used up already and so not present in the container,
  -- e.g., if the item destroyed itself. This is OK. Message is still needed.
  discover :: MonadClientUI m => Container -> ItemId -> m ()
  discover c iid = do
    COps{coitem} <- getsState scops
    CCUI{coscreen=ScreenContent{rwidth}} <- getsSession sccui
    lid <- getsState $ lidFromC c
    globalTime <- getsState stime
    localTime <- getsState $ getLocalTime lid
    itemFull <- getsState $ itemToFull iid
    bag <- getsState $ getContainerBag c
    side <- getsClient sside
    factionD <- getsState sfactionD
    (noMsg, nameWhere) <- case c of
      CActor aidOwner storeOwner -> do
        bOwner <- getsState $ getActorBody aidOwner
        name <- if bproj bOwner
                then return []
                else ppContainerWownW partActorLeader True c
        let arItem = aspectRecordFull itemFull
            inMetaGame = IA.checkFlag Ability.MetaGame arItem
            isOurOrgan = bfid bOwner == side
                         && storeOwner == COrgan
                         && not inMetaGame
              -- assume own faction organs known intuitively,
              -- except backstories and other meta game items
        return (isOurOrgan, name)
      CTrunk _ _ p | p == originPoint -> return (True, [])
        -- the special reveal at game over, using fake @CTrunk@; don't spam
      _ -> return (False, [])
    let kit = EM.findWithDefault quantSingle iid bag
                -- may be used up by that time
        knownName = makePhrase
          [partItemMediumAW rwidth side factionD localTime itemFull kit]
        flav = flavourToName $ jflavour $ itemBase itemFull
        (object1, object2) =
          partItemShortest rwidth side factionD localTime itemFull kit
        name1 = makePhrase [object1, object2]
        -- Make sure the two names in the message differ.
        (ikObvious, itemKind) = case jkind $ itemBase itemFull of
          IdentityObvious ik -> (True, ik)
          IdentityCovered _ix ik -> (False, ik)
            -- fake kind (template); OK, we talk about appearances
        name2 = IK.iname $ okind coitem itemKind
        name = if ikObvious && T.unwords (tail (T.words knownName)) /= name1
               then name1  -- avoid "a pair turns out to be"
               else name2  -- avoid "chip of scientific explanation"
        unknownName = MU.Phrase $ [MU.Text flav, MU.Text name] ++ nameWhere
        msg = makeSentence
          [ "the"
          , MU.SubjectVerbSg unknownName "turn out to be"
          , MU.Text knownName ]
    unless (noMsg || globalTime == timeZero) $  -- no spam about initial equipment
      msgAdd MsgItemDiscovery msg
  
  ppHearMsg :: MonadClientUI m => Maybe Int -> HearMsg -> m Text
  ppHearMsg distance hearMsg = case hearMsg of
    HearUpd cmd -> do
      COps{coTileSpeedup} <- getsState scops
      let sound = case cmd of
            UpdDestroyActor{} -> "shriek"
            UpdCreateItem{} -> "clatter"
            UpdTrajectory{} -> "thud"  -- A non-blast projectle hits a tile.
            UpdAlterTile _ _ fromTile toTile ->
              if | Tile.isOpenable coTileSpeedup fromTile
                   && Tile.isClosable coTileSpeedup toTile
                   || Tile.isClosable coTileSpeedup fromTile
                      && Tile.isOpenable coTileSpeedup toTile -> "creaking sound"
                 | Tile.isWalkable coTileSpeedup fromTile
                   && Tile.isWalkable coTileSpeedup toTile -> "splash"
                 | otherwise -> "rumble"
            UpdAlterExplorable _ k ->
              if k > 0 then "grinding noise" else "fizzing noise"
            _ -> error $ "" `showFailure` cmd
          adjective = MU.Text $ ppHearDistanceAdjective distance
          msg = makeSentence ["you hear", MU.AW $ MU.Phrase [adjective, sound]]
      return $! msg
    HearStrike ik -> do
      COps{coitem} <- getsState scops
      let verb = IK.iverbHit $ okind coitem ik
          adverb = MU.Text $ ppHearDistanceAdverb distance
          msg = makeSentence [ "you", adverb, "hear something"
                             , MU.Text verb, "someone" ]
      return $! msg
    HearSummon isProj grp p -> do
      let verb = if isProj then "something lure" else "somebody summon"
          part = MU.Text $ displayGroupName grp
          object = if p == 1  -- works, because exact number sent, not dice
                   then MU.AW part
                   else MU.Ws part
          adverb = MU.Text $ ppHearDistanceAdverb distance
      return $! makeSentence ["you", adverb, "hear", verb, object]
    HearCollideTile -> do
      let adverb = MU.Text $ ppHearDistanceAdverb distance
      return $! makeSentence ["you", adverb, "hear someone crash into something"]
    HearTaunt t -> do
      let adverb = MU.Text $ ppHearDistanceAdverb distance
      return $! makePhrase ["You", adverb, "overhear", MU.Text t]
  
  ppHearDistanceAdjective :: Maybe Int -> Text
  ppHearDistanceAdjective Nothing = "indistinct"
  ppHearDistanceAdjective (Just 0) = "very close"
  ppHearDistanceAdjective (Just 1) = "close"
  ppHearDistanceAdjective (Just 2) = ""
  ppHearDistanceAdjective (Just 3) = "remote"
  ppHearDistanceAdjective (Just 4) = "distant"
  ppHearDistanceAdjective (Just _) = "far-off"
  
  ppHearDistanceAdverb :: Maybe Int -> Text
  ppHearDistanceAdverb Nothing = "indistinctly"
  ppHearDistanceAdverb (Just 0) = "very clearly"
  ppHearDistanceAdverb (Just 1) = "clearly"
  ppHearDistanceAdverb (Just 2) = ""
  ppHearDistanceAdverb (Just 3) = "remotely"
  ppHearDistanceAdverb (Just 4) = "distantly"
  ppHearDistanceAdverb (Just _) = "barely"
  