diff --git a/app/doc/Swarm/Doc/Schema/Render.hs b/app/doc/Swarm/Doc/Schema/Render.hs index be620e3ec9..678ee59fb8 100644 --- a/app/doc/Swarm/Doc/Schema/Render.hs +++ b/app/doc/Swarm/Doc/Schema/Render.hs @@ -25,7 +25,6 @@ import Swarm.Doc.Schema.Arrangement import Swarm.Doc.Schema.Parse import Swarm.Doc.Schema.Refined import Swarm.Doc.Schema.SchemaType -import Swarm.Doc.Util import Swarm.Doc.Wiki.Util import Swarm.Util (applyWhen, brackets, quote, showT) import System.Directory (listDirectory) @@ -77,7 +76,7 @@ makePandocTable titleMap (SchemaData _ (ToplevelSchema theTitle theDescription _ ItemList xs -> makePropsTable False listColumnHeadings titleMap . M.fromList - $ zip (map tshow [0 :: Int ..]) xs + $ zip (map showT [0 :: Int ..]) xs mkTable x = doc $ case x of ObjectProperties props -> makePropsTable True propertyColumnHeadings titleMap props diff --git a/app/doc/Swarm/Doc/Wiki/Cheatsheet.hs b/app/doc/Swarm/Doc/Wiki/Cheatsheet.hs index 363a8c0545..4033fd7a5e 100644 --- a/app/doc/Swarm/Doc/Wiki/Cheatsheet.hs +++ b/app/doc/Swarm/Doc/Wiki/Cheatsheet.hs @@ -18,8 +18,8 @@ import Control.Lens.Combinators (to) import Data.Foldable (find, toList) import Data.List (transpose) import Data.Map.Lazy qualified as Map -import Data.Maybe (fromMaybe, isJust) -import Data.Set qualified as Set +import Data.Maybe (isJust) +import Data.Set qualified as S import Data.Text (Text) import Data.Text qualified as T import Data.Text.IO qualified as T @@ -27,6 +27,7 @@ import Swarm.Doc.Schema.Render import Swarm.Doc.Util import Swarm.Doc.Wiki.Matrix import Swarm.Doc.Wiki.Util +import Swarm.Game.Device qualified as D import Swarm.Game.Display (displayChar) import Swarm.Game.Entity (Entity, EntityMap (entitiesByName), entityDisplay, entityName, loadEntities) import Swarm.Game.Entity qualified as E @@ -110,7 +111,7 @@ commandToList :: Const -> [Text] commandToList c = map escapeTable - [ addLink ("#" <> tshow c) . codeQuote $ constSyntax c + [ addLink ("#" <> showT c) . codeQuote $ constSyntax c , codeQuote . prettyTextLine $ inferConst c , maybe "" Capability.capabilityName $ Capability.constCaps c , Syntax.briefDoc . Syntax.constDoc $ Syntax.constInfo c @@ -172,13 +173,13 @@ capabilityRow PageAddress {..} em cap = linkCommand c = ( if T.null commandsAddress then id - else addLink (commandsAddress <> "#" <> tshow c) + else addLink (commandsAddress <> "#" <> showT c) ) . codeQuote $ constSyntax c cs = [c | c <- Syntax.allConst, let mcap = Capability.constCaps c, isJust $ find (== cap) mcap] - es = fromMaybe [] $ E.entitiesByCap em Map.!? cap + es = E.devicesForCap cap em capabilityTable :: PageAddress -> EntityMap -> [Capability] -> Text capabilityTable a em cs = T.unlines $ header <> map (listToRow mw) capabilityRows @@ -201,8 +202,8 @@ entityToList e = escapeTable [ codeQuote . T.singleton $ e ^. entityDisplay . to displayChar , addLink ("#" <> linkID) $ view entityName e - , T.intercalate ", " $ Capability.capabilityName <$> Set.toList (view E.entityCapabilities e) - , T.intercalate ", " . map tshow . filter (/= E.Pickable) $ toList props + , T.intercalate ", " $ Capability.capabilityName <$> Map.keys (D.getMap $ view E.entityCapabilities e) + , T.intercalate ", " . map showT . filter (/= E.Pickable) $ toList props , if E.Pickable `elem` props then ":heavy_check_mark:" else ":negative_squared_cross_mark:" @@ -225,13 +226,13 @@ entityToSection e = , "" , " - Char: " <> (codeQuote . T.singleton $ e ^. entityDisplay . to displayChar) ] - <> [" - Properties: " <> T.intercalate ", " (map tshow $ toList props) | not $ null props] + <> [" - Properties: " <> T.intercalate ", " (map showT $ toList props) | not $ null props] <> [" - Capabilities: " <> T.intercalate ", " (Capability.capabilityName <$> caps) | not $ null caps] <> ["\n"] <> [Markdown.docToMark $ view E.entityDescription e] where props = view E.entityProperties e - caps = Set.toList $ view E.entityCapabilities e + caps = S.toList $ D.getCapabilitySet $ view E.entityCapabilities e entitiesPage :: PageAddress -> [Entity] -> Text entitiesPage _a es = @@ -255,11 +256,11 @@ recipeRow PageAddress {..} r = [ T.intercalate ", " (map formatCE $ view recipeInputs r) , T.intercalate ", " (map formatCE $ view recipeOutputs r) , T.intercalate ", " (map formatCE $ view recipeCatalysts r) - , tshow $ view recipeTime r - , tshow $ view recipeWeight r + , showT $ view recipeTime r + , showT $ view recipeWeight r ] where - formatCE (c, e) = T.unwords [tshow c, linkEntity $ view entityName e] + formatCE (c, e) = T.unwords [showT c, linkEntity $ view entityName e] linkEntity t = if T.null entityAddress then t diff --git a/data/scenarios/Testing/00-ORDER.txt b/data/scenarios/Testing/00-ORDER.txt index 26bf083141..6b733d3578 100644 --- a/data/scenarios/Testing/00-ORDER.txt +++ b/data/scenarios/Testing/00-ORDER.txt @@ -55,4 +55,5 @@ Achievements 1634-message-colors.yaml 1681-pushable-entity.yaml 1747-volume-command.yaml +1777-capability-cost.yaml 1775-custom-terrain.yaml diff --git a/data/scenarios/Testing/1777-capability-cost.yaml b/data/scenarios/Testing/1777-capability-cost.yaml new file mode 100644 index 0000000000..2280fa3ff8 --- /dev/null +++ b/data/scenarios/Testing/1777-capability-cost.yaml @@ -0,0 +1,86 @@ +version: 1 +name: Capability cost +description: | + Consume inventory by exercising device capabilities +creative: false +seed: 0 +objectives: + - goal: + - | + Eliminate the `packing peanut`{=entity}s + condition: | + judge <- robotnamed "judge"; + as judge { + dist <- sniff "packing peanut"; + return $ dist < 0; + } +solution: | + move; + turn right; + move; + place "packing peanut"; + ignite down; + move; + move; + ignite forward; +robots: + - name: base + dir: east + devices: + - treads + - logger + - Zippo + - grabber + inventory: + - [2, lighter fluid] + - [1, packing peanut] + - name: judge + dir: east + system: true +entities: + - name: lighter fluid + display: + char: 'f' + description: + - Fuel for a Zippo + properties: [known, pickable] + - name: Zippo + display: + char: 'z' + description: + - Ignites things + properties: [known, pickable] + capabilities: + - capability: ignite + cost: + - [1, "lighter fluid"] + - name: packing peanut + display: + attr: snow + char: 's' + description: + - Easy to drop, but impossible to pick up. + - Highly combustible. + properties: [known, combustible] + combustion: + ignition: 0.5 + duration: [10, 20] + product: ash +known: [water, ash] +world: + dsl: | + {water} + palette: + 'B': [grass, erase, base] + 'j': [grass, erase, judge] + '.': [grass, erase] + 'c': [grass, packing peanut] + upperleft: [-1, 1] + map: | + ...... + Bcccc. + .j.... + .cccc. + ...... + .cccc. + ...... diff --git a/data/scenarios/Testing/_Validation/1777-capability-cost-bad-entity-reference.yaml b/data/scenarios/Testing/_Validation/1777-capability-cost-bad-entity-reference.yaml new file mode 100644 index 0000000000..8e90fbeaaf --- /dev/null +++ b/data/scenarios/Testing/_Validation/1777-capability-cost-bad-entity-reference.yaml @@ -0,0 +1,39 @@ +version: 1 +name: Capability cost - bad entity reference +description: | + Capability cost recipe for 'ignite' in `Zippo`{=entity} + references a non-existent entity +creative: false +robots: + - name: base + dir: east + devices: + - Zippo +entities: + - name: heavier fluid + display: + char: 'f' + description: + - Fuel for a Zippo + properties: [known, pickable] + - name: Zippo + display: + char: 'z' + description: + - Ignites things + properties: [known, pickable] + capabilities: + - capability: ignite + cost: + - [1, "lighter fluid"] +known: [] +world: + dsl: | + {grass} + palette: + 'B': [grass, null, base] + '.': [grass] + upperleft: [-1, 1] + map: | + .. + B. diff --git a/data/schema/entity.json b/data/schema/entity.json index 9c8ced9a4c..c709de4a74 100644 --- a/data/schema/entity.json +++ b/data/schema/entity.json @@ -97,7 +97,25 @@ "default": [], "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "capability": { + "description": "Capability name", + "type": "string" + }, + "cost": { + "$ref": "inventory.json", + "description": "A list of ingredients consumed by the command." + } + } + } + ] }, "description": "A list of capabilities provided by entity, when it is equipped as a device. See [Capabilities](https://github.com/swarm-game/swarm/wiki/Capabilities-cheat-sheet)." } diff --git a/src/Swarm/Doc/Util.hs b/src/Swarm/Doc/Util.hs index f4714df051..95a9885eaa 100644 --- a/src/Swarm/Doc/Util.hs +++ b/src/Swarm/Doc/Util.hs @@ -30,9 +30,6 @@ codeQuote = wrap '`' addLink :: Text -> Text -> Text addLink l t = T.concat ["[", t, "](", l, ")"] -tshow :: (Show a) => a -> Text -tshow = T.pack . show - -- * Common symbols operators :: [Const] diff --git a/src/Swarm/TUI/Controller.hs b/src/Swarm/TUI/Controller.hs index 1638a23b9c..671fc2fec9 100644 --- a/src/Swarm/TUI/Controller.hs +++ b/src/Swarm/TUI/Controller.hs @@ -90,7 +90,7 @@ import Swarm.Game.State.Robot import Swarm.Game.State.Runtime import Swarm.Game.State.Substate import Swarm.Game.Step (finishGameTick, gameTick) -import Swarm.Language.Capability (Capability (CDebug, CGod, CMake), constCaps) +import Swarm.Language.Capability (Capability (CGod, CMake), constCaps) import Swarm.Language.Context import Swarm.Language.Key (KeyCombo, mkKeyCombo) import Swarm.Language.Module @@ -308,7 +308,7 @@ handleMainEvent ev = do let isRunning = maybe True isRunningModal mt let isPaused = s ^. gameState . temporal . paused let isCreative = s ^. gameState . creativeMode - let hasDebug = fromMaybe isCreative $ s ^? gameState . to focusedRobot . _Just . robotCapabilities . Lens.contains CDebug + let hasDebug = hasDebugCapability isCreative s case ev of AppEvent ae -> case ae of Frame diff --git a/src/Swarm/TUI/Controller/Util.hs b/src/Swarm/TUI/Controller/Util.hs index 88950aac0e..5683f4d9a3 100644 --- a/src/Swarm/TUI/Controller/Util.hs +++ b/src/Swarm/TUI/Controller/Util.hs @@ -10,13 +10,17 @@ import Control.Lens import Control.Monad (forM_, unless) import Control.Monad.IO.Class (liftIO) import Data.Map qualified as M +import Data.Set qualified as S import Graphics.Vty qualified as V +import Swarm.Game.Device +import Swarm.Game.Robot (robotCapabilities) import Swarm.Game.State import Swarm.Game.State.Landscape import Swarm.Game.State.Robot import Swarm.Game.State.Substate import Swarm.Game.Universe import Swarm.Game.World qualified as W +import Swarm.Language.Capability (Capability (CDebug)) import Swarm.TUI.Model import Swarm.TUI.Model.UI import Swarm.TUI.View.Util (generateModal) @@ -97,3 +101,8 @@ mouseLocToWorldCoords (Brick.Location mouseLoc) = do mx = snd mouseLoc' + fst regionStart my = fst mouseLoc' + snd regionStart in pure . Just $ Cosmic (region ^. subworld) $ W.Coords (mx, my) + +hasDebugCapability :: Bool -> AppState -> Bool +hasDebugCapability isCreative s = + maybe isCreative (S.member CDebug . getCapabilitySet) $ + s ^? gameState . to focusedRobot . _Just . robotCapabilities diff --git a/src/Swarm/TUI/Model.hs b/src/Swarm/TUI/Model.hs index 16e24a79c5..ddb4039e67 100644 --- a/src/Swarm/TUI/Model.hs +++ b/src/Swarm/TUI/Model.hs @@ -117,6 +117,7 @@ import GitHash (GitInfo) import Graphics.Vty (ColorMode (..)) import Network.Wai.Handler.Warp (Port) import Swarm.Game.Entity as E +import Swarm.Game.Ingredients import Swarm.Game.Robot import Swarm.Game.Robot.Concrete import Swarm.Game.Robot.Context diff --git a/src/Swarm/TUI/Model/Menu.hs b/src/Swarm/TUI/Model/Menu.hs index 93e1d9e1a3..8c7e40ec38 100644 --- a/src/Swarm/TUI/Model/Menu.hs +++ b/src/Swarm/TUI/Model/Menu.hs @@ -21,6 +21,7 @@ import Data.Text (Text) import Data.Vector qualified as V import Swarm.Game.Achievement.Definitions import Swarm.Game.Entity as E +import Swarm.Game.Ingredients import Swarm.Game.ScenarioInfo ( ScenarioCollection, ScenarioInfo (..), diff --git a/src/Swarm/TUI/View.hs b/src/Swarm/TUI/View.hs index b7271c69e1..927025534a 100644 --- a/src/Swarm/TUI/View.hs +++ b/src/Swarm/TUI/View.hs @@ -73,8 +73,10 @@ import Network.Wai.Handler.Warp (Port) import Numeric (showFFloat) import Swarm.Constant import Swarm.Game.CESK (CESK (..)) +import Swarm.Game.Device (getMap) import Swarm.Game.Display import Swarm.Game.Entity as E +import Swarm.Game.Ingredients import Swarm.Game.Land import Swarm.Game.Location import Swarm.Game.Recipe @@ -122,6 +124,7 @@ import Swarm.Language.Typecheck (inferConst) import Swarm.Log import Swarm.TUI.Border import Swarm.TUI.Controller (ticksPerFrameCap) +import Swarm.TUI.Controller.Util (hasDebugCapability) import Swarm.TUI.Editor.Model import Swarm.TUI.Editor.View qualified as EV import Swarm.TUI.Inventory.Sorting (renderSortMethod) @@ -1003,7 +1006,7 @@ drawKeyMenu s = isReplWorking = s ^. gameState . gameControls . replWorking isPaused = s ^. gameState . temporal . paused - hasDebug = fromMaybe creative $ s ^? gameState . to focusedRobot . _Just . robotCapabilities . Lens.contains CDebug + hasDebug = hasDebugCapability creative s viewingBase = (s ^. gameState . robotInfo . viewCenterRule) == VCRobot 0 creative = s ^. gameState . creativeMode cheat = s ^. uiState . uiCheatMode @@ -1209,8 +1212,8 @@ explainEntry s e = , drawMarkdown (e ^. entityDescription) , explainRecipes s e ] - <> [drawRobotMachine s False | e ^. entityCapabilities . Lens.contains CDebug] - <> [drawRobotLog s | e ^. entityCapabilities . Lens.contains CLog] + <> [drawRobotMachine s False | CDebug `M.member` getMap (e ^. entityCapabilities)] + <> [drawRobotLog s | CLog `M.member` getMap (e ^. entityCapabilities)] displayProperties :: [EntityProperty] -> Widget Name displayProperties = displayList . mapMaybe showProperty @@ -1359,7 +1362,7 @@ drawRecipe me inv (Recipe ins outs reqs time _weight) = -- | Ad-hoc entity to represent time - only used in recipe drawing timeE :: Entity -timeE = mkEntity (defaultEntityDisplay '.') "ticks" mempty [] [] +timeE = mkEntity (defaultEntityDisplay '.') "ticks" mempty [] mempty drawReqs :: IngredientList Entity -> Widget Name drawReqs = vBox . map (hCenter . drawReq) diff --git a/src/swarm-engine/Swarm/Game/CESK.hs b/src/swarm-engine/Swarm/Game/CESK.hs index 3ee7d34012..8dedcffb65 100644 --- a/src/swarm-engine/Swarm/Game/CESK.hs +++ b/src/swarm-engine/Swarm/Game/CESK.hs @@ -86,8 +86,9 @@ import Data.IntMap.Strict (IntMap) import Data.IntMap.Strict qualified as IM import GHC.Generics (Generic) import Prettyprinter (Doc, Pretty (..), encloseSep, hsep, (<+>)) -import Swarm.Game.Entity (Count, Entity) +import Swarm.Game.Entity (Entity) import Swarm.Game.Exception +import Swarm.Game.Ingredients (Count) import Swarm.Game.Tick import Swarm.Game.World (WorldUpdate (..)) import Swarm.Language.Context diff --git a/src/swarm-engine/Swarm/Game/Exception.hs b/src/swarm-engine/Swarm/Game/Exception.hs index f1b9571172..7910f91a5c 100644 --- a/src/swarm-engine/Swarm/Game/Exception.hs +++ b/src/swarm-engine/Swarm/Game/Exception.hs @@ -9,6 +9,7 @@ module Swarm.Game.Exception ( Exn (..), IncapableFix (..), formatExn, + IncapableFixWords (..), -- * Helper functions formatIncapable, @@ -25,7 +26,7 @@ import Data.Text qualified as T import GHC.Generics (Generic) import Swarm.Constant import Swarm.Game.Achievement.Definitions -import Swarm.Game.Entity (EntityMap, deviceForCap, entityName) +import Swarm.Game.Entity (EntityMap, devicesForCap, entityName) import Swarm.Language.Capability (Capability (CGod), capabilityName) import Swarm.Language.Pretty (prettyText) import Swarm.Language.Requirement (Requirements (..)) @@ -54,7 +55,9 @@ data IncapableFix = -- | 'Swarm.Language.Syntax.Equip' the missing device on yourself/target FixByEquip | -- | Add the missing device to your inventory - FixByObtain + FixByObtainDevice + | -- | Add the missing consumables to your inventory + FixByObtainConsumables deriving (Eq, Show, Generic, FromJSON, ToJSON) -- | The type of exceptions that can be thrown by robot programs. @@ -99,11 +102,17 @@ formatExn em = \case -- INCAPABLE HELPERS -- ------------------------------------------------------------------ +data IncapableFixWords = IncapableFixWords + { fixVerb :: Text + , fixNoun :: Text + } + -- | Pretty-print an 'IncapableFix': either "equip" or "obtain". -formatIncapableFix :: IncapableFix -> Text +formatIncapableFix :: IncapableFix -> IncapableFixWords formatIncapableFix = \case - FixByEquip -> "equip" - FixByObtain -> "obtain" + FixByEquip -> IncapableFixWords "equip" "device" + FixByObtainDevice -> IncapableFixWords "obtain" "device" + FixByObtainConsumables -> IncapableFixWords "obtain" "consumables" -- | Pretty print the incapable exception with an actionable suggestion -- on how to fix it. @@ -156,12 +165,13 @@ formatIncapable em f (Requirements caps _ inv) tm , swarmRepoUrl <> "issues/26" ] | not (S.null caps) = - unlinesExText - ( "You do not have the devices required for:" - :| squote (prettyText tm) - : "Please " <> formatIncapableFix f <> ":" - : (("- " <>) . formatDevices <$> filter (not . null) deviceSets) - ) + let IncapableFixWords fVerb fNoun = formatIncapableFix f + in unlinesExText + ( T.unwords ["You do not have the", fNoun, "required for:"] + :| squote (prettyText tm) + : "Please " <> fVerb <> ":" + : (("- " <>) . formatDevices <$> filter (not . null) deviceSets) + ) | otherwise = unlinesExText ( "You are missing required inventory for:" @@ -171,7 +181,7 @@ formatIncapable em f (Requirements caps _ inv) tm ) where capList = S.toList caps - deviceSets = map (`deviceForCap` em) capList + deviceSets = map (`devicesForCap` em) capList devicePerCap = zip capList deviceSets -- capabilities not provided by any device capsNone = map (capabilityName . fst) $ filter (null . snd) devicePerCap diff --git a/src/swarm-engine/Swarm/Game/State.hs b/src/swarm-engine/Swarm/Game/State.hs index 0adb59ee22..0ac126c17c 100644 --- a/src/swarm-engine/Swarm/Game/State.hs +++ b/src/swarm-engine/Swarm/Game/State.hs @@ -99,6 +99,7 @@ import Data.Text.Lazy qualified as TL import Data.Text.Lazy.Encoding qualified as TL import Linear (V2 (..)) import Swarm.Game.CESK (emptyStore, finalValue, initMachine) +import Swarm.Game.Device (getCapabilitySet, getMap) import Swarm.Game.Entity import Swarm.Game.Failure (SystemFailure (..)) import Swarm.Game.Land @@ -596,7 +597,7 @@ pureScenarioToGameState scenario theSeed now toRun gsc = TerrainEntityMaps _ em = sLandscape ^. scenarioTerrainAndEntities baseID = 0 - (things, devices) = partition (null . view entityCapabilities) (M.elems (entitiesByName em)) + (things, devices) = partition (M.null . getMap . view entityCapabilities) (M.elems (entitiesByName em)) getCodeToRun (CodeToRun _ s) = s @@ -641,7 +642,7 @@ pureScenarioToGameState scenario theSeed now toRun gsc = allCapabilities r = inventoryCapabilities (r ^. equippedDevices) <> inventoryCapabilities (r ^. robotInventory) - initialCaps = mconcat $ map allCapabilities robotList + initialCaps = getCapabilitySet $ mconcat $ map allCapabilities robotList initialCommands = filter (maybe True (`S.member` initialCaps) . constCaps) diff --git a/src/swarm-engine/Swarm/Game/Step.hs b/src/swarm-engine/Swarm/Game/Step.hs index ebd1050b68..df78b20a0f 100644 --- a/src/swarm-engine/Swarm/Game/Step.hs +++ b/src/swarm-engine/Swarm/Game/Step.hs @@ -158,7 +158,13 @@ insertBackRobot rn rob = do unless (isActive rob) (sleepForever rn) -- Run a set of robots - this is used to run robots before/after the focused one. -runRobotIDs :: (Has (State GameState) sig m, Has (Lift IO) sig m, Has Effect.Time sig m) => IS.IntSet -> m () +runRobotIDs :: + ( Has (State GameState) sig m + , Has (Lift IO) sig m + , Has Effect.Time sig m + ) => + IS.IntSet -> + m () runRobotIDs robotNames = forM_ (IS.toList robotNames) $ \rn -> do mr <- uses (robotInfo . robotMap) (IM.lookup rn) forM_ mr (stepOneRobot rn) @@ -166,7 +172,15 @@ runRobotIDs robotNames = forM_ (IS.toList robotNames) $ \rn -> do stepOneRobot rn rob = tickRobot rob >>= insertBackRobot rn -- This is a helper function to do one robot step or run robots before/after. -singleStep :: (Has (State GameState) sig m, Has (Lift IO) sig m, Has Effect.Time sig m) => SingleStep -> RID -> IS.IntSet -> m Bool +singleStep :: + ( Has (State GameState) sig m + , Has (Lift IO) sig m + , Has Effect.Time sig m + ) => + SingleStep -> + RID -> + IS.IntSet -> + m Bool singleStep ss focRID robotSet = do let (preFoc, focusedActive, postFoc) = IS.splitMember focRID robotSet case ss of @@ -626,7 +640,7 @@ stepCESK cesk = case cesk of devicesForCaps, requiredDevices :: Set (Set Text) -- possible devices to provide each required capability - devicesForCaps = S.map (S.fromList . map (^. entityName) . (`deviceForCap` em)) caps + devicesForCaps = S.map (S.fromList . map (^. entityName) . (`devicesForCap` em)) caps -- outright required devices requiredDevices = S.map S.singleton devs diff --git a/src/swarm-engine/Swarm/Game/Step/Combustion.hs b/src/swarm-engine/Swarm/Game/Step/Combustion.hs index 2354a3eae6..d3daac79aa 100644 --- a/src/swarm-engine/Swarm/Game/Step/Combustion.hs +++ b/src/swarm-engine/Swarm/Game/Step/Combustion.hs @@ -21,6 +21,7 @@ import Control.Carrier.State.Lazy import Control.Effect.Lens import Control.Lens as Lens hiding (Const, distrib, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=)) import Control.Monad (forM_, when) +import Data.Maybe (fromMaybe) import Data.Text qualified as T import Linear (zero) import Swarm.Effect as Effect (Time, getNow) @@ -89,11 +90,10 @@ addCombustionBot :: Cosmic Location -> m Integer addCombustionBot inputEntity combustibility ts loc = do - botInventory <- case maybeCombustionProduct of - Nothing -> return [] - Just n -> do - maybeE <- uses (landscape . terrainAndEntities . entityMap) (lookupEntityName n) - return $ maybe [] (pure . (1,)) maybeE + em <- use $ landscape . terrainAndEntities . entityMap + let botInventory = fromMaybe [] $ do + e <- (`lookupEntityName` em) =<< maybeCombustionProduct + return $ pure (1, e) combustionDurationRand <- uniform durationRange let combustionProg = combustionProgram combustionDurationRand combustibility zoomRobots diff --git a/src/swarm-engine/Swarm/Game/Step/Const.hs b/src/swarm-engine/Swarm/Game/Step/Const.hs index 7a76564a77..4f8e3e9d97 100644 --- a/src/swarm-engine/Swarm/Game/Step/Const.hs +++ b/src/swarm-engine/Swarm/Game/Step/Const.hs @@ -1031,7 +1031,7 @@ execConst runChildProg c vs s k = do (childRobot ^. equippedDevices) cmd "The target robot" - FixByObtain + FixByObtainDevice -- update other robot's CESK machine, environment and context -- the childRobot inherits the parent robot's environment @@ -1078,7 +1078,7 @@ execConst runChildProg c vs s k = do pid <- use robotID (toEquip, toGive) <- - checkRequirements (r ^. robotInventory) E.empty E.empty cmd "You" FixByObtain + checkRequirements (r ^. robotInventory) E.empty E.empty cmd "You" FixByObtainDevice -- Pick a random display name. displayName <- randomName @@ -1498,7 +1498,7 @@ execConst runChildProg c vs s k = do -- help with later error message generation. possibleDevices :: [(Maybe Capability, [Entity])] possibleDevices = - map (Just &&& (`deviceForCap` em)) caps -- Possible devices for capabilities + map (Just &&& (`devicesForCap` em)) caps -- Possible devices for capabilities ++ map ((Nothing,) . (: [])) devs -- Outright required devices -- A device is OK if it is available in the inventory of the @@ -1549,10 +1549,11 @@ execConst runChildProg c vs s k = do -- Now, ensure there is at least one device available to be -- equipped for each requirement. let missingDevices = map snd . filter (null . fst) $ partitionedDevices + let IncapableFixWords fVerb fNoun = formatIncapableFix fixI null missingDevices `holdsOrFail` ( singularSubjectVerb subject "do" - : "not have required devices, please" - : formatIncapableFix fixI <> ":" + : "not have required " <> fNoun <> ", please" + : fVerb <> ":" : (("\n - " <>) . formatDevices <$> missingDevices) ) diff --git a/src/swarm-engine/Swarm/Game/Step/Util.hs b/src/swarm-engine/Swarm/Game/Step/Util.hs index eef6ec3346..6e93327ef4 100644 --- a/src/swarm-engine/Swarm/Game/Step/Util.hs +++ b/src/swarm-engine/Swarm/Game/Step/Util.hs @@ -22,6 +22,7 @@ import Data.Set qualified as S import Data.Text (Text) import Data.Text qualified as T import Linear (zero) +import Swarm.Game.Device import Swarm.Game.Entity hiding (empty, lookup, singleton, union) import Swarm.Game.Exception import Swarm.Game.Location @@ -91,7 +92,7 @@ hasCapability :: (Has (State Robot) sig m, Has (State GameState) sig m) => Capab hasCapability cap = do isPrivileged <- isPrivilegedBot caps <- use robotCapabilities - return (isPrivileged || cap `S.member` caps) + return (isPrivileged || cap `S.member` getCapabilitySet caps) -- | Ensure that either a robot has a given capability, OR we are in creative -- mode. diff --git a/src/swarm-engine/Swarm/Game/Step/Util/Command.hs b/src/swarm-engine/Swarm/Game/Step/Util/Command.hs index 7b62dbdc23..c7bb5b16da 100644 --- a/src/swarm-engine/Swarm/Game/Step/Util/Command.hs +++ b/src/swarm-engine/Swarm/Game/Step/Util/Command.hs @@ -20,6 +20,8 @@ import Control.Effect.Lens import Control.Effect.Lift import Control.Lens as Lens hiding (Const, distrib, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=)) import Control.Monad (forM_, unless, when) +import Data.List (find) +import Data.List.NonEmpty qualified as NE import Data.Map qualified as M import Data.Sequence qualified as Seq import Data.Set (Set) @@ -27,15 +29,18 @@ import Data.Set qualified as S import Data.Text (Text) import Data.Text qualified as T import Data.Time (getZonedTime) +import Data.Tuple (swap) import Linear (zero) import Swarm.Game.Achievement.Attainment import Swarm.Game.Achievement.Definitions import Swarm.Game.Achievement.Description (getValidityRequirements) import Swarm.Game.CESK +import Swarm.Game.Device import Swarm.Game.Display import Swarm.Game.Entity hiding (empty, lookup, singleton, union) import Swarm.Game.Entity qualified as E import Swarm.Game.Exception +import Swarm.Game.Land import Swarm.Game.Location import Swarm.Game.Recipe import Swarm.Game.Robot @@ -57,25 +62,88 @@ import Swarm.Language.Requirement qualified as R import Swarm.Language.Syntax import Swarm.Language.Text.Markdown qualified as Markdown import Swarm.Log -import Swarm.Util hiding (both) import System.Clock (TimeSpec) import Prelude hiding (Applicative (..), lookup) -data GrabbingCmd = Grab' | Harvest' | Swap' | Push' deriving (Eq, Show) +data GrabbingCmd + = Grab' + | Harvest' + | Swap' + | Push' + deriving (Eq, Show) -- | Ensure that a robot is capable of executing a certain constant -- (either because it has a device which gives it that capability, -- or it is a system robot, or we are in creative mode). -ensureCanExecute :: (Has (State Robot) sig m, Has (State GameState) sig m, Has (Throw Exn) sig m) => Const -> m () +-- +-- For certain capabilities that require payment of inventory +-- items in order to be exercised, we pay the toll up front, regardless of +-- other conditions that may preclude the capability from eventually +-- being exercised (e.g. an obstacle that ultimately prevents a "move"). +-- +-- Note that there exist some code paths where the "toll" +-- is bypassed, e.g. see 'hasCapabilityFor'. +-- We should just try to avoid authoring scenarios that +-- include toll-gated devices for those particular capabilities. +-- +-- Since this function has the side-effect of removing items from the +-- robot's inventory, we must be careful that it is executed exactly +-- once per command. +ensureCanExecute :: + ( Has (State Robot) sig m + , Has (State GameState) sig m + , Has (Throw Exn) sig m + ) => + Const -> + m () ensureCanExecute c = gets @Robot (constCapsFor c) >>= \case Nothing -> pure () Just cap -> do isPrivileged <- isPrivilegedBot - robotCaps <- use robotCapabilities - let hasCaps = cap `S.member` robotCaps - (isPrivileged || hasCaps) - `holdsOr` Incapable FixByEquip (R.singletonCap cap) (TConst c) + -- Privileged robots can execute commands regardless + -- of equipped devices, and without expending + -- a capability's exercise cost. + unless isPrivileged $ do + robotCaps <- use robotCapabilities + let capProviders = M.lookup cap $ getMap robotCaps + case capProviders of + Nothing -> throwError $ Incapable FixByEquip (R.singletonCap cap) (TConst c) + Just rawCosts -> payExerciseCost c rawCosts + +payExerciseCost :: + ( Has (State Robot) sig m + , Has (State GameState) sig m + , Has (Throw Exn) sig m + ) => + Const -> + NE.NonEmpty (DeviceUseCost Entity EntityName) -> + m () +payExerciseCost c rawCosts = do + em <- use $ landscape . terrainAndEntities . entityMap + let eitherCosts = mapM (promoteDeviceUseCost $ lookupEntityE $ entitiesByName em) rawCosts + costs <- case eitherCosts of + -- NOTE: Entity references have been validated already at scenario load time, + -- so we should never encounter this error. + Left e -> throwError $ Fatal e + Right cs -> return cs + inv <- use robotInventory + let getMissingIngredients = findLacking inv . ingredients . useCost + maybeFeasibleRecipe = find (null . getMissingIngredients) $ NE.sort costs + case maybeFeasibleRecipe of + Nothing -> + throwError $ + Incapable FixByObtainConsumables (expenseToRequirement $ NE.head costs) (TConst c) + -- Consume the inventory + Just feasibleRecipe -> + forM_ (ingredients . useCost $ feasibleRecipe) $ \(cnt, e) -> + robotInventory %= deleteCount cnt e + where + expenseToRequirement :: DeviceUseCost Entity Entity -> R.Requirements + expenseToRequirement (DeviceUseCost d (ExerciseCost ingdts)) = + R.Requirements S.empty (S.singleton $ d ^. entityName) ingdtsMap + where + ingdtsMap = M.fromListWith (+) $ map (swap . fmap (view entityName)) ingdts -- | Clear watches that are out of range purgeFarAwayWatches :: @@ -247,9 +315,9 @@ updateAvailableRecipes invs e = do updateAvailableCommands :: Has (State GameState) sig m => Entity -> m () updateAvailableCommands e = do - let newCaps = e ^. entityCapabilities + let newCaps = getMap $ e ^. entityCapabilities keepConsts = \case - Just cap -> cap `S.member` newCaps + Just cap -> cap `M.member` newCaps Nothing -> False entityConsts = filter (keepConsts . constCaps) allConst knownCommands <- use $ discovery . availableCommands . notificationsContent diff --git a/src/swarm-scenario/Swarm/Game/Device.hs b/src/swarm-scenario/Swarm/Game/Device.hs new file mode 100644 index 0000000000..a3770ba8f5 --- /dev/null +++ b/src/swarm-scenario/Swarm/Game/Device.hs @@ -0,0 +1,107 @@ +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- A device is an entity that provides capabilities. +-- +-- Some capabilities have a cost to exercise. +-- Items will be consumed from the inventory for +-- invoking a command that utilizes a given capability. +module Swarm.Game.Device ( + SingleEntityCapabilities, + MultiEntityCapabilities, + Capabilities (..), + DeviceUseCost (..), + ExerciseCost (..), + getCapabilitySet, + zeroCostCapabilities, + transformIngredients, + promoteDeviceUseCost, +) +where + +import Control.Applicative ((<|>)) +import Data.Function (on) +import Data.Hashable +import Data.List.NonEmpty (NonEmpty (..)) +import Data.Map (Map) +import Data.Map qualified as M +import Data.Set (Set) +import Data.Vector qualified as V +import Data.Yaml +import GHC.Generics (Generic) +import Swarm.Game.Ingredients +import Swarm.Language.Capability (Capability) + +-- This wrapper exists so that YAML can be parsed +-- either as a list of 'Capability' or as a Map. +newtype Capabilities e = Capabilities + { getMap :: Map Capability e + } + deriving (Show, Eq, Generic, ToJSON, Hashable, Functor) + +getCapabilitySet :: Capabilities e -> Set Capability +getCapabilitySet (Capabilities m) = M.keysSet m + +zeroCostCapabilities :: Set Capability -> Capabilities (ExerciseCost e) +zeroCostCapabilities = Capabilities . M.fromSet (const $ ExerciseCost []) + +type SingleEntityCapabilities e = Capabilities (ExerciseCost e) + +type MultiEntityCapabilities e en = Capabilities (NonEmpty (DeviceUseCost e en)) + +-- | For JSON parsing only +data CapabilityCost e = CapabilityCost + { capability :: Capability + , cost :: IngredientList e + } + deriving (Generic, FromJSON) + +-- | First, attempt to parse capabilities as a list. +-- Otherwise, parse as a Map from capabilities to ingredients. +instance (FromJSON e) => FromJSON (SingleEntityCapabilities e) where + parseJSON x = + Capabilities <$> (simpleList <|> costMap) + where + simpleList = M.fromSet (const $ ExerciseCost []) <$> parseJSON x + costMap = withArray "Capabilities" (fmap (M.fromList . map toMapEntry) . mapM parseJSON . V.toList) x + toMapEntry (CapabilityCost a b) = (a, ExerciseCost b) + +instance (Ord e, Semigroup e) => Semigroup (Capabilities e) where + Capabilities c1 <> Capabilities c2 = + Capabilities $ M.unionWith (<>) c1 c2 + +instance (Ord e, Semigroup e) => Monoid (Capabilities e) where + mempty = Capabilities mempty + +-- | Exercising a capability may have a cost. +newtype ExerciseCost e = ExerciseCost + { ingredients :: IngredientList e + } + deriving (Eq, Show, Generic, FromJSON, ToJSON, Hashable, Functor) + +instance (Eq e) => Ord (ExerciseCost e) where + compare = compare `on` (getCost . ingredients) + +data DeviceUseCost e en = DeviceUseCost + { device :: e + , useCost :: ExerciseCost en + } + deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON, Functor) + +-- TODO Should this derive from an Applicative instance? +promoteDeviceUseCost :: + Monad m => + (e -> m e') -> + DeviceUseCost x e -> + m (DeviceUseCost x e') +promoteDeviceUseCost f (DeviceUseCost d ex) = + DeviceUseCost d <$> transformIngredients f ex + +-- TODO Should this derive from an Applicative instance? +transformIngredients :: + Monad m => + (e -> m e') -> + ExerciseCost e -> + m (ExerciseCost e') +transformIngredients f (ExerciseCost ings) = + ExerciseCost <$> mapM (traverse f) ings diff --git a/src/swarm-scenario/Swarm/Game/Entity.hs b/src/swarm-scenario/Swarm/Game/Entity.hs index 6513891d23..34205980d0 100644 --- a/src/swarm-scenario/Swarm/Game/Entity.hs +++ b/src/swarm-scenario/Swarm/Game/Entity.hs @@ -47,15 +47,15 @@ module Swarm.Game.Entity ( -- ** Entity map EntityMap (..), buildEntityMap, + lookupEntityE, validateEntityAttrRefs, loadEntities, allEntities, lookupEntityName, - deviceForCap, + devicesForCap, -- * Inventories Inventory, - Count, -- ** Construction empty, @@ -95,6 +95,7 @@ import Control.Lens (Getter, Lens', lens, to, view, (^.)) import Control.Monad (forM_, unless, (<=<)) import Data.Bifunctor (first) import Data.Char (toLower) +import Data.Either.Extra (maybeToEither) import Data.Function (on) import Data.Hashable import Data.IntMap (IntMap) @@ -105,17 +106,19 @@ import Data.List (foldl') import Data.List.NonEmpty qualified as NE import Data.Map (Map) import Data.Map qualified as M -import Data.Maybe (fromMaybe, isJust, listToMaybe) +import Data.Maybe (isJust, listToMaybe) import Data.Set (Set) -import Data.Set qualified as Set (fromList, member, toList, unions) +import Data.Set qualified as Set (fromList, member) import Data.Text (Text) import Data.Text qualified as T import Data.Yaml import GHC.Generics (Generic) +import Swarm.Game.Device import Swarm.Game.Display import Swarm.Game.Entity.Cosmetic (WorldAttr (..)) import Swarm.Game.Entity.Cosmetic.Assignment (worldAttributes) import Swarm.Game.Failure +import Swarm.Game.Ingredients import Swarm.Game.Location import Swarm.Game.ResourceLoading (getDataFileNameSafe) import Swarm.Language.Capability @@ -277,7 +280,7 @@ data Entity = Entity -- grabbed. , _entityProperties :: Set EntityProperty -- ^ Properties of the entity. - , _entityCapabilities :: Set Capability + , _entityCapabilities :: SingleEntityCapabilities EntityName -- ^ Capabilities provided by this entity. , _entityInventory :: Inventory -- ^ Inventory of other entities held by this entity. @@ -331,7 +334,7 @@ mkEntity :: -- | Properties [EntityProperty] -> -- | Capabilities - [Capability] -> + Set Capability -> Entity mkEntity disp nm descr props caps = rehashEntity $ @@ -347,7 +350,7 @@ mkEntity disp nm descr props caps = Nothing Nothing (Set.fromList props) - (Set.fromList caps) + (zeroCostCapabilities caps) empty ------------------------------------------------------------ @@ -363,11 +366,11 @@ mkEntity disp nm descr props caps = -- This enables scenario authors to specify iteration order of -- the 'Swarm.Language.Syntax.TagMembers' command. data EntityMap = EntityMap - { entitiesByName :: Map Text Entity - , entitiesByCap :: Map Capability [Entity] + { entitiesByName :: Map EntityName Entity + , entitiesByCap :: MultiEntityCapabilities Entity Entity , entityDefinitionOrder :: [Entity] } - deriving (Eq, Show, Generic, FromJSON, ToJSON) + deriving (Eq, Show, Generic, ToJSON) -- | -- Note that duplicates in a single 'EntityMap' are precluded by the @@ -382,11 +385,11 @@ instance Semigroup EntityMap where EntityMap n1 c1 d1 <> EntityMap n2 c2 d2 = EntityMap (n1 <> n2) - (M.unionWith (<>) c1 c2) + (c1 <> c2) (filter ((`M.notMember` n2) . view entityName) d1 <> d2) instance Monoid EntityMap where - mempty = EntityMap M.empty M.empty [] + mempty = EntityMap M.empty mempty [] mappend = (<>) -- | Get a list of all the entities in the entity map. @@ -399,8 +402,8 @@ lookupEntityName nm = M.lookup nm . entitiesByName -- | Find all entities which are devices that provide the given -- capability. -deviceForCap :: Capability -> EntityMap -> [Entity] -deviceForCap cap = fromMaybe [] . M.lookup cap . entitiesByCap +devicesForCap :: Capability -> EntityMap -> [Entity] +devicesForCap cap = maybe [] (NE.toList . NE.map device) . M.lookup cap . getMap . entitiesByCap -- | Validates references to 'Display' attributes validateEntityAttrRefs :: Has (Throw LoadingFailure) sig m => Set WorldAttr -> [Entity] -> m () @@ -429,14 +432,50 @@ buildEntityMap es = do case findDup (map fst namedEntities) of Nothing -> return () Just duped -> throwError $ Duplicate Entities duped - return $ - EntityMap - { entitiesByName = M.fromList namedEntities - , entitiesByCap = M.fromListWith (<>) . concatMap (\e -> map (,[e]) (Set.toList $ e ^. entityCapabilities)) $ es - , entityDefinitionOrder = es - } + case combineEntityCapsM entsByName es of + Left x -> throwError $ CustomMessage x + Right ebc -> + return $ + EntityMap + { entitiesByName = entsByName + , entitiesByCap = ebc + , entityDefinitionOrder = es + } where namedEntities = map (view entityName &&& id) es + entsByName = M.fromList namedEntities + +-- Compare to 'combineEntityCapsM' +combineEntityCaps :: + [Entity] -> + MultiEntityCapabilities Entity EntityName +combineEntityCaps = mconcat . map mkForEntity + where + mkForEntity e = f <$> e ^. entityCapabilities + where + f = pure . DeviceUseCost e + +lookupEntityE :: Map Text b -> Text -> Either Text b +lookupEntityE em en = + maybeToEither err $ M.lookup en em + where + err = T.unwords [quote en, "is not a valid entity name"] + +combineEntityCapsM :: + Map EntityName Entity -> + [Entity] -> + Either Text (MultiEntityCapabilities Entity Entity) +combineEntityCapsM em = + fmap mconcat . mapM mkForEntity + where + transformCaps (Capabilities m) = do + Capabilities <$> mapM (transformIngredients $ lookupEntityE em) m + + mkForEntity e = do + betterCaps <- transformCaps $ e ^. entityCapabilities + return $ f <$> betterCaps + where + f = pure . DeviceUseCost e ------------------------------------------------------------ -- Serialization @@ -456,7 +495,7 @@ instance FromJSON Entity where <*> v .:? "combustion" <*> v .:? "yields" <*> v .:? "properties" .!= mempty - <*> v .:? "capabilities" .!= mempty + <*> v .:? "capabilities" .!= Capabilities mempty <*> pure empty ) @@ -481,7 +520,7 @@ instance ToJSON Entity where ++ ["growth" .= (e ^. entityGrowth) | isJust (e ^. entityGrowth)] ++ ["yields" .= (e ^. entityYields) | isJust (e ^. entityYields)] ++ ["properties" .= (e ^. entityProperties) | not . null $ e ^. entityProperties] - ++ ["capabilities" .= (e ^. entityCapabilities) | not . null $ e ^. entityCapabilities] + ++ ["capabilities" .= (e ^. entityCapabilities) | not . M.null . getMap $ e ^. entityCapabilities] -- | Load entities from a data file called @entities.yaml@, producing -- either an 'EntityMap' or a parse error. @@ -579,7 +618,7 @@ hasProperty :: Entity -> EntityProperty -> Bool hasProperty e p = p `elem` (e ^. entityProperties) -- | The capabilities this entity provides when equipped. -entityCapabilities :: Lens' Entity (Set Capability) +entityCapabilities :: Lens' Entity (SingleEntityCapabilities EntityName) entityCapabilities = hashedLens _entityCapabilities (\e x -> e {_entityCapabilities = x}) -- | The inventory of other entities carried by this entity. @@ -590,10 +629,6 @@ entityInventory = hashedLens _entityInventory (\e x -> e {_entityInventory = x}) -- Inventory ------------------------------------------------------------ --- | A convenient synonym to remind us when an 'Int' is supposed to --- represent /how many/ of something we have. -type Count = Int - -- | An inventory is really just a bag/multiset of entities. That is, -- it contains some entities, along with the number of times each -- occurs. Entities can be looked up directly, or by name. @@ -707,8 +742,8 @@ isEmpty :: Inventory -> Bool isEmpty = all ((== 0) . fst) . elems -- | Compute the set of capabilities provided by the devices in an inventory. -inventoryCapabilities :: Inventory -> Set Capability -inventoryCapabilities = Set.unions . map (^. entityCapabilities) . nonzeroEntities +inventoryCapabilities :: Inventory -> MultiEntityCapabilities Entity EntityName +inventoryCapabilities = combineEntityCaps . nonzeroEntities -- | List elements that have at least one copy in the inventory. nonzeroEntities :: Inventory -> [Entity] @@ -718,14 +753,14 @@ nonzeroEntities = map snd . filter ((> 0) . fst) . elems -- exist with nonzero count in the inventory. extantElemsWithCapability :: Capability -> Inventory -> [Entity] extantElemsWithCapability cap = - filter (Set.member cap . (^. entityCapabilities)) . nonzeroEntities + filter (M.member cap . getMap . (^. entityCapabilities)) . nonzeroEntities -- | Groups entities by the capabilities they offer. entitiesByCapability :: Inventory -> Map Capability (NE.NonEmpty Entity) entitiesByCapability inv = binTuples entityCapabilityPairs where - getCaps = Set.toList . (^. entityCapabilities) + getCaps = M.keys . getMap . (^. entityCapabilities) entityCapabilityPairs = concatMap ((\e -> map (,e) $ getCaps e) . snd) $ elems inv -- | Delete a single copy of a certain entity from an inventory. diff --git a/src/swarm-scenario/Swarm/Game/Ingredients.hs b/src/swarm-scenario/Swarm/Game/Ingredients.hs new file mode 100644 index 0000000000..8998ac6442 --- /dev/null +++ b/src/swarm-scenario/Swarm/Game/Ingredients.hs @@ -0,0 +1,20 @@ +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.Game.Ingredients ( + IngredientList, + Count, + getCost, +) where + +-- | A convenient synonym to remind us when an 'Int' is supposed to +-- represent /how many/ of something we have. +type Count = Int + +-- | An ingredient list is a list of entities with multiplicity. It +-- is polymorphic in the entity type so that we can use either +-- entity names when serializing, or actual entity objects while the +-- game is running. +type IngredientList e = [(Count, e)] + +getCost :: IngredientList e -> Int +getCost = sum . map fst diff --git a/src/swarm-scenario/Swarm/Game/Recipe.hs b/src/swarm-scenario/Swarm/Game/Recipe.hs index 1b123b04ac..e55b858680 100644 --- a/src/swarm-scenario/Swarm/Game/Recipe.hs +++ b/src/swarm-scenario/Swarm/Game/Recipe.hs @@ -47,6 +47,7 @@ module Swarm.Game.Recipe ( recipesFor, make, make', + findLacking, ) where import Control.Algebra (Has) @@ -67,18 +68,13 @@ import Data.Yaml import GHC.Generics (Generic) import Swarm.Game.Entity as E import Swarm.Game.Failure +import Swarm.Game.Ingredients import Swarm.Game.ResourceLoading (getDataFileNameSafe) import Swarm.Util.Effect (withThrow) import Swarm.Util.Lens (makeLensesNoSigs) import Swarm.Util.Yaml import Witch --- | An ingredient list is a list of entities with multiplicity. It --- is polymorphic in the entity type so that we can use either --- entity names when serializing, or actual entity objects while the --- game is running. -type IngredientList e = [(Count, e)] - -- | A recipe represents some kind of process where inputs are -- transformed into outputs. data Recipe e = Recipe @@ -220,6 +216,13 @@ data MissingIngredient = MissingIngredient MissingType Count Entity data MissingType = MissingInput | MissingCatalyst deriving (Show, Eq) +-- | Determines whether recipe inputs are satisfied by a +-- robot's inventory. +findLacking :: Inventory -> [(Count, Entity)] -> [(Count, Entity)] +findLacking robotInventory = filter ((> 0) . fst) . map countNeeded + where + countNeeded (need, entity) = (need - E.lookup entity robotInventory, entity) + -- | Figure out which ingredients (if any) are lacking from an -- inventory to be able to carry out the recipe. Catalysts are not -- consumed and so can be used even when equipped. @@ -229,8 +232,6 @@ missingIngredientsFor (inv, ins) (Recipe inps _ cats _ _) = <> mkMissing MissingCatalyst (findLacking ins (findLacking inv cats)) where mkMissing k = map (uncurry (MissingIngredient k)) - findLacking inven = filter ((> 0) . fst) . map (countNeeded inven) - countNeeded inven (need, entity) = (need - E.lookup entity inven, entity) -- | Figure out if a recipe is available, /i.e./ if we at least know -- about all the ingredients. Note it does not matter whether we have @@ -259,7 +260,12 @@ make invs r = finish <$> make' invs r finish (invTaken, out) = (invTaken, out, r) -- | Try to make a recipe, but do not insert it yet. -make' :: (Inventory, Inventory) -> Recipe Entity -> Either [MissingIngredient] (Inventory, IngredientList Entity) +make' :: + (Inventory, Inventory) -> + Recipe Entity -> + Either + [MissingIngredient] + (Inventory, IngredientList Entity) make' invs@(inv, _) r = case missingIngredientsFor invs r of [] -> diff --git a/src/swarm-scenario/Swarm/Game/Robot.hs b/src/swarm-scenario/Swarm/Game/Robot.hs index 9ceec8337e..9b1b0d43b2 100644 --- a/src/swarm-scenario/Swarm/Game/Robot.hs +++ b/src/swarm-scenario/Swarm/Game/Robot.hs @@ -78,8 +78,10 @@ import Data.Text (Text) import Data.Yaml (FromJSON (parseJSON), (.!=), (.:), (.:?)) import GHC.Generics (Generic) import Linear +import Swarm.Game.Device import Swarm.Game.Display (Display, curOrientation, defaultRobotDisplay, invisible) import Swarm.Game.Entity hiding (empty) +import Swarm.Game.Ingredients import Swarm.Game.Land import Swarm.Game.Location (Heading, Location, toDirection, toHeading) import Swarm.Game.Universe @@ -135,7 +137,7 @@ type instance RobotLogUpdatedMember 'TemplateRobot = () data RobotR (phase :: RobotPhase) = RobotR { _robotEntity :: Entity , _equippedDevices :: Inventory - , _robotCapabilities :: Set Capability + , _robotCapabilities :: MultiEntityCapabilities Entity EntityName -- ^ A cached view of the capabilities this robot has. -- Automatically generated from '_equippedDevices'. , _robotLog :: RobotLogMember phase @@ -289,7 +291,7 @@ robotKnows r e = contains0plus e (r ^. robotInventory) || contains0plus e (r ^. -- getter, not a lens, because it is automatically generated from -- the 'equippedDevices'. The only way to change a robot's -- capabilities is to modify its 'equippedDevices'. -robotCapabilities :: Getter Robot (Set Capability) +robotCapabilities :: Getter Robot (MultiEntityCapabilities Entity EntityName) robotCapabilities = to _robotCapabilities -- | Is this robot a "system robot"? System robots are generated by @@ -313,7 +315,7 @@ data WalkabilityContext walkabilityContext :: Getter Robot WalkabilityContext walkabilityContext = to $ - \x -> WalkabilityContext (_robotCapabilities x) (_unwalkableEntities x) + \x -> WalkabilityContext (getCapabilitySet $ _robotCapabilities x) (_unwalkableEntities x) -- | A general function for creating robots. mkRobot :: @@ -346,7 +348,7 @@ mkRobot :: mkRobot pid name descr loc dir disp m devs inv sys heavy unwalkables ts = RobotR { _robotEntity = - mkEntity disp name descr [] [] + mkEntity disp name descr [] mempty & entityOrientation ?~ dir & entityInventory .~ fromElems inv , _equippedDevices = inst diff --git a/swarm.cabal b/swarm.cabal index 7b36825e4f..3ab8f6dc93 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -183,11 +183,13 @@ library swarm-scenario exposed-modules: Swarm.Constant Swarm.Game.Achievement.Definitions + Swarm.Game.Device Swarm.Game.Display Swarm.Game.Entity Swarm.Game.Entity.Cosmetic Swarm.Game.Entity.Cosmetic.Assignment Swarm.Game.Failure + Swarm.Game.Ingredients Swarm.Game.Land Swarm.Game.Location Swarm.Game.Recipe @@ -531,12 +533,14 @@ library Swarm.Game.Achievement.Description, Swarm.Game.Achievement.Persistence, Swarm.Game.CESK, + Swarm.Game.Device, Swarm.Game.Display, Swarm.Game.Entity, Swarm.Game.Entity.Cosmetic, Swarm.Game.Entity.Cosmetic.Assignment, Swarm.Game.Exception, Swarm.Game.Failure, + Swarm.Game.Ingredients, Swarm.Game.Land, Swarm.Game.Location, Swarm.Game.Recipe, diff --git a/test/integration/Main.hs b/test/integration/Main.hs index e8e29a4cd2..56e71eb71a 100644 --- a/test/integration/Main.hs +++ b/test/integration/Main.hs @@ -368,6 +368,7 @@ testScenarioSolutions rs ui = , testSolution Default "Testing/1631-tags" , testSolution Default "Testing/1747-volume-command" , testSolution Default "Testing/1775-custom-terrain" + , testSolution (Sec 3) "Testing/1777-capability-cost" , testGroup -- Note that the description of the classic world in -- data/worlds/classic.yaml (automatically tested to some diff --git a/test/unit/TestInventory.hs b/test/unit/TestInventory.hs index e35ea12571..de8dc1cab4 100644 --- a/test/unit/TestInventory.hs +++ b/test/unit/TestInventory.hs @@ -109,6 +109,6 @@ testInventory = ) ] where - x = E.mkEntity (defaultEntityDisplay 'X') "fooX" mempty [] [] - y = E.mkEntity (defaultEntityDisplay 'Y') "fooY" mempty [] [] - z = E.mkEntity (defaultEntityDisplay 'Z') "fooZ" mempty [] [] + x = E.mkEntity (defaultEntityDisplay 'X') "fooX" mempty [] mempty + y = E.mkEntity (defaultEntityDisplay 'Y') "fooY" mempty [] mempty + z = E.mkEntity (defaultEntityDisplay 'Z') "fooZ" mempty [] mempty diff --git a/test/unit/TestRecipeCoverage.hs b/test/unit/TestRecipeCoverage.hs index dc73a17ee4..372ef3a95b 100644 --- a/test/unit/TestRecipeCoverage.hs +++ b/test/unit/TestRecipeCoverage.hs @@ -8,9 +8,11 @@ module TestRecipeCoverage where import Control.Lens ((^.)) +import Data.List.NonEmpty qualified as NE import Data.Map qualified as M import Data.Set qualified as Set import Data.Text qualified as T +import Swarm.Game.Device import Swarm.Game.Entity (EntityMap (entitiesByCap), entityName) import Swarm.Game.Land import Swarm.Game.Recipe (recipeOutputs) @@ -43,7 +45,7 @@ testDeviceRecipeCoverage rs = -- Only include entities that grant a capability: entityNames = - Set.fromList . map (^. entityName) . concat . M.elems . entitiesByCap $ + Set.fromList . map ((^. entityName) . device) . concatMap NE.toList . M.elems . getMap . entitiesByCap $ rs ^. stdEntityTerrainMap . entityMap getOutputsForRecipe r = map ((^. entityName) . snd) $ r ^. recipeOutputs