diff --git a/data/scenarios/Testing/1356-portals/00-ORDER.txt b/data/scenarios/Testing/1356-portals/00-ORDER.txt new file mode 100644 index 000000000..c26d1b38d --- /dev/null +++ b/data/scenarios/Testing/1356-portals/00-ORDER.txt @@ -0,0 +1,3 @@ +automatic-waypoint-patrol.yaml +portals-and-waypoints.yaml +portals-flip-and-rotate.yaml \ No newline at end of file diff --git a/data/scenarios/Testing/1356-portals/_automatic-waypoint-patrol/program.sw b/data/scenarios/Testing/1356-portals/_automatic-waypoint-patrol/program.sw new file mode 100644 index 000000000..8cf22bf37 --- /dev/null +++ b/data/scenarios/Testing/1356-portals/_automatic-waypoint-patrol/program.sw @@ -0,0 +1,49 @@ +def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end; +def abs = \n. if (n < 0) {-n} {n} end; + +def mapTuple = \f. \t. + (f $ fst t, f $ snd t) + end; + +def sumTuples = \t1. \t2. + (fst t1 + fst t2, snd t1 + snd t2); + end; + +def negateTuple = \t. + mapTuple (\x. -x) t; + end; + +def getRelativeLocation = \absCurrentLoc. \absDestLoc. + let negatedLoc = negateTuple absCurrentLoc in + return $ sumTuples negatedLoc absDestLoc; + end; + +def moveTuple = \tup. + let x = fst tup in + let y = snd tup in + turn $ if (x > 0) {east} {west}; + doN (abs x) move; + turn $ if (y > 0) {north} {south}; + doN (abs y) move; + end; + +def goToLocation = \currentLoc. \absoluteDestination. + relativeDestination <- getRelativeLocation currentLoc absoluteDestination; + moveTuple relativeDestination; + end; + +def visitNextWaypoint = \nextWpIdx. + loc <- whereami; + nextWaypointQuery <- waypoint "wp" nextWpIdx; + goToLocation loc $ snd nextWaypointQuery; + + visitNextWaypoint $ nextWpIdx + 1; + end; + +def go = + waypointQuery <- waypoint "wp" 0; + teleport self $ snd waypointQuery; + visitNextWaypoint 1; + end; + +go; diff --git a/data/scenarios/Testing/1356-portals/_portals-flip-and-rotate/solution.sw b/data/scenarios/Testing/1356-portals/_portals-flip-and-rotate/solution.sw new file mode 100644 index 000000000..b7566d118 --- /dev/null +++ b/data/scenarios/Testing/1356-portals/_portals-flip-and-rotate/solution.sw @@ -0,0 +1,61 @@ +def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end; + +def abs = \n. if (n < 0) {-n} {n} end; + +def mapTuple = \f. \t. + (f $ fst t, f $ snd t) + end; + +def sumTuples = \t1. \t2. + (fst t1 + fst t2, snd t1 + snd t2); + end; + +def negateTuple = \t. + mapTuple (\x. -x) t; + end; + +def getRelativeLocation = \absCurrentLoc. \absDestLoc. + let negatedLoc = negateTuple absCurrentLoc in + return $ sumTuples negatedLoc absDestLoc; + end; + +def moveTuple = \tup. + let x = fst tup in + let y = snd tup in + turn $ if (x > 0) {east} {west}; + doN (abs x) move; + turn $ if (y > 0) {north} {south}; + doN (abs y) move; + end; + +def goToLocation = \currentLoc. \absoluteDestination. + relativeDestination <- getRelativeLocation currentLoc absoluteDestination; + moveTuple relativeDestination; + end; + +def goToBottom = + turn south; doN 14 move; + end; + +def go = + goToLocation (0, 0) (3, -2); + goToLocation (0, 0) (12, -2); + goToLocation (0, 0) (18, -5); + goToLocation (0, 0) (23, -3); + + goToBottom; + goToLocation (0, -14) (3, -12); + goToBottom; + goToLocation (0, -14) (9, -9); + goToBottom; + goToLocation (0, -14) (18, -9); + goToBottom; + goToLocation (0, -14) (26, -10); + + turn east; + doN 29 move; + goToBottom; + grab; + end; + +go; diff --git a/data/scenarios/Testing/1356-portals/automatic-waypoint-patrol.yaml b/data/scenarios/Testing/1356-portals/automatic-waypoint-patrol.yaml new file mode 100644 index 000000000..90924df3b --- /dev/null +++ b/data/scenarios/Testing/1356-portals/automatic-waypoint-patrol.yaml @@ -0,0 +1,73 @@ +version: 1 +name: Querying waypoints +description: | + Demonstrate patrolling between waypoints +creative: true +robots: + - name: base + loc: [0, 0] + dir: [1, 0] + - name: patroller + loc: [5, -4] + dir: [1, 0] + display: + invisible: false + attr: robot + program: | + run "scenarios/Testing/1356-portals/_automatic-waypoint-patrol/program.sw" +known: [flower, boulder] +world: + upperleft: [-1, 1] + default: [blank] + palette: + '.': [grass] + '*': [stone, flower] + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + structures: + - name: bigbox + structure: + palette: + '.': [dirt] + '@': [dirt, boulder] + 'w': + cell: [dirt] + waypoint: + name: wp + map: | + @@@ + @w. + @.@ + placements: + - src: bigbox + offset: [2, -2] + orient: + up: "DNorth" + - src: bigbox + offset: [8, -2] + orient: + up: "DEast" + - src: bigbox + offset: [8, -6] + orient: + up: "DSouth" + - src: bigbox + offset: [2, -6] + orient: + up: "DWest" + map: | + ┌───────────┐ + │*..*..*..*.│ + │.*..*..*..*│ + │..*..*..*..│ + │*..*..*..*.│ + │.*..*..*..*│ + │..*..*..*..│ + │*..*..*..*.│ + │.*..*..*..*│ + │..*..*..*..│ + └───────────┘ diff --git a/data/scenarios/Testing/1356-portals/portals-and-waypoints.yaml b/data/scenarios/Testing/1356-portals/portals-and-waypoints.yaml new file mode 100644 index 000000000..95f24e3cb --- /dev/null +++ b/data/scenarios/Testing/1356-portals/portals-and-waypoints.yaml @@ -0,0 +1,115 @@ +version: 1 +name: Waypoints for nested structures +description: | + Demonstrate behavior of waypoints across structure overlays +creative: true +attrs: + - name: portal_in + fg: "#ff9a00" + bg: "#ff5d00" + - name: portal_out + fg: "#00a2ff" + bg: "#0065ff" +entities: + - name: telepad entrance + display: + attr: portal_in + char: "o" + description: + - Portal entrance + properties: [known] + - name: telepad exit + display: + attr: portal_out + char: "o" + description: + - Portal exit + properties: [known] +robots: + - name: base + loc: [0, 4] + dir: [1, 0] +known: [tree, flower, sand, bit (0), bit (1)] +world: + upperleft: [-4, 7] + default: [blank] + palette: + '.': [grass] + '*': [stone, flower] + 'P': [grass, telepad entrance] + 'p': [grass, telepad exit] + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + structures: + - name: bitpair + structure: + palette: + 'p': [stone, telepad exit] + '1': [stone, bit (1)] + map: | + 1 + p + waypoints: + - name: bitpair_bottom + loc: [0, -1] + - name: minibox + structure: + palette: + '.': [stone] + 's': [stone, sand] + 'P': [stone, telepad entrance] + placements: + - src: bitpair + offset: [1, 0] + waypoints: + - name: minibox_corner + loc: [0, 0] + map: | + P.s + s.s + - name: bigbox + structure: + palette: + '.': [stone] + 'T': [stone, tree] + 'w': + cell: [dirt, telepad entrance] + waypoint: + name: bigbox_middle + map: | + TTTTTT + T.TwT. + .T.T.T + TTTTTT + placements: + - src: bigbox + offset: [1, -1] + - src: bigbox + offset: [7, -5] + - src: minibox + offset: [1, -7] + waypoints: + - name: meadow + loc: [12, -1] + portals: + - entrance: bigbox_middle + exitInfo: + exit: bitpair_bottom + - entrance: minibox_corner + exitInfo: + exit: meadow + map: | + ┌────────────┐ + │*..*..*..*.p│ + │.*..*..*..*.│ + │..*..*..*..*│ + │*..*..*..*..│ + │.*..*..*..*.│ + │..*..*..*..*│ + │*..*..*..*..│ + │.*..*..*..*.│ + └────────────┘ diff --git a/data/scenarios/Testing/1356-portals/portals-flip-and-rotate.yaml b/data/scenarios/Testing/1356-portals/portals-flip-and-rotate.yaml new file mode 100644 index 000000000..de142e303 --- /dev/null +++ b/data/scenarios/Testing/1356-portals/portals-flip-and-rotate.yaml @@ -0,0 +1,143 @@ +version: 1 +name: Portals with substructure flip and rotation +description: | + Validate proper flip/rotate of portal waypoints +objectives: + - goal: + - | + `grab` the "bitcoin" + condition: | + as base {has "bitcoin"} +solution: | + run "scenarios/Testing/1356-portals/_portals-flip-and-rotate/solution.sw" +attrs: + - name: portal_in + fg: "#ff9a00" + bg: "#ff5d00" + - name: portal_out + fg: "#00a2ff" + bg: "#0065ff" +entities: + - name: telepad entrance + display: + attr: portal_in + char: "o" + description: + - Portal entrance + properties: [known] + - name: telepad exit + display: + attr: portal_out + char: "o" + description: + - Portal exit + properties: [known] +robots: + - name: base + dir: [1, 0] + devices: + - ADT calculator + - branch predictor + - comparator + - compass + - dictionary + - GPS receiver + - grabber + - lambda + - lodestone + - logger + - strange loop + - treads +known: [flower, bit (0), bit (1), bitcoin] +world: + default: [blank] + palette: + '.': [grass] + '*': [stone, flower] + 'b': [stone, bitcoin] + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + 'p': + cell: [dirt, telepad exit, base] + waypoint: + name: portal_out + upperleft: [-1, 1] + structures: + - name: tetromino + structure: + mask: '.' + palette: + '0': [stone, bit (0)] + '1': [stone, bit (1)] + 'P': + cell: [dirt, telepad entrance] + waypoint: + name: portal_in + map: | + 10.. + 1P.. + 10.. + 10.. + 1000 + 1111 + placements: + - src: tetromino + offset: [3, -2] + - src: tetromino + offset: [9, -2] + orient: + up: "DEast" + - src: tetromino + offset: [17, -2] + orient: + up: "DSouth" + - src: tetromino + offset: [23, -2] + orient: + up: "DWest" + - src: tetromino + offset: [3, -9] + orient: + up: "DNorth" + flip: true + - src: tetromino + offset: [9, -9] + orient: + up: "DEast" + flip: true + - src: tetromino + offset: [17, -9] + orient: + up: "DSouth" + flip: true + - src: tetromino + offset: [23, -9] + orient: + up: "DWest" + flip: true + portals: + - entrance: portal_in + exitInfo: + exit: portal_out + map: | + ┌──────────────────────────────┐ + │p..*..*..*..*..*..*..*..*..*..│ + │.*..*..*..*..*..*..*..*..*..*.│ + │..*..*..*..*..*..*..*..*..*..*│ + │*..*..*..*..*..*..*..*..*..*..│ + │.*..*..*..*..*..*..*..*..*..*.│ + │..*..*..*..*..*..*..*..*..*..*│ + │*..*..*..*..*..*..*..*..*..*..│ + │.*..*..*..*..*..*..*..*..*..*.│ + │..*..*..*..*..*..*..*..*..*..*│ + │*..*..*..*..*..*..*..*..*..*..│ + │.*..*..*..*..*..*..*..*..*..*.│ + │..*..*..*..*..*..*..*..*..*..*│ + │*..*..*..*..*..*..*..*..*..*..│ + │.*..*..*..*..*..*..*..*..*..*.│ + │..*..*..*..*..*..*..*..*..*..b│ + └──────────────────────────────┘ diff --git a/data/scenarios/Testing/_Validation/1356-ambiguous-portal-entrance.yaml b/data/scenarios/Testing/_Validation/1356-ambiguous-portal-entrance.yaml new file mode 100644 index 000000000..efab55c98 --- /dev/null +++ b/data/scenarios/Testing/_Validation/1356-ambiguous-portal-entrance.yaml @@ -0,0 +1,67 @@ +version: 1 +name: Reject multi-exit portal +description: | + Portals must have only a single exit +attrs: + - name: portal_in + fg: "#ff9a00" + bg: "#ff5d00" + - name: portal_out + fg: "#00a2ff" + bg: "#0065ff" +entities: + - name: telepad entrance + display: + attr: portal_in + char: "o" + description: + - Portal entrance + properties: [known] + - name: telepad exit + display: + attr: portal_out + char: "o" + description: + - Portal exit + properties: [known] +robots: + - name: base + dir: [1, 0] +known: [tree] +world: + upperleft: [-1, 1] + default: [blank] + palette: + '.': [grass] + 'B': [grass, null, base] + 'P': + cell: [grass, telepad entrance] + waypoint: + name: inportal + 'p': + cell: [grass, telepad exit] + waypoint: + name: outportal1 + 'q': + cell: [grass, telepad exit] + waypoint: + name: outportal2 + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + portals: + - entrance: inportal + exitInfo: + exit: outportal1 + - entrance: inportal + exitInfo: + exit: outportal2 + map: | + ┌────────┐ + │....B..q│ + │.p......│ + │....P...│ + └────────┘ diff --git a/data/scenarios/Testing/_Validation/1356-ambiguous-portal-exit.yaml b/data/scenarios/Testing/_Validation/1356-ambiguous-portal-exit.yaml new file mode 100644 index 000000000..0b104636f --- /dev/null +++ b/data/scenarios/Testing/_Validation/1356-ambiguous-portal-exit.yaml @@ -0,0 +1,60 @@ +version: 1 +name: Reject overlapping portal entrances +description: | + Two portals must not share the same entrance location +attrs: + - name: portal_in + fg: "#ff9a00" + bg: "#ff5d00" + - name: portal_out + fg: "#00a2ff" + bg: "#0065ff" +entities: + - name: telepad entrance + display: + attr: portal_in + char: "o" + description: + - Portal entrance + properties: [known] + - name: telepad exit + display: + attr: portal_out + char: "o" + description: + - Portal exit + properties: [known] +robots: + - name: base + dir: [1, 0] +known: [tree] +world: + upperleft: [1, -1] + default: [blank] + palette: + '.': [grass] + 'B': [grass, null, base] + 'P': + cell: [grass, telepad entrance] + waypoint: + name: inportal + 'p': + cell: [grass, telepad exit] + waypoint: + name: outportal + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + portals: + - entrance: inportal + exitInfo: + exit: outportal + map: | + ┌────────┐ + │....B..p│ + │.p......│ + │....P...│ + └────────┘ diff --git a/data/scenarios/Testing/_Validation/1356-waypoint-uniqueness-enforcement.yaml b/data/scenarios/Testing/_Validation/1356-waypoint-uniqueness-enforcement.yaml new file mode 100644 index 000000000..032f2457b --- /dev/null +++ b/data/scenarios/Testing/_Validation/1356-waypoint-uniqueness-enforcement.yaml @@ -0,0 +1,43 @@ +version: 1 +name: Waypoint uniqueness enforcement +description: | + Waypoints can optionally be required to be unique +attrs: + - name: portal_out + fg: "#00a2ff" + bg: "#0065ff" +entities: + - name: telepad exit + display: + attr: portal_out + char: "o" + description: + - Portal exit + properties: [known] +robots: + - name: base + dir: [1, 0] +known: [tree] +world: + upperleft: [1, -1] + default: [blank] + palette: + '.': [grass] + 'B': [grass, null, base] + 'p': + cell: [grass, telepad exit] + waypoint: + name: outportal + unique: True + '┌': [stone, upper left corner] + '┐': [stone, upper right corner] + '└': [stone, lower left corner] + '┘': [stone, lower right corner] + '─': [stone, horizontal wall] + '│': [stone, vertical wall] + map: | + ┌────────┐ + │....B..p│ + │.p......│ + │........│ + └────────┘ diff --git a/editors/emacs/swarm-mode.el b/editors/emacs/swarm-mode.el index 45628c1fb..79356b221 100644 --- a/editors/emacs/swarm-mode.el +++ b/editors/emacs/swarm-mode.el @@ -82,6 +82,7 @@ "time" "scout" "whereami" + "waypoint" "detect" "resonate" "density" diff --git a/editors/vscode/syntaxes/swarm.tmLanguage.json b/editors/vscode/syntaxes/swarm.tmLanguage.json index 63247768e..6a6e4e1a5 100644 --- a/editors/vscode/syntaxes/swarm.tmLanguage.json +++ b/editors/vscode/syntaxes/swarm.tmLanguage.json @@ -58,7 +58,7 @@ }, { "name": "keyword.other", - "match": "\\b(?i)(self|parent|base|if|inl|inr|case|fst|snd|force|undefined|fail|not|format|chars|split|charat|tochar|key|noop|wait|selfdestruct|move|push|stride|turn|grab|harvest|place|give|equip|unequip|make|has|equipped|count|drill|use|build|salvage|reprogram|say|listen|log|view|appear|create|halt|time|scout|whereami|detect|resonate|density|sniff|chirp|watch|surveil|heading|blocked|scan|upload|ishere|isempty|meet|meetall|whoami|setname|random|run|return|try|swap|atomic|instant|installkeyhandler|teleport|as|robotnamed|robotnumbered|knows)\\b" + "match": "\\b(?i)(self|parent|base|if|inl|inr|case|fst|snd|force|undefined|fail|not|format|chars|split|charat|tochar|key|noop|wait|selfdestruct|move|push|stride|turn|grab|harvest|place|give|equip|unequip|make|has|equipped|count|drill|use|build|salvage|reprogram|say|listen|log|view|appear|create|halt|time|scout|whereami|waypoint|detect|resonate|density|sniff|chirp|watch|surveil|heading|blocked|scan|upload|ishere|isempty|meet|meetall|whoami|setname|random|run|return|try|swap|atomic|instant|installkeyhandler|teleport|as|robotnamed|robotnumbered|knows)\\b" } ] }, diff --git a/src/Swarm/Game/Scenario.hs b/src/Swarm/Game/Scenario.hs index 6534ffc21..2818ea84d 100644 --- a/src/Swarm/Game/Scenario.hs +++ b/src/Swarm/Game/Scenario.hs @@ -60,12 +60,12 @@ import Swarm.Game.Failure.Render import Swarm.Game.Recipe import Swarm.Game.ResourceLoading (getDataFileNameSafe) import Swarm.Game.Robot (TRobot) -import Swarm.Game.Scenario.Cell import Swarm.Game.Scenario.Objective import Swarm.Game.Scenario.Objective.Validation import Swarm.Game.Scenario.RobotLookup import Swarm.Game.Scenario.Style -import Swarm.Game.Scenario.WorldDescription +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.WorldDescription import Swarm.Language.Pipeline (ProcessedTerm) import Swarm.Util (failT) import Swarm.Util.Lens (makeLensesNoSigs) diff --git a/src/Swarm/TUI/Editor/Area.hs b/src/Swarm/Game/Scenario/Topography/Area.hs similarity index 96% rename from src/Swarm/TUI/Editor/Area.hs rename to src/Swarm/Game/Scenario/Topography/Area.hs index 5072b822b..5339b54e9 100644 --- a/src/Swarm/TUI/Editor/Area.hs +++ b/src/Swarm/Game/Scenario/Topography/Area.hs @@ -1,6 +1,6 @@ {-# LANGUAGE OverloadedStrings #-} -module Swarm.TUI.Editor.Area where +module Swarm.Game.Scenario.Topography.Area where import Data.Int (Int32) import Data.List qualified as L diff --git a/src/Swarm/Game/Scenario/Cell.hs b/src/Swarm/Game/Scenario/Topography/Cell.hs similarity index 80% rename from src/Swarm/Game/Scenario/Cell.hs rename to src/Swarm/Game/Scenario/Topography/Cell.hs index 14c527c2e..583b1f7a1 100644 --- a/src/Swarm/Game/Scenario/Cell.hs +++ b/src/Swarm/Game/Scenario/Topography/Cell.hs @@ -3,9 +3,10 @@ -- | -- SPDX-License-Identifier: BSD-3-Clause -module Swarm.Game.Scenario.Cell ( +module Swarm.Game.Scenario.Topography.Cell ( PCell (..), Cell, + AugmentedCell (..), CellPaintDisplay, ) where @@ -16,9 +17,10 @@ import Data.Maybe (catMaybes, listToMaybe) import Data.Text (Text) import Data.Vector qualified as V import Data.Yaml as Y -import Swarm.Game.Entity -import Swarm.Game.Scenario.EntityFacade +import Swarm.Game.Entity hiding (empty) import Swarm.Game.Scenario.RobotLookup +import Swarm.Game.Scenario.Topography.EntityFacade +import Swarm.Game.Scenario.Topography.Navigation.Waypoint (WaypointConfig) import Swarm.Game.Terrain import Swarm.Util.Yaml @@ -41,6 +43,13 @@ data PCell e = Cell -- and optionally an entity and robot. type Cell = PCell Entity +-- | Supplements a cell with waypoint information +data AugmentedCell e = AugmentedCell + { waypointCfg :: Maybe WaypointConfig + , standardCell :: PCell e + } + deriving (Eq, Show) + -- | Re-usable serialization for variants of "PCell" mkPCellJson :: ToJSON b => (a -> b) -> PCell a -> Value mkPCellJson modifier x = @@ -54,10 +63,6 @@ mkPCellJson modifier x = instance ToJSON Cell where toJSON = mkPCellJson $ view entityName --- | Parse a tuple such as @[grass, rock, base]@ into a 'Cell'. The --- entity and robot, if present, are immediately looked up and --- converted into 'Entity' and 'TRobot' values. If they are not --- found, a parse error results. instance FromJSONE (EntityMap, RobotMap) Cell where parseJSONE = withArrayE "tuple" $ \v -> do let tup = V.toList v @@ -79,6 +84,21 @@ instance FromJSONE (EntityMap, RobotMap) Cell where return $ Cell terr ent robs +-- | Parse a tuple such as @[grass, rock, base]@ into a 'Cell'. The +-- entity and robot, if present, are immediately looked up and +-- converted into 'Entity' and 'TRobot' values. If they are not +-- found, a parse error results. +instance FromJSONE (EntityMap, RobotMap) (AugmentedCell Entity) where + parseJSONE x = case x of + Object v -> objParse v + z -> AugmentedCell Nothing <$> parseJSONE z + where + objParse v = + AugmentedCell + <$> liftE (v .:? "waypoint") + <*> v + ..: "cell" + ------------------------------------------------------------ -- World editor ------------------------------------------------------------ diff --git a/src/Swarm/Game/Scenario/EntityFacade.hs b/src/Swarm/Game/Scenario/Topography/EntityFacade.hs similarity index 88% rename from src/Swarm/Game/Scenario/EntityFacade.hs rename to src/Swarm/Game/Scenario/Topography/EntityFacade.hs index 1166bf6ba..47fb5c1f9 100644 --- a/src/Swarm/Game/Scenario/EntityFacade.hs +++ b/src/Swarm/Game/Scenario/Topography/EntityFacade.hs @@ -6,11 +6,11 @@ -- -- Useful for simplified serialization, debugging, -- and equality checking, particularly for the World Editor. -module Swarm.Game.Scenario.EntityFacade where +module Swarm.Game.Scenario.Topography.EntityFacade where -import Control.Lens hiding (from, (.=), (<.>)) +import Control.Lens ((^.)) import Data.Text (Text) -import Data.Yaml as Y +import Data.Yaml as Y (ToJSON (toJSON)) import Swarm.Game.Display (Display) import Swarm.Game.Entity qualified as E diff --git a/src/Swarm/Game/Scenario/Topography/Navigation/Portal.hs b/src/Swarm/Game/Scenario/Topography/Navigation/Portal.hs new file mode 100644 index 000000000..69cd5c937 --- /dev/null +++ b/src/Swarm/Game/Scenario/Topography/Navigation/Portal.hs @@ -0,0 +1,120 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.Game.Scenario.Topography.Navigation.Portal where + +import Control.Monad (forM, forM_, unless) +import Data.Aeson (FromJSON) +import Data.Int (Int32) +import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.List.NonEmpty qualified as NE +import Data.Map qualified as M +import Data.Maybe (listToMaybe) +import Data.Text (Text) +import Data.Text qualified as T +import GHC.Generics (Generic) +import Linear (V2) +import Swarm.Game.Location +import Swarm.Game.Scenario.Topography.Navigation.Waypoint +import Swarm.Util (binTuples, quote) + +-- | Note: The primary overworld shall use +-- the reserved name \"root\". +newtype SubworldName = SubworldName Text + deriving (Show, Eq, Ord, Generic, FromJSON) + +data Navigation = Navigation + { waypoints :: M.Map WaypointName (NonEmpty Location) + -- ^ Note that waypoints defined at the "root" level are still relative to + -- the top-left corner of the map rectangle; they are not in absolute world + -- coordinates (as with applying the "ul" offset). + , portals :: M.Map Location Location + } + deriving (Eq, Show) + +data PortalExit = PortalExit + { exit :: WaypointName + , subworldName :: Maybe SubworldName + -- ^ Note: 'Nothing' indicates that references a waypoint within the same subworld. + } + deriving (Show, Eq, Generic, FromJSON) + +data Portal = Portal + { entrance :: WaypointName + , exitInfo :: PortalExit + } + deriving (Show, Eq, Generic, FromJSON) + +failUponDuplication :: + (MonadFail m, Show a, Show b) => + String -> + M.Map a (NonEmpty b) -> + m () +failUponDuplication message binnedMap = + forM_ (listToMaybe $ M.toList duplicated) $ \(pIn, pOuts) -> + fail $ + unwords + [ "Waypoint" + , show pIn + , message + , intercalate ", " $ map show $ NE.toList pOuts + ] + where + duplicated = M.filter ((> 1) . NE.length) binnedMap + +-- | Enforces the following constraints: +-- * portals can have multiple entrances but only a single exit +-- * no two portals share the same entrance location +-- * global waypoint uniqueness when the "unique" flag is specified +validateLandmarks :: + (MonadFail m, Traversable t) => + V2 Int32 -> + [Originated Waypoint] -> + t Portal -> + m Navigation +validateLandmarks upperLeft unmergedWaypoints portalDefs = do + failUponDuplication "is required to be unique, but is duplicated in:" waypointsWithUniqueFlag + + -- TODO(#144) Currently ignores subworld references + nestedPortalPairs <- forM portalDefs $ \(Portal entranceName (PortalExit exitName@(WaypointName rawExitName) _)) -> do + -- Portals can have multiple entrances but only a single exit. + -- That is, the pairings of entries to exits must form a proper mathematical "function". + -- Multiple occurrences of entrance waypoints of a given name will replicate portal entrances. + entranceLocs <- getLocs entranceName + firstExitLoc :| otherExits <- getLocs exitName + unless (null otherExits) + . fail + . T.unpack + $ T.unwords ["Ambiguous exit waypoints named", quote rawExitName, "for portal"] + return $ map ((,extractLoc firstExitLoc) . extractLoc) $ NE.toList entranceLocs + + let reconciledPortalPairs = concat nestedPortalPairs + + -- Aside from the enforcement of single-exit per portal, we apply another layer of + -- enforcement to ensure that no two portals share the same entrance location + failUponDuplication "has overlapping portal entrances exiting to" $ + binTuples reconciledPortalPairs + + return $ Navigation bareWaypoints $ M.fromList reconciledPortalPairs + where + getLocs wpWrapper@(WaypointName rawName) = case M.lookup wpWrapper correctedWaypoints of + Nothing -> + fail $ + T.unpack $ + T.unwords + [ "No waypoint named" + , quote rawName + ] + Just xs -> return xs + + extractLoc (Originated _ (Waypoint _ loc)) = loc + correctedWaypoints = + binTuples $ + map + (\x -> (wpName $ wpConfig $ value x, fmap (offsetWaypoint upperLeft) x)) + unmergedWaypoints + bareWaypoints = M.map (NE.map extractLoc) correctedWaypoints + + waypointsWithUniqueFlag = M.filter (any $ wpUnique . wpConfig . value) correctedWaypoints diff --git a/src/Swarm/Game/Scenario/Topography/Navigation/Waypoint.hs b/src/Swarm/Game/Scenario/Topography/Navigation/Waypoint.hs new file mode 100644 index 000000000..dfd13628f --- /dev/null +++ b/src/Swarm/Game/Scenario/Topography/Navigation/Waypoint.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.Game.Scenario.Topography.Navigation.Waypoint where + +import Data.Int (Int32) +import Data.Text qualified as T +import Data.Yaml as Y +import GHC.Generics (Generic) +import Linear (V2 (..)) +import Swarm.Game.Location +import Swarm.Game.Scenario.Topography.Placement + +-- | Indicates which structure something came from +-- for debugging purposes. +data Originated a = Originated + { parent :: Maybe Placement + , value :: a + } + deriving (Show, Eq, Functor) + +newtype WaypointName = WaypointName T.Text + deriving (Show, Eq, Ord, Generic, FromJSON) + +-- | Metadata about a waypoint +data WaypointConfig = WaypointConfig + { wpName :: WaypointName + , wpUnique :: Bool + -- ^ Enforce global uniqueness of this waypoint + } + deriving (Show, Eq) + +parseWaypointConfig :: Object -> Parser WaypointConfig +parseWaypointConfig v = + WaypointConfig + <$> v .: "name" + <*> v .:? "unique" .!= False + +instance FromJSON WaypointConfig where + parseJSON = withObject "Waypoint Config" parseWaypointConfig + +-- | +-- A parent world shouldn't have to know the exact layout of a subworld +-- to specify where exactly a portal will deliver a robot to within the subworld. +-- Therefore, we define named waypoints in the subworld and the parent world +-- must reference them by name, rather than by coordinate. +data Waypoint = Waypoint + { wpConfig :: WaypointConfig + , wpLoc :: Location + } + deriving (Show, Eq) + +-- | JSON representation is flattened; all keys are at the same level, +-- in contrast with the underlying record. +instance FromJSON Waypoint where + parseJSON = withObject "Waypoint" $ \v -> + Waypoint + <$> parseWaypointConfig v + <*> v .: "loc" + +-- | Basically "fmap" for the "Location" field +modifyLocation :: + (Location -> Location) -> + Waypoint -> + Waypoint +modifyLocation f (Waypoint cfg originalLoc) = Waypoint cfg $ f originalLoc + +-- | Translation by a vector +offsetWaypoint :: + V2 Int32 -> + Waypoint -> + Waypoint +offsetWaypoint locOffset = modifyLocation (.+^ locOffset) diff --git a/src/Swarm/Game/Scenario/Topography/Placement.hs b/src/Swarm/Game/Scenario/Topography/Placement.hs new file mode 100644 index 000000000..45baa5129 --- /dev/null +++ b/src/Swarm/Game/Scenario/Topography/Placement.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.Game.Scenario.Topography.Placement where + +import Data.List (transpose) +import Data.Text (Text) +import Data.Yaml as Y +import GHC.Generics (Generic) +import Swarm.Game.Location +import Swarm.Game.Scenario.Topography.Area +import Swarm.Language.Syntax (AbsoluteDir (..)) + +newtype StructureName = StructureName Text + deriving (Eq, Ord, Show, Generic, FromJSON) + +-- | Orientation transformations are applied before translation. +data Orientation = Orientation + { up :: AbsoluteDir + -- ^ e.g. For "East", rotates 270 degrees. + , flipped :: Bool + -- ^ vertical flip, applied before rotation + } + deriving (Eq, Show) + +instance FromJSON Orientation where + parseJSON = withObject "structure orientation" $ \v -> do + Orientation + <$> v .:? "up" .!= DNorth + <*> v .:? "flip" .!= False + +defaultOrientation :: Orientation +defaultOrientation = Orientation DNorth False + +-- | This is the point-wise equivalent of "applyOrientationTransform" +reorientWaypoint :: Orientation -> AreaDimensions -> Location -> Location +reorientWaypoint (Orientation upDir shouldFlip) (AreaDimensions width height) = + rotational . flipping + where + transposeLoc (Location x y) = Location (-y) (-x) + flipV (Location x y) = Location x $ -(height - 1) - y + flipH (Location x y) = Location (width - 1 - x) y + flipping = if shouldFlip then flipV else id + rotational = case upDir of + DNorth -> id + DSouth -> flipH . flipV + DEast -> transposeLoc . flipV + DWest -> transposeLoc . flipH + +-- | affine transformation +applyOrientationTransform :: Orientation -> [[a]] -> [[a]] +applyOrientationTransform (Orientation upDir shouldFlip) = + rotational . flipping + where + flipV = reverse + flipping = if shouldFlip then flipV else id + rotational = case upDir of + DNorth -> id + DSouth -> transpose . flipV . transpose . flipV + DEast -> transpose . flipV + DWest -> flipV . transpose + +data Placement = Placement + { src :: StructureName + , offset :: Location + , orient :: Orientation + } + deriving (Eq, Show) + +instance FromJSON Placement where + parseJSON = withObject "structure placement" $ \v -> do + sName <- v .: "src" + Placement sName + <$> v .:? "offset" .!= origin + <*> v .:? "orient" .!= defaultOrientation diff --git a/src/Swarm/Game/Scenario/Structure.hs b/src/Swarm/Game/Scenario/Topography/Structure.hs similarity index 58% rename from src/Swarm/Game/Scenario/Structure.hs rename to src/Swarm/Game/Scenario/Topography/Structure.hs index 6bdb53060..da2bac566 100644 --- a/src/Swarm/Game/Scenario/Structure.hs +++ b/src/Swarm/Game/Scenario/Topography/Structure.hs @@ -2,31 +2,29 @@ -- | -- SPDX-License-Identifier: BSD-3-Clause -module Swarm.Game.Scenario.Structure where +module Swarm.Game.Scenario.Topography.Structure where import Control.Applicative ((<|>)) import Control.Arrow ((&&&)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap -import Data.List (transpose) +import Data.Coerce import Data.Map qualified as M -import Data.Maybe (mapMaybe) +import Data.Maybe (catMaybes, mapMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Yaml as Y -import GHC.Generics (Generic) import Swarm.Game.Entity import Swarm.Game.Location -import Swarm.Game.Scenario.Cell import Swarm.Game.Scenario.RobotLookup -import Swarm.Game.Scenario.WorldPalette -import Swarm.Language.Syntax (AbsoluteDir (..)) +import Swarm.Game.Scenario.Topography.Area +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.Navigation.Waypoint +import Swarm.Game.Scenario.Topography.Placement +import Swarm.Game.Scenario.Topography.WorldPalette import Swarm.Util.Yaml import Witch (into) -newtype StructureName = StructureName Text - deriving (Eq, Ord, Show, Generic, FromJSON) - data NamedStructure c = NamedStructure { name :: StructureName , structure :: PStructure c @@ -46,26 +44,11 @@ data PStructure c = Structure -- ^ structure definitions from parents shall be accessible by children , placements :: [Placement] -- ^ earlier placements will be overlaid on top of later placements in the YAML file + , waypoints :: [Waypoint] } deriving (Eq, Show) -newtype MergedStructure c = MergedStructure [[c]] - -data Orientation = Orientation - { up :: AbsoluteDir - , flipped :: Bool - -- ^ vertical flip, applied before rotation - } - deriving (Eq, Show) - -instance FromJSON Orientation where - parseJSON = withObject "structure orientation" $ \v -> do - Orientation - <$> (v .:? "up" .!= DNorth) - <*> (v .:? "flip" .!= False) - -defaultOrientation :: Orientation -defaultOrientation = Orientation DNorth False +data MergedStructure c = MergedStructure [[c]] [Originated Waypoint] -- | Destructively overlays one direct child structure -- upon the input structure. @@ -77,13 +60,21 @@ overlaySingleStructure :: MergedStructure (Maybe a) overlaySingleStructure inheritedStrucDefs - (Placement _ (Location colOffset rowOffset) orientation, struc) - (MergedStructure inputArea) = - MergedStructure $ zipWithPad mergeSingleRow inputArea paddedOverlayRows + (p@(Placement _ loc@(Location colOffset rowOffset) orientation), struc) + (MergedStructure inputArea inputWaypoints) = + MergedStructure mergedArea mergedWaypoints where + mergedArea = zipWithPad mergeSingleRow inputArea paddedOverlayRows + + placeWaypoint = + offsetWaypoint (coerce loc) + . modifyLocation (reorientWaypoint orientation $ getAreaDimensions overlayArea) + mergedWaypoints = inputWaypoints <> map (fmap placeWaypoint) overlayWaypoints + zipWithPad f a b = zipWith f a $ b <> repeat Nothing - MergedStructure overlayArea = mergeStructures inheritedStrucDefs struc - affineTransformedOverlay = getTransform orientation overlayArea + + MergedStructure overlayArea overlayWaypoints = mergeStructures inheritedStrucDefs (Just p) struc + affineTransformedOverlay = applyOrientationTransform orientation overlayArea mergeSingleRow inputRow maybeOverlayRow = zipWithPad (flip (<|>)) inputRow paddedSingleOverlayRow @@ -99,12 +90,18 @@ overlaySingleStructure then (replicate integralOffset Nothing <>) else drop $ abs integralOffset --- | Overlays all of the "child placements", such that the --- earlier children supersede the later ones (due to use of "foldr" instead of "foldl"). -mergeStructures :: M.Map StructureName (PStructure (Maybe a)) -> PStructure (Maybe a) -> MergedStructure (Maybe a) -mergeStructures inheritedStrucDefs (Structure origArea subStructures subPlacements) = - foldr (overlaySingleStructure structureMap) (MergedStructure origArea) overlays +-- | Overlays all of the "child placements", such that the children encountered earlier +-- in the YAML file supersede the later ones (due to use of "foldr" instead of "foldl"). +mergeStructures :: + M.Map StructureName (PStructure (Maybe a)) -> + Maybe Placement -> + PStructure (Maybe a) -> + MergedStructure (Maybe a) +mergeStructures inheritedStrucDefs parentPlacement (Structure origArea subStructures subPlacements subWaypoints) = + foldr (overlaySingleStructure structureMap) (MergedStructure origArea originatedWaypoints) overlays where + originatedWaypoints = map (Originated parentPlacement) subWaypoints + -- deeper definitions override the outer (toplevel) ones structureMap = M.union (M.fromList $ map (name &&& structure) subStructures) inheritedStrucDefs overlays = mapMaybe g subPlacements @@ -116,43 +113,30 @@ instance FromJSONE (EntityMap, RobotMap) (PStructure (Maybe (PCell Entity))) whe pal <- v ..:? "palette" ..!= WorldPalette mempty structureDefs <- v ..:? "structures" ..!= [] placementDefs <- liftE $ v .:? "placements" .!= [] + waypointDefs <- liftE $ v .:? "waypoints" .!= [] maybeMaskChar <- liftE $ v .:? "mask" - maskedArea <- liftE $ (v .:? "map" .!= "") >>= paintMap maybeMaskChar pal - return $ Structure maskedArea structureDefs placementDefs - --- | affine transformation -getTransform :: Orientation -> ([[a]] -> [[a]]) -getTransform (Orientation upDir shouldFlip) = - rotational . flipping - where - flipV = reverse - flipping = if shouldFlip then flipV else id - rotational = case upDir of - DNorth -> id - DSouth -> transpose . flipV . transpose . flipV - DEast -> transpose . flipV - DWest -> flipV . transpose - -data Placement = Placement - { src :: StructureName - , offset :: Location - , orient :: Orientation - } - deriving (Eq, Show) - -instance FromJSON Placement where - parseJSON = withObject "structure placement" $ \v -> do - sName <- v .: "src" - Placement sName - <$> (v .:? "offset" .!= origin) - <*> (v .:? "orient" .!= defaultOrientation) + (maskedArea, mapWaypoints) <- liftE $ (v .:? "map" .!= "") >>= paintMap maybeMaskChar pal + return $ Structure maskedArea structureDefs placementDefs $ waypointDefs <> mapWaypoints -- | "Paint" a world map using a 'WorldPalette', turning it from a raw -- string into a nested list of 'Cell' values by looking up each -- character in the palette, failing if any character in the raw map -- is not contained in the palette. -paintMap :: MonadFail m => Maybe Char -> WorldPalette e -> Text -> m [[Maybe (PCell e)]] -paintMap maskChar pal = readMap toCell +paintMap :: + MonadFail m => + Maybe Char -> + WorldPalette e -> + Text -> + m ([[Maybe (PCell e)]], [Waypoint]) +paintMap maskChar pal a = do + nestedLists <- readMap toCell a + let cells = map (map $ fmap standardCell) nestedLists + f i j maybeAugmentedCell = do + wpCfg <- waypointCfg =<< maybeAugmentedCell + return . Waypoint wpCfg . Location j $ negate i + wps = concat $ zipWith (\i -> catMaybes . zipWith (f i) [0 ..]) [0 ..] nestedLists + + return (cells, wps) where toCell c = if Just c == maskChar diff --git a/src/Swarm/Game/Scenario/WorldDescription.hs b/src/Swarm/Game/Scenario/Topography/WorldDescription.hs similarity index 69% rename from src/Swarm/Game/Scenario/WorldDescription.hs rename to src/Swarm/Game/Scenario/Topography/WorldDescription.hs index f5eeab903..2ebfc979f 100644 --- a/src/Swarm/Game/Scenario/WorldDescription.hs +++ b/src/Swarm/Game/Scenario/Topography/WorldDescription.hs @@ -3,17 +3,19 @@ -- | -- SPDX-License-Identifier: BSD-3-Clause -module Swarm.Game.Scenario.WorldDescription where +module Swarm.Game.Scenario.Topography.WorldDescription where +import Data.Coerce import Data.Maybe (catMaybes) import Data.Yaml as Y import Swarm.Game.Entity import Swarm.Game.Location -import Swarm.Game.Scenario.Cell -import Swarm.Game.Scenario.EntityFacade import Swarm.Game.Scenario.RobotLookup -import Swarm.Game.Scenario.Structure qualified as Structure -import Swarm.Game.Scenario.WorldPalette +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.EntityFacade +import Swarm.Game.Scenario.Topography.Navigation.Portal +import Swarm.Game.Scenario.Topography.Structure qualified as Structure +import Swarm.Game.Scenario.Topography.WorldPalette import Swarm.Util.Yaml ------------------------------------------------------------ @@ -30,6 +32,7 @@ data PWorldDescription e = WorldDescription , palette :: WorldPalette e , ul :: Location , area :: [[PCell e]] + , navigation :: Navigation } deriving (Eq, Show) @@ -39,19 +42,26 @@ instance FromJSONE (EntityMap, RobotMap) WorldDescription where parseJSONE = withObjectE "world description" $ \v -> do pal <- v ..:? "palette" ..!= WorldPalette mempty structureDefs <- v ..:? "structures" ..!= [] + waypointDefs <- liftE $ v .:? "waypoints" .!= [] + portalDefs <- liftE $ v .:? "portals" .!= [] placementDefs <- liftE $ v .:? "placements" .!= [] - initialArea <- liftE ((v .:? "map" .!= "") >>= Structure.paintMap Nothing pal) + (initialArea, mapWaypoints) <- liftE ((v .:? "map" .!= "") >>= Structure.paintMap Nothing pal) - let struc = Structure.Structure initialArea structureDefs placementDefs - Structure.MergedStructure mergedArea = Structure.mergeStructures mempty struc + upperLeft <- liftE (v .:? "upperleft" .!= origin) + + let struc = Structure.Structure initialArea structureDefs placementDefs $ waypointDefs <> mapWaypoints + Structure.MergedStructure mergedArea unmergedWaypoints = Structure.mergeStructures mempty Nothing struc + + validatedLandmarks <- validateLandmarks (coerce upperLeft) unmergedWaypoints portalDefs WorldDescription <$> v ..:? "default" <*> liftE (v .:? "offset" .!= False) <*> liftE (v .:? "scrollable" .!= True) <*> pure pal - <*> liftE (v .:? "upperleft" .!= origin) + <*> pure upperLeft <*> pure (map catMaybes mergedArea) -- Root-level map has no transparent cells. + <*> pure validatedLandmarks ------------------------------------------------------------ -- World editor diff --git a/src/Swarm/Game/Scenario/WorldPalette.hs b/src/Swarm/Game/Scenario/Topography/WorldPalette.hs similarity index 94% rename from src/Swarm/Game/Scenario/WorldPalette.hs rename to src/Swarm/Game/Scenario/Topography/WorldPalette.hs index aa183c505..691f846f9 100644 --- a/src/Swarm/Game/Scenario/WorldPalette.hs +++ b/src/Swarm/Game/Scenario/Topography/WorldPalette.hs @@ -2,7 +2,7 @@ -- | -- SPDX-License-Identifier: BSD-3-Clause -module Swarm.Game.Scenario.WorldPalette where +module Swarm.Game.Scenario.Topography.WorldPalette where import Control.Arrow (first) import Control.Lens hiding (from, (.=), (<.>)) @@ -14,15 +14,15 @@ import Data.Text (Text) import Data.Text qualified as T import Data.Tuple (swap) import Swarm.Game.Entity -import Swarm.Game.Scenario.Cell -import Swarm.Game.Scenario.EntityFacade import Swarm.Game.Scenario.RobotLookup +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.EntityFacade import Swarm.Game.Terrain (TerrainType) import Swarm.Util.Yaml -- | A world palette maps characters to 'Cell' values. newtype WorldPalette e = WorldPalette - {unPalette :: KeyMap (PCell e)} + {unPalette :: KeyMap (AugmentedCell e)} deriving (Eq, Show) instance FromJSONE (EntityMap, RobotMap) (WorldPalette Entity) where @@ -100,7 +100,7 @@ prepForJson (WorldPalette suggestedPalette) cellGrid = where preassignments :: [(Char, TerrainWith EntityFacade)] preassignments = - map (first T.head . fmap cellToTerrainPair) $ + map (first T.head . fmap (cellToTerrainPair . standardCell)) $ M.toList $ KM.toMapText suggestedPalette diff --git a/src/Swarm/Game/State.hs b/src/Swarm/Game/State.hs index ea7078d41..3a6922e60 100644 --- a/src/Swarm/Game/State.hs +++ b/src/Swarm/Game/State.hs @@ -60,6 +60,7 @@ module Swarm.Game.State ( recipesReq, currentScenarioPath, knownEntities, + worldNavigation, world, worldScrollable, viewCenterRule, @@ -169,6 +170,7 @@ import Swarm.Game.Recipe ( import Swarm.Game.Robot import Swarm.Game.Scenario.Objective import Swarm.Game.Scenario.Status +import Swarm.Game.Scenario.Topography.Navigation.Portal (Navigation (..)) import Swarm.Game.ScenarioInfo import Swarm.Game.Terrain (TerrainType (..)) import Swarm.Game.World (Coords (..), WorldFun (..), locToCoords, worldFunFromArray) @@ -398,6 +400,7 @@ data GameState = GameState , _recipesReq :: IntMap [Recipe Entity] , _currentScenarioPath :: Maybe FilePath , _knownEntities :: [Text] + , _worldNavigation :: Navigation , _world :: W.World Int Entity , _worldScrollable :: Bool , _viewCenterRule :: ViewCenterRule @@ -559,6 +562,10 @@ currentScenarioPath :: Lens' GameState (Maybe FilePath) -- robots know what they are without having to scan them. knownEntities :: Lens' GameState [Text] +-- | Includes a Map of named locations and an +-- "Edge list" (graph) that maps portal entrances to exits +worldNavigation :: Lens' GameState Navigation + -- | The current state of the world (terrain and entities only; robots -- are stored in the 'robotMap'). Int is used instead of -- TerrainType because we need to be able to store terrain values in @@ -995,6 +1002,7 @@ initGameState gsc = , _recipesReq = reqRecipeMap (initRecipes gsc) , _currentScenarioPath = Nothing , _knownEntities = [] + , _worldNavigation = Navigation mempty mempty , _world = W.emptyWorld (fromEnum StoneT) , _worldScrollable = True , _viewCenterRule = VCRobot 0 @@ -1051,6 +1059,7 @@ scenarioToGameState scenario (LaunchParams (Identity userSeed) (Identity toRun)) & recipesIn %~ addRecipesWith inRecipeMap & recipesReq %~ addRecipesWith reqRecipeMap & knownEntities .~ scenario ^. scenarioKnown + & worldNavigation .~ navigation (scenario ^. scenarioWorld) & world .~ theWorld theSeed & worldScrollable .~ scenario ^. scenarioWorld . to scrollable & viewCenterRule .~ VCRobot baseID diff --git a/src/Swarm/Game/Step.hs b/src/Swarm/Game/Step.hs index 241c59a29..83bcdc352 100644 --- a/src/Swarm/Game/Step.hs +++ b/src/Swarm/Game/Step.hs @@ -47,6 +47,7 @@ import Data.IntMap qualified as IM import Data.IntSet qualified as IS import Data.List (find, sortOn) import Data.List qualified as L +import Data.List.NonEmpty qualified as NE import Data.Map qualified as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Ord (Down (Down)) @@ -74,6 +75,8 @@ import Swarm.Game.ResourceLoading (getDataFileNameSafe) import Swarm.Game.Robot import Swarm.Game.Scenario.Objective qualified as OB import Swarm.Game.Scenario.Objective.WinCheck qualified as WC +import Swarm.Game.Scenario.Topography.Navigation.Portal (Navigation (..)) +import Swarm.Game.Scenario.Topography.Navigation.Waypoint (WaypointName (..)) import Swarm.Game.State import Swarm.Game.Value import Swarm.Game.World qualified as W @@ -1398,6 +1401,13 @@ execConst c vs s k = do Whereami -> do loc <- use robotLocation return $ Out (asValue loc) s k + Waypoint -> case vs of + [VText name, VInt idx] -> do + lm <- use worldNavigation + case M.lookup (WaypointName name) (waypoints lm) of + Nothing -> throwError $ CmdFailed Waypoint (T.unwords ["No waypoint named", name]) Nothing + Just wps -> return $ Out (asValue (NE.length wps, indexWrapNonEmpty wps idx)) s k + _ -> badConst Detect -> case vs of [VText name, VRect x1 y1 x2 y2] -> do loc <- use robotLocation @@ -2669,6 +2679,7 @@ provisionChild childID toEquip toGive = do -- 'robotsByLocation' map, so we can always look up robots by -- location. This should be the /only/ way to update the location -- of a robot. +-- Also implements teleportation by portals. updateRobotLocation :: (HasRobotStepState sig m) => Location -> @@ -2677,12 +2688,17 @@ updateRobotLocation :: updateRobotLocation oldLoc newLoc | oldLoc == newLoc = return () | otherwise = do + newlocWithPortal <- applyPortal newLoc rid <- use robotID robotsByLocation . at oldLoc %= deleteOne rid - robotsByLocation . at newLoc . non Empty %= IS.insert rid - modify (unsafeSetRobotLocation newLoc) + robotsByLocation . at newlocWithPortal . non Empty %= IS.insert rid + modify (unsafeSetRobotLocation newlocWithPortal) flagRedraw where + applyPortal loc = do + lms <- use worldNavigation + return $ M.findWithDefault loc loc $ portals lms + -- Make sure empty sets don't hang around in the -- robotsByLocation map. We don't want a key with an -- empty set at every location any robot has ever diff --git a/src/Swarm/Game/Value.hs b/src/Swarm/Game/Value.hs index 4bc0cb75d..954f3c085 100644 --- a/src/Swarm/Game/Value.hs +++ b/src/Swarm/Game/Value.hs @@ -35,8 +35,17 @@ class Valuable a where instance Valuable Int32 where asValue = VInt . fromIntegral +instance Valuable Int where + asValue = VInt . fromIntegral + instance (Valuable a) => Valuable (V2 a) where - asValue (V2 x y) = VPair (asValue x) (asValue y) + asValue (V2 x y) = asValue (x, y) + +instance (Valuable a, Valuable b) => Valuable (a, b) where + asValue (x, y) = VPair (asValue x) (asValue y) + +instance Valuable Location where + asValue (Location x y) = VPair (asValue x) (asValue y) instance Valuable Entity where asValue = VText . view entityName @@ -44,9 +53,6 @@ instance Valuable Entity where instance Valuable Robot where asValue = VRobot . view robotID -instance Valuable Location where - asValue (Location x y) = VPair (VInt (fromIntegral x)) (VInt (fromIntegral y)) - instance (Valuable a) => Valuable (Maybe a) where asValue Nothing = VInj False VUnit asValue (Just x) = VInj True $ asValue x diff --git a/src/Swarm/Language/Capability.hs b/src/Swarm/Language/Capability.hs index 61498e0bd..432727e3e 100644 --- a/src/Swarm/Language/Capability.hs +++ b/src/Swarm/Language/Capability.hs @@ -246,6 +246,7 @@ constCaps = \case Wait -> Just CTimerel Scout -> Just CRecondir Whereami -> Just CSenseloc + Waypoint -> Just CGod Detect -> Just CDetectloc Resonate -> Just CDetectcount Density -> Just CDetectcount diff --git a/src/Swarm/Language/Syntax.hs b/src/Swarm/Language/Syntax.hs index fc74f6715..865af8bb1 100644 --- a/src/Swarm/Language/Syntax.hs +++ b/src/Swarm/Language/Syntax.hs @@ -269,6 +269,8 @@ data Const Scout | -- | Get the current x, y coordinates Whereami + | -- | Get the x, y coordinates of a named waypoint, by index + Waypoint | -- | Locate the closest instance of a given entity within the rectangle -- specified by opposite corners, relative to the current location. Detect @@ -664,6 +666,12 @@ constInfo c = case c of , T.unwords ["Has a max range of", T.pack $ show maxScoutRange, "units."] ] Whereami -> command 0 Intangible "Get the current x and y coordinates." + Waypoint -> + command 2 Intangible . doc "Get the x, y coordinates of a named waypoint, by index" $ + [ "Since waypoint names can have plural multiplicity, returns a tuple of (count, (x, y))." + , "The supplied index will be wrapped automatically, modulo the waypoint count." + , "A robot can use the count to know whether they have iterated over the full waypoint circuit." + ] Detect -> command 2 Intangible . doc "Detect an entity within a rectangle." $ ["Locate the closest instance of a given entity within the rectangle specified by opposite corners, relative to the current location."] diff --git a/src/Swarm/Language/Typecheck.hs b/src/Swarm/Language/Typecheck.hs index 05175b8ec..0e38ec476 100644 --- a/src/Swarm/Language/Typecheck.hs +++ b/src/Swarm/Language/Typecheck.hs @@ -731,6 +731,7 @@ inferConst c = case c of Time -> [tyQ| cmd int |] Scout -> [tyQ| dir -> cmd bool |] Whereami -> [tyQ| cmd (int * int) |] + Waypoint -> [tyQ| text -> int -> cmd (int * (int * int)) |] Detect -> [tyQ| text -> ((int * int) * (int * int)) -> cmd (unit + (int * int)) |] Resonate -> [tyQ| text -> ((int * int) * (int * int)) -> cmd int |] Density -> [tyQ| ((int * int) * (int * int)) -> cmd int |] diff --git a/src/Swarm/TUI/Editor/Controller.hs b/src/Swarm/TUI/Editor/Controller.hs index f0df7c482..f31440f68 100644 --- a/src/Swarm/TUI/Editor/Controller.hs +++ b/src/Swarm/TUI/Editor/Controller.hs @@ -14,7 +14,7 @@ import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) import Data.Map qualified as M import Data.Yaml qualified as Y import Graphics.Vty qualified as V -import Swarm.Game.Scenario.EntityFacade +import Swarm.Game.Scenario.Topography.EntityFacade import Swarm.Game.State import Swarm.Game.World qualified as W import Swarm.TUI.Controller.Util diff --git a/src/Swarm/TUI/Editor/Json.hs b/src/Swarm/TUI/Editor/Json.hs index 24e33fdc9..4b55144f5 100644 --- a/src/Swarm/TUI/Editor/Json.hs +++ b/src/Swarm/TUI/Editor/Json.hs @@ -4,7 +4,7 @@ import Data.Text (Text) import Data.Yaml as Y import GHC.Generics (Generic) import Swarm.Game.Entity (Entity) -import Swarm.Game.Scenario.WorldDescription +import Swarm.Game.Scenario.Topography.WorldDescription data SkeletonScenario = SkeletonScenario { version :: Int diff --git a/src/Swarm/TUI/Editor/Model.hs b/src/Swarm/TUI/Editor/Model.hs index 2745349ce..7b50f13fd 100644 --- a/src/Swarm/TUI/Editor/Model.hs +++ b/src/Swarm/TUI/Editor/Model.hs @@ -10,8 +10,8 @@ import Data.Map qualified as M import Data.Vector qualified as V import Swarm.Game.Display (Display) import Swarm.Game.Entity qualified as E -import Swarm.Game.Scenario.EntityFacade -import Swarm.Game.Scenario.WorldPalette +import Swarm.Game.Scenario.Topography.EntityFacade +import Swarm.Game.Scenario.Topography.WorldPalette import Swarm.Game.Terrain (TerrainType) import Swarm.Game.World qualified as W import Swarm.TUI.Model.Name diff --git a/src/Swarm/TUI/Editor/Palette.hs b/src/Swarm/TUI/Editor/Palette.hs index 875b3ceed..4f2f42152 100644 --- a/src/Swarm/TUI/Editor/Palette.hs +++ b/src/Swarm/TUI/Editor/Palette.hs @@ -21,18 +21,20 @@ import Swarm.Game.Display (Display, defaultChar) import Swarm.Game.Entity (entitiesByName) import Swarm.Game.Location import Swarm.Game.Scenario -import Swarm.Game.Scenario.Cell -import Swarm.Game.Scenario.EntityFacade -import Swarm.Game.Scenario.WorldPalette +import Swarm.Game.Scenario.Topography.Area (AreaDimensions (..), getAreaDimensions) +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.EntityFacade +import Swarm.Game.Scenario.Topography.Navigation.Portal (Navigation (..)) +import Swarm.Game.Scenario.Topography.WorldPalette import Swarm.Game.Terrain (TerrainType (BlankT), getTerrainDefaultPaletteChar) -import Swarm.TUI.Editor.Area (AreaDimensions (..), getAreaDimensions) import Swarm.TUI.Editor.Json (SkeletonScenario (SkeletonScenario)) import Swarm.Util (binTuples, histogram) import Swarm.Util qualified as U -makeSuggestedPalette :: Maybe Scenario -> [[CellPaintDisplay]] -> KM.KeyMap CellPaintDisplay +makeSuggestedPalette :: Maybe Scenario -> [[CellPaintDisplay]] -> KM.KeyMap (AugmentedCell EntityFacade) makeSuggestedPalette maybeOriginalScenario cellGrid = KM.fromMapText + . M.map (AugmentedCell Nothing) . M.fromList . M.elems -- NOTE: the left-most maps take precedence! @@ -83,7 +85,7 @@ makeSuggestedPalette maybeOriginalScenario cellGrid = originalPalette :: KM.KeyMap CellPaintDisplay originalPalette = - KM.map toCellPaintDisplay $ + KM.map (toCellPaintDisplay . standardCell) $ maybe mempty (unPalette . palette . (^. scenarioWorld)) maybeOriginalScenario pairsWithDisplays :: Map (TerrainWith EntityName) (T.Text, CellPaintDisplay) @@ -125,6 +127,7 @@ constructScenario maybeOriginalScenario cellGrid = , palette = WorldPalette suggestedPalette , ul = upperLeftCoord , area = cellGrid + , navigation = Navigation mempty mempty } suggestedPalette = makeSuggestedPalette maybeOriginalScenario cellGrid diff --git a/src/Swarm/TUI/Editor/Util.hs b/src/Swarm/TUI/Editor/Util.hs index 8d8a3a3e5..86a3d8861 100644 --- a/src/Swarm/TUI/Editor/Util.hs +++ b/src/Swarm/TUI/Editor/Util.hs @@ -9,12 +9,12 @@ import Data.Map qualified as Map import Data.Maybe qualified as Maybe import Data.Vector qualified as V import Swarm.Game.Entity -import Swarm.Game.Scenario.Cell -import Swarm.Game.Scenario.EntityFacade -import Swarm.Game.Scenario.WorldDescription +import Swarm.Game.Scenario.Topography.Area qualified as EA +import Swarm.Game.Scenario.Topography.Cell +import Swarm.Game.Scenario.Topography.EntityFacade +import Swarm.Game.Scenario.Topography.WorldDescription import Swarm.Game.Terrain (TerrainType) import Swarm.Game.World qualified as W -import Swarm.TUI.Editor.Area qualified as EA import Swarm.TUI.Editor.Model import Swarm.TUI.Model diff --git a/src/Swarm/TUI/Editor/View.hs b/src/Swarm/TUI/Editor/View.hs index bf4d36ab9..cee307b69 100644 --- a/src/Swarm/TUI/Editor/View.hs +++ b/src/Swarm/TUI/Editor/View.hs @@ -6,12 +6,12 @@ import Brick.Widgets.Center (hCenter) import Brick.Widgets.List qualified as BL import Control.Lens hiding (Const, from) import Data.List qualified as L -import Swarm.Game.Scenario.EntityFacade +import Swarm.Game.Scenario.Topography.Area qualified as EA +import Swarm.Game.Scenario.Topography.EntityFacade import Swarm.Game.Terrain (TerrainType) import Swarm.Game.World qualified as W import Swarm.TUI.Attr import Swarm.TUI.Border -import Swarm.TUI.Editor.Area qualified as EA import Swarm.TUI.Editor.Model import Swarm.TUI.Model import Swarm.TUI.Model.Name diff --git a/src/Swarm/TUI/View/CellDisplay.hs b/src/Swarm/TUI/View/CellDisplay.hs index ff4fba556..36b3712a4 100644 --- a/src/Swarm/TUI/View/CellDisplay.hs +++ b/src/Swarm/TUI/View/CellDisplay.hs @@ -21,7 +21,7 @@ import Swarm.Game.CESK (TickNumber (..)) import Swarm.Game.Display import Swarm.Game.Entity import Swarm.Game.Robot -import Swarm.Game.Scenario.EntityFacade +import Swarm.Game.Scenario.Topography.EntityFacade import Swarm.Game.State import Swarm.Game.Terrain import Swarm.Game.World qualified as W diff --git a/src/Swarm/Util.hs b/src/Swarm/Util.hs index 31969a67c..12883cff8 100644 --- a/src/Swarm/Util.hs +++ b/src/Swarm/Util.hs @@ -15,6 +15,7 @@ module Swarm.Util ( maximum0, cycleEnum, listEnums, + indexWrapNonEmpty, uniq, binTuples, histogram, @@ -137,6 +138,13 @@ cycleEnum e listEnums :: (Enum e, Bounded e) => [e] listEnums = [minBound .. maxBound] +-- | Guaranteed to yield an element of the list +indexWrapNonEmpty :: Integral b => NonEmpty a -> b -> a +indexWrapNonEmpty list idx = + NE.toList list !! fromIntegral wrappedIdx + where + wrappedIdx = idx `mod` fromIntegral (NE.length list) + -- | Drop repeated elements that are adjacent to each other. -- -- >>> uniq [] diff --git a/src/Swarm/Util/Yaml.hs b/src/Swarm/Util/Yaml.hs index 16de73c31..0c5dcd101 100644 --- a/src/Swarm/Util/Yaml.hs +++ b/src/Swarm/Util/Yaml.hs @@ -24,6 +24,7 @@ module Swarm.Util.Yaml ( withArrayE, ) where +import Control.Applicative (Alternative) import Control.Monad.Reader import Data.Aeson.Key (fromText) import Data.Aeson.Types (explicitParseField, explicitParseFieldMaybe) @@ -42,7 +43,7 @@ import Swarm.Util (failT, showT) -- value of type @e@. newtype With e f a = E {runE :: e -> f a} deriving (Functor) - deriving (Applicative, Monad, MonadFail) via (ReaderT e f) + deriving (Applicative, Monad, MonadFail, Alternative) via (ReaderT e f) -- | A 'ParserE' is a YAML 'Parser' that can also depend on knowing an -- value of type @e@. The @E@ used to stand for @EntityMap@, but now diff --git a/swarm.cabal b/swarm.cabal index 6ea852efc..1264ba38c 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -102,12 +102,11 @@ library Swarm.Game.ResourceLoading Swarm.Game.Robot Swarm.Game.Scenario - Swarm.Game.Scenario.Cell + Swarm.Game.Scenario.Topography.Cell Swarm.TUI.Launch.Controller Swarm.TUI.Launch.Model Swarm.TUI.Launch.Prep Swarm.TUI.Launch.View - Swarm.Game.Scenario.EntityFacade Swarm.Game.Scenario.Objective Swarm.Game.Scenario.Objective.Graph Swarm.Game.Scenario.Objective.Logic @@ -119,10 +118,14 @@ library Swarm.Game.Scenario.Scoring.ConcreteMetrics Swarm.Game.Scenario.Scoring.GenericMetrics Swarm.Game.Scenario.Status - Swarm.Game.Scenario.Structure Swarm.Game.Scenario.Style - Swarm.Game.Scenario.WorldDescription - Swarm.Game.Scenario.WorldPalette + Swarm.Game.Scenario.Topography.EntityFacade + Swarm.Game.Scenario.Topography.Navigation.Portal + Swarm.Game.Scenario.Topography.Navigation.Waypoint + Swarm.Game.Scenario.Topography.Placement + Swarm.Game.Scenario.Topography.Structure + Swarm.Game.Scenario.Topography.WorldDescription + Swarm.Game.Scenario.Topography.WorldPalette Swarm.Game.ScenarioInfo Swarm.Game.State Swarm.Game.Step @@ -153,7 +156,7 @@ library Swarm.ReadableIORef Swarm.TUI.Attr Swarm.TUI.Border - Swarm.TUI.Editor.Area + Swarm.Game.Scenario.Topography.Area Swarm.TUI.Editor.Controller Swarm.TUI.Editor.Json Swarm.TUI.Editor.Masking diff --git a/test/integration/Main.hs b/test/integration/Main.hs index a5d659d33..933677b1b 100644 --- a/test/integration/Main.hs +++ b/test/integration/Main.hs @@ -296,6 +296,7 @@ testScenarioSolution _ci _em = , testSolution Default "Testing/1234-push-command" , testSolution Default "Testing/1256-halt-command" , testSolution Default "Testing/1295-density-command" + , testSolution Default "Testing/1356-portals/portals-flip-and-rotate.yaml" ] ] where