packages feed

swarm-0.7.0.0: src/swarm-scenario/Swarm/Game/Terrain.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Terrain types and properties.
module Swarm.Game.Terrain (
  TerrainType (..),
  TerrainObj (..),
  TerrainMap (..),
  blankTerrainIndex,
  getTerrainDefaultPaletteChar,
  getTerrainWord,
  terrainFromText,
  loadTerrain,
  mkTerrainMap,
  validateTerrainAttrRefs,
) where

import Control.Algebra (Has)
import Control.Arrow (first, (&&&))
import Control.Effect.Lift (Lift, sendIO)
import Control.Effect.Throw (Throw, liftEither, throwError)
import Control.Monad (forM, unless, (<=<))
import Data.Char (toUpper)
import Data.Hashable (Hashable)
import Data.IntMap (IntMap)
import Data.IntMap qualified as IM
import Data.Map (Map)
import Data.Map qualified as M
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Tuple (swap)
import Data.Yaml
import GHC.Generics (Generic)
import Swarm.Failure
import Swarm.Game.Display
import Swarm.Game.Entity.Cosmetic (WorldAttr (..))
import Swarm.ResourceLoading (getDataFileNameSafe)
import Swarm.Util (enumeratedMap, quote)
import Swarm.Util.Effect (withThrow)

data TerrainType = BlankT | TerrainType Text
  deriving (Eq, Ord, Show, Generic, ToJSON, Hashable)

blankTerrainIndex :: Int
blankTerrainIndex = 0

terrainFromText :: Text -> TerrainType
terrainFromText "blank" = BlankT
terrainFromText x = TerrainType x

getTerrainWord :: TerrainType -> Text
getTerrainWord BlankT = "blank"
getTerrainWord (TerrainType x) = x

instance FromJSON TerrainType where
  parseJSON =
    withText "TerrainType" $
      return . terrainFromText

instance Semigroup TerrainType where
  t <> BlankT = t
  _ <> t = t

instance Monoid TerrainType where
  mempty = BlankT

getTerrainDefaultPaletteChar :: TerrainType -> Char
getTerrainDefaultPaletteChar = toUpper . T.head . getTerrainWord

-- | Representation for parsing only. Not exported.
data TerrainItem = TerrainItem
  { name :: TerrainType
  , attr :: Text
  , description :: Text
  }
  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)

data TerrainObj = TerrainObj
  { terrainName :: TerrainType
  , terrainDesc :: Text
  , terrainDisplay :: Display
  }
  deriving (Show)

promoteTerrainObjects :: [TerrainItem] -> [TerrainObj]
promoteTerrainObjects =
  map (\(TerrainItem n a d) -> TerrainObj n d $ defaultTerrainDisplay (AWorld a))

invertedIndexMap :: IntMap TerrainObj -> Map TerrainType Int
invertedIndexMap = M.fromList . map (first terrainName . swap) . IM.toList

-- | Each terrain type shall have a unique
-- integral index. The indices should
-- be consecutive by parse order.
data TerrainMap = TerrainMap
  { terrainByName :: Map TerrainType TerrainObj
  , terrainByIndex :: IntMap TerrainObj
  , terrainIndexByName :: Map TerrainType Int
  -- ^ basically the inverse of 'terrainByIndex'.
  -- This needs to be (is) recomputed upon every update to
  -- the other fields in 'TerrainMap'.
  }
  deriving (Show)

instance Semigroup TerrainMap where
  TerrainMap oldByName oldByIndex _ <> TerrainMap newByName newByIndex _ =
    TerrainMap
      (oldByName <> newByName)
      combinedTerrainByIndex
      (invertedIndexMap combinedTerrainByIndex)
   where
    combinedTerrainByIndex = oldByIndex <> enumeratedMap (IM.size oldByIndex) (IM.elems newByIndex)

instance Monoid TerrainMap where
  mempty = TerrainMap mempty mempty mempty

mkTerrainMap :: [TerrainObj] -> TerrainMap
mkTerrainMap items =
  TerrainMap
    { terrainByName = M.fromList $ map (terrainName &&& id) items
    , terrainByIndex = byIndex
    , terrainIndexByName = invertedIndexMap byIndex
    }
 where
  byIndex = enumeratedMap blankTerrainIndex items

-- | Validates references to 'Display' attributes
validateTerrainAttrRefs :: Has (Throw LoadingFailure) sig m => Set WorldAttr -> [TerrainItem] -> m [TerrainObj]
validateTerrainAttrRefs validAttrs rawTerrains =
  forM rawTerrains $ \(TerrainItem n a d) -> do
    unless (Set.member (WorldAttr $ T.unpack a) validAttrs)
      . throwError
      . SystemFailure
      . CustomFailure
      $ T.unwords
        [ "Nonexistent attribute"
        , quote a
        , "referenced by terrain"
        , quote $ getTerrainWord n
        ]

    return $ TerrainObj n d $ defaultTerrainDisplay (AWorld a)

-- | Load terrain from a data file called @terrains.yaml@, producing
--   either an 'TerrainMap' or a parse error.
loadTerrain ::
  (Has (Throw SystemFailure) sig m, Has (Lift IO) sig m) =>
  m TerrainMap
loadTerrain = do
  fileName <- getDataFileNameSafe Terrain terrainFile
  decoded <-
    withThrow (terrainFailure . CanNotParseYaml) . (liftEither <=< sendIO) $
      decodeFileEither fileName

  let terrainObjs = promoteTerrainObjects decoded
  -- Ensures that the blank terrain gets index 0
  return $ mkTerrainMap $ blankTerrainObj : terrainObjs
 where
  terrainFile = "terrains.yaml"
  terrainFailure = AssetNotLoaded (Data Terrain) terrainFile

  blankTerrainObj = TerrainObj BlankT "Blank terrain" $ defaultTerrainDisplay ADefault