packages feed

swarm-0.7.0.0: src/swarm-scenario/Swarm/Game/Recipe/Graph.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
-- Description: calculate the graph of craftable entities
-- and their dependencies via recipes.
module Swarm.Game.Recipe.Graph (
  RecipeGraph (..),
  classicScenarioRecipeGraph,
  scenarioRecipeGraph,
  ignoredEntities,
) where

import Control.Lens (view, (^.))
import Data.Map.Lazy (Map)
import Data.Map.Lazy qualified as Map
import Data.Maybe (catMaybes, fromMaybe, listToMaybe, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Tuple (swap)
import Swarm.Failure (simpleErrorHandle)
import Swarm.Game.Entity (Entity, EntityMap (entitiesByName), EntityProperty (Pickable), entityProperties, entityYields)
import Swarm.Game.Entity qualified as E
import Swarm.Game.Land
import Swarm.Game.Recipe (Recipe, recipeCatalysts, recipeInputs, recipeOutputs)
import Swarm.Game.Robot (TRobot, tequippedDevices, trobotInventory)
import Swarm.Game.Scenario
import Swarm.Game.Scenario.Topography.Cell (PCell (..))
import Swarm.Game.Scenario.Topography.Grid
import Swarm.Game.Scenario.Topography.Structure.Overlay (gridContent)
import Swarm.Game.Scenario.Topography.WorldDescription
import Swarm.Game.World.Gen (extractEntities)
import Swarm.Util (both)
import Swarm.Util.Erasable (erasableToMaybe)

-- | Ignore utility entities that are just used for tutorials and challenges.
ignoredEntities :: Set Text
ignoredEntities = Set.fromList ["wall"]

-- | A description of a recipe graph for a given scenario.
data RecipeGraph = RecipeGraph
  { worldEntities :: Set Entity
  -- ^ Entities available in the world
  , startingDevices :: Set Entity
  -- ^ Which devices the base starts with
  , startingInventory :: Set Entity
  -- ^ What inventory the base starts with
  , levels :: [Set Entity]
  -- ^ Recursively craftable entities, organized by distance from
  -- starting inventory (i.e. number of crafting steps needed)
  , allEntities :: Set Entity
  -- ^ All known entities
  , recipes :: [Recipe Entity]
  -- ^ All known recipes
  }

baseRobotTemplate :: ScenarioLandscape -> Maybe TRobot
baseRobotTemplate = listToMaybe . view scenarioRobots

-- | Load the recipe graph corresponding to the classic scenario.
classicScenarioRecipeGraph :: IO RecipeGraph
classicScenarioRecipeGraph = simpleErrorHandle $ do
  (classic, gsi) <- loadStandaloneScenario "data/scenarios/classic.yaml"
  pure $ scenarioRecipeGraph classic gsi

scenarioRecipeGraph :: Scenario -> GameStateInputs -> RecipeGraph
scenarioRecipeGraph scenario (GameStateInputs (ScenarioInputs _ (TerrainEntityMaps _ emap)) recipeList) =
  RecipeGraph
    { startingDevices = devs
    , startingInventory = inv
    , worldEntities = ents
    , levels = recipeLevels scenarioEMap recipeList (Set.unions [ents, devs, inv])
    , allEntities = Set.fromList . Map.elems $ entitiesByName scenarioEMap
    , recipes = recipeList ++ (scenario ^. scenarioOperation . scenarioRecipes)
    }
 where
  scenarioEMap = (scenario ^. scenarioLandscape . scenarioTerrainAndEntities . entityMap) <> emap
  landscape = scenario ^. scenarioLandscape
  baseRobot = baseRobotTemplate landscape
  devs = maybe Set.empty robotStartingDevices baseRobot
  inv = maybe Set.empty (Map.keysSet . robotStartingInventory) baseRobot
  ents = landscapeEntities landscape

-- | Get the set of all entities which can be harvested from the world.
landscapeEntities :: ScenarioLandscape -> Set Entity
landscapeEntities = foldMap harvestable . view scenarioWorlds
 where
  harvestable :: WorldDescription -> Set Entity
  harvestable wd = Set.fromList (static wd) <> dynamic wd
   where
    static :: WorldDescription -> [Entity]
    static =
      filter (Set.member Pickable . view entityProperties)
        . mapMaybe (erasableToMaybe . cellEntity)
        . catMaybes
        . allMembers
        . gridContent
        . area

    dynamic :: WorldDescription -> Set Entity
    dynamic = maybe Set.empty extractEntities . worldProg

-------------------------------------------------------------------------------
-- RECIPE LEVELS
-------------------------------------------------------------------------------

-- | Order entities in sets depending on how soon it is possible to obtain them.
--
-- So:
--  * Level 0 - starting entities (for example those obtainable in the world)
--  * Level N+1 - everything possible to make (or drill or harvest) from Level N
--
-- This is almost a BFS, but the requirement is that the set of entities
-- required for recipe is subset of the entities known in Level N.
--
-- If we ever depend on some graph library, this could be rewritten
-- as some BFS-like algorithm with added recipe nodes, but you would
-- need to enforce the condition that recipes need ALL incoming edges.
recipeLevels :: EntityMap -> [Recipe Entity] -> Set Entity -> [Set Entity]
recipeLevels emap recipeList start = levs
 where
  recipeParts r = ((r ^. recipeInputs) <> (r ^. recipeCatalysts), r ^. recipeOutputs)
  m :: [(Set Entity, Set Entity)]
  m = map (both (Set.fromList . map snd) . recipeParts) recipeList
  levs :: [Set Entity]
  levs = reverse $ go [start] start
   where
    isKnown known (i, _o) = null $ i Set.\\ known
    lookupYield e = case view entityYields e of
      Nothing -> e
      Just yn -> fromMaybe e (E.lookupEntityName yn emap)
    yielded = Set.map lookupYield
    nextLevel known = Set.unions $ yielded known : map snd (filter (isKnown known) m)
    go ls known =
      let n = nextLevel known Set.\\ known
       in if null n
            then ls
            else go (n : ls) (Set.union n known)

robotStartingDevices :: TRobot -> Set Entity
robotStartingDevices = Set.fromList . map snd . E.elems . view tequippedDevices

robotStartingInventory :: TRobot -> Map Entity Int
robotStartingInventory = Map.fromList . map swap . E.elems . view trobotInventory