Capability exercise cost (#1777)

Closes #1684
Closes #1262

# Demo

A simple "puzzle" that makes use of consumables:

    scripts/play.sh -i data/scenarios/Testing/1777-capability-cost.yaml --autoplay

Demo of enabled commands and costs display in left pane:

    scripts/play.sh -i data/scenarios/Testing/1262-display-device-commands.yaml

![Screenshot from 2024-03-01 22-39-12](https://github.com/swarm-game/swarm/assets/261693/03fc0e4f-d219-4aa1-8775-cb5112eb0b90)


# In this PR

* Supports specifying capabilities both as plain lists and as maps from capabilities to ingredients in YAML
* JSON Schema support for both capability specification styles
* New integration test
* Removed redundant `tshow` implementation from `Swarm.Doc.Util`

# Entity lookup approaches

The cost of exercising a capability in terms of "ingredients" is specified by the `_entityCapabilities` field within an `Entity` definition.  Each ingredient itself is an entity, specified by name.
For many purposes, having ingredients just of type `EntityName` is sufficient.  But for `Swarm.Game.Recipe.findLacking` in particular, the ingredients list must be actual `Entity` objects, not just names of entities.  So at some point the names need to be looked up in the global entity map to be promoted to `Entity` objects.

The full list of entities is not available at `Entity` parse time to look up an ingredient entity by name, so we cannot store ingredients lists of type `(Count, Entity)` within a parent `Entity` object.

Approaches considered were:

* Store a copy of the `entityMap` in `RobotR`, for use by the `equippedDevices` lens
* Introduce a type parameter to `Entity` representing a "parse phase"
* **Allow a redundant "entity lookup" (and validation) at command execution time**

## Store `entityMap` in `RobotR`

One approach explored was to add a field to `RobotR`:
```
  , _globalEntityMap :: EntityMap
```

This allowed the `equippedDevices` lens implementation to promote the `EntityName`s to `Entity`s when setting the value of the `_robotCapabilities` field.  However, it was rather invasive as it entailed threading the `EntityMap` through many new code paths.

## `Entity` type parameter

Currently, `Entity` has a field:
```
_entityCapabilities :: SingleEntityCapabilities EntityName
```

This would entail a huge refactoring, with:
```
data Entity e = Entity
  ...
  , _entityCapabilities :: SingleEntityCapabilities e
```

At initial parse time we would obtain a list of `Entity EntityName`, but somewhere later during `Scenario` parse time, we can do another pass to obtain `Entity Entity` objects.

This would at least have the advantage of doing the entity lookup/validation on ingredient lists in exactly one place, at parse time.

## Defer `EntityName -> Entity` promotion to command execution time

This is what is implemented in this PR.  The global set of capability costs is validated at scenario parse time.  But it is also redundantly validated in the `payExerciseCost` function, which is not ideal.
This commit is contained in:
Karl Ostmo 2024-04-25 12:39:54 -07:00 committed by GitHub
parent 62375ebf2d
commit f5ecd3fa53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 697 additions and 117 deletions

View File

@ -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

View File

@ -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

View File

@ -36,6 +36,7 @@ Achievements
1218-stride-command.yaml
1234-push-command.yaml
1256-halt-command.yaml
1262-display-device-commands.yaml
1295-density-command.yaml
1138-structures
1320-world-DSL
@ -59,4 +60,5 @@ Achievements
1634-message-colors.yaml
1681-pushable-entity.yaml
1747-volume-command.yaml
1777-capability-cost.yaml
1775-custom-terrain.yaml

View File

@ -0,0 +1,54 @@
version: 1
name: Device commands
description: |
Demo display of commands offered by each device, along with their cost.
creative: false
robots:
- name: base
dir: east
devices:
- treads
- logger
- Fresnel lens
- string
inventory:
- [1, flash bulb]
- [1, photographic plate]
entities:
- name: flash bulb
display:
char: 'f'
description:
- Consumables for a `Fresnel lens`{=entity} that enable `ignite`ing
properties: [known, pickable]
- name: photographic plate
display:
char: 'p'
description:
- Consumables for a `Fresnel lens`{=entity} that enable `scan`ning
properties: [known, pickable]
- name: Fresnel lens
display:
char: 'z'
description:
- Ignites things with sufficiently powerful light source
properties: [known, pickable]
capabilities:
- capability: ignite
cost:
- [1, "flash bulb"]
- capability: scan
cost:
- [2, "photographic plate"]
known: [water]
world:
dsl: |
{water}
palette:
'B': [grass, erase, base]
'.': [grass, erase]
upperleft: [-1, 1]
map: |
...
.B.
...

View File

@ -0,0 +1,94 @@
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: |
hasLighterFluid <- as base {
has "lighter fluid";
};
if (not hasLighterFluid) {
judge <- robotnamed "judge";
as judge {
maybePath <- path (inL ()) (inR "packing peanut");
return $ case maybePath (\_. true) (\d. false);
}
} {
return false;
};
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`{=entity}
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.
......

View File

@ -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.

View File

@ -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)."
}

View File

@ -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]

View File

@ -91,7 +91,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
@ -309,7 +309,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

View File

@ -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

View File

@ -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

View File

@ -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 (..),

View File

@ -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 (commandCost, commandsForDeviceCaps, enabledCommands, getMap, ingredients)
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
@ -1207,10 +1210,11 @@ explainEntry s e =
vBox $
[ displayProperties $ Set.toList (e ^. entityProperties)
, drawMarkdown (e ^. entityDescription)
, explainCapabilities (s ^. gameState) e
, 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
@ -1236,6 +1240,66 @@ displayProperties = displayList . mapMaybe showProperty
, txt " "
]
-- | This widget can have potentially multiple "headings"
-- (one per capability), each with multiple commands underneath.
-- Directly below each heading there will be a "exercise cost"
-- description, unless the capability is free-to-exercise.
explainCapabilities :: GameState -> Entity -> Widget Name
explainCapabilities gs e
| null capabilitiesAndCommands = emptyWidget
| otherwise =
padBottom (Pad 1) $
vBox
[ hBorderWithLabel (txt "Enabled commands")
, hCenter
. vBox
. L.intersperse (txt " ") -- Inserts an extra blank line between major "Cost" sections
$ map drawSingleCapabilityWidget capabilitiesAndCommands
]
where
eLookup = lookupEntityE $ entitiesByName $ gs ^. landscape . terrainAndEntities . entityMap
eitherCosts = (traverse . traverse) eLookup $ e ^. entityCapabilities
capabilitiesAndCommands = case eitherCosts of
Right eCaps -> M.elems . getMap . commandsForDeviceCaps $ eCaps
Left x ->
error $
unwords
[ "Error: somehow an invalid entity reference escaped the parse-time check"
, T.unpack x
]
drawSingleCapabilityWidget cmdsAndCost =
vBox
[ costWidget cmdsAndCost
, padLeft (Pad 1) . vBox . map renderCmdInfo . NE.toList $ enabledCommands cmdsAndCost
]
renderCmdInfo c =
padTop (Pad 1) $
vBox
[ hBox
[ padRight (Pad 1) (txt . syntax $ constInfo c)
, padRight (Pad 1) (txt ":")
, withAttr magentaAttr . txt . prettyText $ inferConst c
]
, padTop (Pad 1) . padLeft (Pad 1) . txtWrap . briefDoc . constDoc $ constInfo c
]
costWidget cmdsAndCost =
if null ings
then emptyWidget
else padTop (Pad 1) $ vBox $ withAttr boldAttr (txt "Cost:") : map drawCost ings
where
ings = ingredients $ commandCost cmdsAndCost
drawCost (n, ingr) =
padRight (Pad 1) (str (show n)) <+> eName
where
eName = applyEntityNameAttr Nothing missing ingr $ txt $ ingr ^. entityName
missing = E.lookup ingr robotInv < n
robotInv = fromMaybe E.empty $ gs ^? to focusedRobot . _Just . robotInventory
explainRecipes :: AppState -> Entity -> Widget Name
explainRecipes s e
| null recipes = emptyWidget
@ -1347,19 +1411,24 @@ drawRecipe me inv (Recipe ins outs reqs time _weight) =
-- If it's the focused entity, draw it highlighted.
-- If the robot doesn't have any, draw it in red.
fmtEntityName missing ingr
| Just ingr == me = withAttr highlightAttr $ txtLines nm
| ingr == timeE = withAttr yellowAttr $ txtLines nm
| missing = withAttr invalidFormInputAttr $ txtLines nm
| otherwise = txtLines nm
fmtEntityName :: Bool -> Entity -> Widget n
fmtEntityName missing ingr =
applyEntityNameAttr me missing ingr $ txtLines nm
where
-- Split up multi-word names, one line per word
nm = ingr ^. entityName
txtLines = vBox . map txt . T.words
applyEntityNameAttr :: Maybe Entity -> Bool -> Entity -> (Widget n -> Widget n)
applyEntityNameAttr me missing ingr
| Just ingr == me = withAttr highlightAttr
| ingr == timeE = withAttr yellowAttr
| missing = withAttr invalidFormInputAttr
| otherwise = id
-- | 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)

View File

@ -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

View File

@ -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,18 @@ formatExn em = \case
-- INCAPABLE HELPERS
-- ------------------------------------------------------------------
-- | Pretty-print an 'IncapableFix': either "equip" or "obtain".
formatIncapableFix :: IncapableFix -> Text
data IncapableFixWords = IncapableFixWords
{ fixVerb :: Text
, fixNoun :: Text
}
-- | Pretty-print an 'IncapableFix': either "equip device",
-- "obtain device", or "obtain consumables".
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 +166,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 +182,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

View File

@ -101,6 +101,7 @@ import Data.Text.Lazy.Encoding qualified as TL
import GHC.Generics (Generic)
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
@ -599,7 +600,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
@ -644,7 +645,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)

View File

@ -684,7 +684,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

View File

@ -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)
@ -90,11 +91,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

View File

@ -1032,7 +1032,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
@ -1079,7 +1079,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
@ -1499,7 +1499,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
@ -1550,10 +1550,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)
)

View File

@ -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
@ -92,7 +93,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.

View File

@ -21,6 +21,8 @@ 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.IntSet qualified as IS
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)
@ -28,15 +30,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
@ -59,25 +64,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 = (traverse . traverse) (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 ::
@ -253,9 +321,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

View File

@ -12,18 +12,24 @@ module Swarm.Language.Capability (
Capability (..),
capabilityName,
constCaps,
constByCaps,
) where
import Control.Arrow ((&&&))
import Data.Aeson (FromJSONKey, ToJSONKey)
import Data.Char (toLower)
import Data.Data (Data)
import Data.Hashable (Hashable)
import Data.List.NonEmpty qualified as NE
import Data.Map (Map)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Tuple (swap)
import Data.Yaml
import GHC.Generics (Generic)
import Swarm.Language.Syntax
import Swarm.Util (failT)
import Swarm.Util (binTuples, failT)
import Text.Read (readMaybe)
import Witch (from)
import Prelude hiding (lookup)
@ -336,3 +342,10 @@ constCaps = \case
-- currently don't.
View -> Nothing -- TODO: #17 should require equipping an antenna
Knows -> Nothing
-- | Inverts the 'constCaps' mapping.
constByCaps :: Map Capability (NE.NonEmpty Const)
constByCaps =
binTuples $
map swap $
mapMaybe (sequenceA . (id &&& constCaps)) allConst

View File

@ -0,0 +1,134 @@
-- |
-- 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 (..),
CommandsAndCost (..),
getCapabilitySet,
zeroCostCapabilities,
commandsForDeviceCaps,
)
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, constByCaps)
import Swarm.Language.Syntax (Const)
-- | The 'Capabilities e' wrapper type stores information of type @e@ for each
-- of some set of capabilities.
-- For example, @e@ could be a list of ingredients needed to exercise a
-- capability, or a set of devices capable of providing a capability.
newtype Capabilities e = Capabilities
{ getMap :: Map Capability e
}
deriving (Show, Eq, Generic, ToJSON, Hashable, Functor, Foldable, Traversable)
-- | Get the set of capabilities about which we are storing information.
getCapabilitySet :: Capabilities e -> Set Capability
getCapabilitySet (Capabilities m) = M.keysSet m
-- | Records an 'ExerciseCost', i.e. list of consumed ingredients, per capability that can be exercised. This represents information about a single entity/device, which can provide multiple capabilities (with a different exercise cost for each).
type SingleEntityCapabilities e = Capabilities (ExerciseCost e)
-- | Records a list of devices capable of providing each capability;
-- along with each device is recorded the 'ExerciseCost' needed to use
-- that device to achieve the given capability.
--
-- See 'DeviceUseCost' for explanation of type parameters.
type MultiEntityCapabilities e en = Capabilities (NonEmpty (DeviceUseCost e en))
-- | Create a default 'SingleEntityCapabilities' map for a device which provides capabilities with no associated costs.
zeroCostCapabilities :: Set Capability -> SingleEntityCapabilities e
zeroCostCapabilities = Capabilities . M.fromSet (const $ ExerciseCost [])
-- | Package together a capability and exercise cost; only used temporarily for parsing this information from JSON format.
data CapabilityCost e = CapabilityCost
{ capability :: Capability
, cost :: IngredientList e
}
deriving (Generic, FromJSON)
-- | First, attempt to parse capabilities as a list, interpreted as a set of capabilities with no exercise cost.
-- Otherwise, parse as a Map from capabilities to ingredients.
instance (FromJSON e) => FromJSON (SingleEntityCapabilities e) where
parseJSON x =
simpleList <|> (Capabilities <$> costMap)
where
simpleList = zeroCostCapabilities <$> 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, in the form of entities that must be consumed each time it is used.
newtype ExerciseCost e = ExerciseCost
{ ingredients :: IngredientList e
}
deriving (Eq, Show, Generic, FromJSON, ToJSON, Hashable, Functor, Foldable, Traversable)
-- | Sort 'ExerciseCost's by the total count of ingredients consumed.
instance (Eq e) => Ord (ExerciseCost e) where
compare = compare `on` (getCost . ingredients)
-- | A device paired with a cost to use it.
--
-- At scenario parse time, the type parameters @e@ and @en@ will stand for
-- 'Entity' and 'EntityName'.
-- This is because `ExerciseCost` is a member of the 'Entity' datatype, and
-- therefore can only refer to another 'Entity' by name before all 'Entity's
-- are parsed.
--
-- However, after parse time, we are able to look up actual 'Entity' objects
-- by name, and therefore can instantiate 'ExerciseCost' with 'Entity' as
-- the type parameter.
-- Then the two type parameters of 'DeviceUseCost' are both of 'Entity' type.
data DeviceUseCost e en = DeviceUseCost
{ device :: e
, useCost :: ExerciseCost en
}
deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON, Functor, Foldable, Traversable)
-- * Utils
-- | A nonempty list of commands together with an exercise cost for using any of them (typically these will be a list of commands all requiring the same capability).
data CommandsAndCost e = CommandsAndCost
{ commandCost :: ExerciseCost e
, enabledCommands :: NonEmpty Const
}
-- | Given mapping from capabilities to their exercise costs provided by a single device, turn it into an mapping from capabilities to their exercise cost and enabled commands.
--
-- NOTE: Because each 'Const' is mapped to at most one
-- 'Capability' by the 'constCaps' function, we know that
-- a given 'Const' will not appear more than once as a value in the 'Map' produced by
-- this function, i.e. for the capabilities provided by a single 'Entity`
-- ('SingleEntityCapabilities').
commandsForDeviceCaps :: SingleEntityCapabilities e -> Capabilities (CommandsAndCost e)
commandsForDeviceCaps = Capabilities . M.mapMaybeWithKey f . getMap
where
f cap xc =
CommandsAndCost xc <$> M.lookup cap constByCaps

View File

@ -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,48 @@ 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 = (traverse . traverse) (lookupEntityE em)
mkForEntity e =
fmap f <$> transformCaps (e ^. entityCapabilities)
where
f = pure . DeviceUseCost e
------------------------------------------------------------
-- Serialization
@ -456,7 +493,7 @@ instance FromJSON Entity where
<*> v .:? "combustion"
<*> v .:? "yields"
<*> v .:? "properties" .!= mempty
<*> v .:? "capabilities" .!= mempty
<*> v .:? "capabilities" .!= Capabilities mempty
<*> pure empty
)
@ -481,7 +518,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 +616,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 +627,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 +740,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 +751,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.

View File

@ -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

View File

@ -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
[] ->

View File

@ -71,18 +71,18 @@ import Control.Applicative ((<|>))
import Control.Lens hiding (Const, contains)
import Data.Hashable (hashWithSalt)
import Data.Kind qualified
import Data.Set (Set)
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.Robot.Walk
import Swarm.Game.Universe
import Swarm.Language.Capability (Capability)
import Swarm.Language.Pipeline (ProcessedTerm)
import Swarm.Language.Syntax (Syntax)
import Swarm.Language.Text.Markdown (Document)
@ -134,7 +134,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
@ -288,7 +288,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
@ -303,7 +303,7 @@ selfDestruct :: Lens' Robot Bool
runningAtomic :: Lens' Robot Bool
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 ::
@ -336,7 +336,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

View File

@ -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
@ -532,12 +534,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,

View File

@ -370,6 +370,7 @@ testScenarioSolutions rs ui =
, testSolution Default "Testing/1631-tags"
, testSolution Default "Testing/1747-volume-command"
, testSolution Default "Testing/1775-custom-terrain"
, testSolution Default "Testing/1777-capability-cost"
, testGroup
-- Note that the description of the classic world in
-- data/worlds/classic.yaml (automatically tested to some

View File

@ -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

View File

@ -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 gsi =
-- 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 $
initEntityTerrain (gsiScenarioInputs gsi) ^. entityMap
getOutputsForRecipe r = map ((^. entityName) . snd) $ r ^. recipeOutputs