diff --git a/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt b/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt index aaf748ade..89e85e243 100644 --- a/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt +++ b/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt @@ -16,4 +16,5 @@ 1644-rotated-preplacement-recognition.yaml 2115-encroaching-upon-exterior-transparent-cells.yaml 2115-encroaching-upon-interior-transparent-cells.yaml -9999-piecewise-lines.yaml +2201-piecewise-lines.yaml +2201-piecewise-solid.yaml diff --git a/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-lines.yaml b/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-lines.yaml new file mode 100644 index 000000000..c8cd1b219 --- /dev/null +++ b/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-lines.yaml @@ -0,0 +1,106 @@ +version: 1 +name: Structure recognition - piecewise lines +description: | + General solution for transparency +creative: false +objectives: + - teaser: Recognize structure + goal: + - | + `spaceship`{=structure} structure should be recognized upon completion, + even with an extraneous entity within its bounds. + condition: | + def isRight = \x. case x (\_. false) (\_. true); end; + foundStructure <- structure "spaceship" 0; + return $ isRight foundStructure; +robots: + - name: base + dir: east + devices: + - ADT calculator + - blueprint + - fast grabber + - logger + - treads + inventory: + - [1, rock] +solution: | + move; move; move; move; move; move; move; + swap "rock"; +structures: + - name: fragment + recognize: [north] + structure: + palette: + 'z': [stone, pixel (R)] + 'w': [stone, pixel (B)] + 'x': [stone, rock] + 'y': [stone, mountain] + mask: '.' + map: | + zw.xy + - name: spaceship + recognize: [north] + structure: + palette: + 'p': [stone, board] + 'x': [stone, rock] + 'y': [stone, mountain] + 'z': [stone, pixel (R)] + 'w': [stone, pixel (B)] + 'q': [stone, pixel (G)] + mask: '.' + map: | + q....xy.zw.xy + qq....ppp.... + q....xy.xy.qq + - name: friendship + recognize: [north] + structure: + palette: + 'x': [stone, rock] + 'y': [stone, mountain] + 'z': [stone, pixel (R)] + 'w': [stone, pixel (B)] + 'q': [stone, pixel (G)] + mask: '.' + map: | + qqq....... + qqq....... + qqq....... + qqq....... + ..xy.zw.xy + qqq....... + - name: damage + description: A single-cell overwrite of the spaceship + structure: + palette: + 't': [stone, tree] + map: | + t + - name: modified ship + description: A spaceship with a single cell replaced by a `tree`{=entity} + structure: + placements: + - src: spaceship + - src: damage + offset: [5, 0] + map: "" +known: [board, mountain, rock, tree, pixel (R), pixel (B)] +world: + dsl: | + {blank} + palette: + '.': [grass, erase] + 'B': [grass, erase, base] + 'p': + structure: + name: modified ship + cell: [grass] + upperleft: [100, -100] + map: | + .......... + B.p....... + .......... + .......... + .......... diff --git a/data/scenarios/Testing/1575-structure-recognizer/9999-piecewise-lines.yaml b/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml similarity index 90% rename from data/scenarios/Testing/1575-structure-recognizer/9999-piecewise-lines.yaml rename to data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml index 60d62b78a..260f41476 100644 --- a/data/scenarios/Testing/1575-structure-recognizer/9999-piecewise-lines.yaml +++ b/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml @@ -34,12 +34,11 @@ structures: palette: 'p': [stone, board] 'x': [stone, rock] - 'y': [stone, mountain] mask: '.' map: | - xy.xy - .ppp. - xy.xy + xxx + ppp + xxx - name: damage description: A single-cell overwrite of the spaceship structure: @@ -67,8 +66,8 @@ world: cell: [grass] upperleft: [0, 0] map: | - .......... - B..p...... - .......... - .......... - .......... + ......... + B..p..... + ......... + ......... + ......... diff --git a/src/swarm-engine/Swarm/Game/State/Initialize.hs b/src/swarm-engine/Swarm/Game/State/Initialize.hs index 4e5ebd05b..d3f1de0c3 100644 --- a/src/swarm-engine/Swarm/Game/State/Initialize.hs +++ b/src/swarm-engine/Swarm/Game/State/Initialize.hs @@ -15,7 +15,6 @@ import Control.Effect.Lens (view) import Control.Effect.Lift (Has) import Control.Effect.State (State) import Control.Lens hiding (Const, use, uses, view, (%=), (+=), (.=), (<+=), (<<.=)) -import Data.Foldable.Extra (allM) import Data.IntMap qualified as IM import Data.List (partition) import Data.List.NonEmpty (NonEmpty) @@ -25,7 +24,6 @@ import Data.Map qualified as M import Data.Maybe (isNothing) import Data.Set qualified as S import Data.Text (Text) -import Linear (V2 (..)) import Swarm.Game.CESK (finalValue, initMachine) import Swarm.Game.Device (getCapabilitySet, getMap) import Swarm.Game.Entity @@ -50,7 +48,7 @@ import Swarm.Game.State import Swarm.Game.State.Landscape (mkLandscape) import Swarm.Game.State.Robot (setRobotInfo) import Swarm.Game.State.Substate -import Swarm.Game.Universe as U (offsetBy) +import Swarm.Game.Step.Util (adaptGameState) import Swarm.Game.World.Gen (Seed) import Swarm.Language.Capability (constCaps) import Swarm.Language.Syntax (allConst, erase) @@ -183,7 +181,8 @@ mkRecognizer :: StaticStructureInfo Cell -> m (StructureRecognizer (Maybe Cell) Entity) mkRecognizer structInfo@(StaticStructureInfo structDefs _) = do - foundIntact <- mapM (sequenceA . (id &&& ensureStructureIntact)) allPlaced + foundIntact <- mapM (sequenceA . (id &&& adaptGameState . ensureStructureIntact mtlEntityAt)) allPlaced + let fs = populateStaticFoundStructures . map fst . filter snd $ foundIntact return $ StructureRecognizer @@ -199,24 +198,6 @@ mkRecognizer structInfo@(StaticStructureInfo structDefs _) = do ((getName . originalDefinition . structureWithGrid) x) (upperLeftCorner x) --- | Matches definitions against the placements. --- Fails fast (short-circuits) if a non-matching --- cell is encountered. -ensureStructureIntact :: - (Has (State GameState) sig m) => - FoundStructure (Maybe Cell) Entity -> - m Bool -ensureStructureIntact (FoundStructure (StructureWithGrid _ _ _ grid) upperLeft) = - allM outer $ zip [0 ..] grid - where - outer (y, row) = allM (inner y) $ zip [0 ..] row - inner y (x, maybeTemplateEntity) = case maybeTemplateEntity of - Nothing -> return True - Just _ -> - fmap (== maybeTemplateEntity) $ - entityAt $ - upperLeft `offsetBy` V2 x (negate y) - buildTagMap :: EntityMap -> Map Text (NonEmpty EntityName) buildTagMap em = binTuples expanded diff --git a/src/swarm-engine/Swarm/Game/Step/Util.hs b/src/swarm-engine/Swarm/Game/Step/Util.hs index c66cd624b..60ba100eb 100644 --- a/src/swarm-engine/Swarm/Game/Step/Util.hs +++ b/src/swarm-engine/Swarm/Game/Step/Util.hs @@ -62,6 +62,16 @@ lookInDirection d = do let nextLoc = loc `offsetBy` newHeading (nextLoc,) <$> entityAt nextLoc +adaptGameState :: + Has (State GameState) sig m => + TS.State GameState b -> + m b +adaptGameState f = do + oldGS <- get @GameState + let (newRecognizer, newGS) = TS.runState f oldGS + put newGS + return newRecognizer + -- | Modify the entity (if any) at a given location. updateEntityAt :: (Has (State Robot) sig m, Has (State GameState) sig m) => @@ -77,14 +87,9 @@ updateEntityAt cLoc@(Cosmic subworldName loc) upd = do currentTick <- use $ temporal . ticks myID <- use robotID zoomRobots $ wakeWatchingRobots myID currentTick cLoc - oldRecognizer <- use $ discovery . structureRecognition - - oldGS <- get @GameState - let (newRecognizer, newGS) = - flip TS.runState oldGS $ - SRT.entityModified mtlEntityAt modType cLoc oldRecognizer - put newGS + oldRecognizer <- use $ discovery . structureRecognition + newRecognizer <- adaptGameState $ SRT.entityModified mtlEntityAt modType cLoc oldRecognizer discovery . structureRecognition .= newRecognizer pcr <- use $ pathCaching . pathCachingRobots diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs index c0cf3c4a6..896e76f63 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs @@ -6,8 +6,13 @@ module Swarm.Game.Scenario.Topography.Structure.Recognition.Log where import Data.Aeson import Data.Int (Int32) +import Data.IntSet qualified as IS +import Data.IntSet.NonEmpty (NEIntSet) +import Data.IntSet.NonEmpty qualified as NEIS import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty qualified as NE import GHC.Generics (Generic) +import Linear (V2) import Servant.Docs (ToSample) import Servant.Docs qualified as SD import Swarm.Game.Location (Location) @@ -29,31 +34,6 @@ data OrientedStructure = OrientedStructure distillLabel :: StructureWithGrid b a -> OrientedStructure distillLabel swg = OrientedStructure (getName $ originalDefinition swg) (rotatedTo swg) -data MatchingRowFrom = MatchingRowFrom - { topDownRowIdx :: Int32 - -- ^ numbered from the top down - , structure :: OrientedStructure - } - deriving (Generic, ToJSON) - -newtype HaystackPosition = HaystackPosition Int - deriving (Generic, ToJSON) - -data HaystackContext e = HaystackContext - { maskedWorldRow :: WorldRowContent e - -- ^ entities that do not constitute any of the eligible structures - -- are replaced with 'null' in this list. - , haystackPosition :: HaystackPosition - } - deriving (Functor, Generic, ToJSON) - -data FoundRowCandidate e = FoundRowCandidate - { haystackContext :: HaystackContext e - , soughtContent :: StructureRowContent e - , matchedCandidates :: [MatchingRowFrom] - } - deriving (Functor, Generic, ToJSON) - data EntityKeyedFinder e = EntityKeyedFinder { searchOffsets :: InspectionOffsets , candidateStructureRows :: NonEmpty (StructureRowContent e) @@ -76,22 +56,96 @@ data IntactPlacementLog = IntactPlacementLog } deriving (Generic, ToJSON) -data VerticalSearch e = VerticalSearch - { haystackVerticalExtents :: InspectionOffsets - -- ^ vertical offset of haystack relative to the found row - , soughtStructures :: [OrientedStructure] - , verticalHaystack :: [WorldRowContent e] +data ChunkMatchFailureReason e + = ChunkMatchFailureReason OriginalName (RowMismatchReason e) + deriving (Functor, Generic, ToJSON) + +data FoundChunkComparison e = FoundChunkComparison + { foundChunkKeys :: [NonEmpty e] + , referenceChunkKeys :: [NonEmpty e] + } + deriving (Functor, Generic, ToJSON) + +data RowMismatchReason e + = NoKeysSubset (FoundChunkComparison e) + | -- | NOTE: should be redundant with 'NoKeysSubset' + EmptyIntersection + deriving (Functor, Generic, ToJSON) + +data WrongRecurrenceCountExplanation = WrongRecurrenceCountExplanation + { foundCount :: Int + , foundMembers :: NonEmpty Int + , expectedCount :: Int + , expectedMembers :: NonEmpty Int + } + deriving (Generic, ToJSON) + +-- | The located occurrences of a specific contiguous chunk of entities. +-- Note that an identical chunk may recur more than once in a structure row. +-- This record represents all of the recurrences of one such chunk. +-- +-- Any different chunks contained within a row will be described by +-- their own instance of this record. +-- +-- Note: By virtue of the searching algorithm, these indices +-- are expected to automatically be in sorted order +data FoundAndExpectedChunkPositions = FoundAndExpectedChunkPositions + { foundPositions :: NEIntSet + , expectedPositions :: NEIntSet + } + deriving (Generic, ToJSON) + +-- | We only have to do this once, for the "smallest" chunk occurrence size discrepancy. +-- All subsequent chunks will merely filter on this initial set. +generatePossibleOffsets :: (Int, FoundAndExpectedChunkPositions) -> Maybe NEIntSet +generatePossibleOffsets (sizeDifference, FoundAndExpectedChunkPositions found expected) = + NEIS.nonEmptySet $ IS.fromList possibleOffsets + where + possibleOffsets = + NE.take (sizeDifference + 1) $ + NE.map (subtract (NEIS.findMin expected)) sortedFound + + sortedFound = NEIS.toAscList found + +-- | Return all of the offsets that are viable for repetitions of this chunk. +-- +-- Note that if there are an equal number of observed occurrences +-- and expected occurrences, then there is only one possible offset. +-- If there are N expected and (N + 1) observed, then there are 2 possible offsets. +findCoveringOffsets :: NEIntSet -> FoundAndExpectedChunkPositions -> Maybe NEIntSet +findCoveringOffsets possibleOffsets x = + NEIS.nonEmptySet $ NEIS.filter (isCoveredWithOffset x) possibleOffsets + +isCoveredWithOffset :: FoundAndExpectedChunkPositions -> Int -> Bool +isCoveredWithOffset (FoundAndExpectedChunkPositions found expected) offset = + NEIS.map (+ offset) expected `NEIS.isSubsetOf` found + +data FoundRowFromChunk a = FoundRowFromChunk + { chunkOffsetFromSearchBorder :: Int + , rowIndexWithinStructure :: Int32 + , structurePositionOffset :: V2 Int32 + , chunkStructure :: a + } + deriving (Functor, Generic, ToJSON) + +data ChunkedRowMatch a e = ChunkedRowMatch + { positionsComparison :: [(FoundAndExpectedChunkPositions, NonEmpty e)] + , foundChunkRow :: FoundRowFromChunk a } deriving (Functor, Generic, ToJSON) data SearchLog e = FoundParticipatingEntity (ParticipatingEntity e) | StructureRemoved OriginalName - | FoundRowCandidates [FoundRowCandidate e] | FoundCompleteStructureCandidates [OrientedStructure] - | -- | There may be multiple candidate structures that could be - -- completed by the element that was just placed. This lists all of them. - VerticalSearchSpans [VerticalSearch e] + | -- | this is actually internally used as a (Map (NonEmpty e) (NonEmpty Int)), + -- but the requirements of Functor force us to invert the mapping + FoundPiecewiseChunks [(NonEmpty Int, NonEmpty e)] + | ExpectedChunks (NonEmpty [NonEmpty e]) + | StartSearchAt (Cosmic Location) InspectionOffsets + | ChunksMatchingExpected [ChunkedRowMatch OriginalName e] + | ChunkFailures [ChunkMatchFailureReason e] + | ChunkIntactnessVerification IntactPlacementLog | IntactStaticPlacement [IntactPlacementLog] deriving (Functor, Generic) diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs index dd5f58330..0441baa99 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs @@ -34,17 +34,24 @@ module Swarm.Game.Scenario.Topography.Structure.Recognition.Precompute ( -- * Main external interface mkAutomatons, + -- * Types + GenericEntLocator, + -- * Helper functions populateStaticFoundStructures, getEntityGrid, lookupStaticPlacements, + ensureStructureIntact, ) where import Control.Arrow ((&&&)) +import Data.Foldable.Extra (allM) import Data.Hashable (Hashable) import Data.Map qualified as M import Data.Maybe (catMaybes, mapMaybe) import Data.Set qualified as Set +import Linear (V2 (..)) +import Swarm.Game.Location (Location) import Swarm.Game.Scenario.Topography.Area (getGridDimensions, rectWidth) import Swarm.Game.Scenario.Topography.Grid (getRows) import Swarm.Game.Scenario.Topography.Placement (Orientation (..), applyOrientationTransform, getStructureName) @@ -57,10 +64,18 @@ import Swarm.Game.Scenario.Topography.Structure.Recognition.Registry ( ) import Swarm.Game.Scenario.Topography.Structure.Recognition.Static import Swarm.Game.Scenario.Topography.Structure.Recognition.Type -import Swarm.Game.Universe (Cosmic (..)) +import Swarm.Game.Universe (Cosmic (..), offsetBy) import Swarm.Language.Syntax.Direction (AbsoluteDir) import Swarm.Util (histogram) +-- | Interface that provides monadic access to +-- querying entities at locations. +-- The provider may be a 'State' monad or just +-- a 'Reader'. +-- +-- 's' is the state variable, 'a' is the return type. +type GenericEntLocator s a = Cosmic Location -> s (Maybe a) + getEntityGrid :: (Maybe b -> Maybe a) -> NamedGrid (Maybe b) -> [[Maybe a]] getEntityGrid extractor = getRows . fmap extractor . structure @@ -126,3 +141,26 @@ lookupStaticPlacements extractor (StaticStructureInfo structDefs thePlacements) g (LocatedStructure theName d loc) = do sGrid <- M.lookup theName definitionMap return $ FoundStructure (extractOrientedGrid extractor sGrid d) $ Cosmic subworldName loc + +-- | Matches definitions against the placements. +-- Fails fast (short-circuits) if a non-matching +-- cell is encountered. +ensureStructureIntact :: + (Monad s, Hashable a) => + GenericEntLocator s a -> + FoundStructure b a -> + s Bool +ensureStructureIntact entLoader (FoundStructure (StructureWithGrid _ _ _ grid) upperLeft) = + allM outer $ zip [0 ..] grid + where + outer (y, row) = allM (inner y) $ zip [0 ..] row + inner y (x, maybeTemplateEntity) = case maybeTemplateEntity of + Nothing -> return True + Just _ -> + fmap (== maybeTemplateEntity) $ + entLoader $ + mkLoc x y + + -- NOTE: We negate the yOffset because structure rows are numbered increasing from top + -- to bottom, but swarm world coordinates increase from bottom to top. + mkLoc x y = upperLeft `offsetBy` V2 x (negate y) diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs index 6f30a1cb2..cbbbbb516 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs @@ -2,15 +2,18 @@ -- SPDX-License-Identifier: BSD-3-Clause module Swarm.Game.Scenario.Topography.Structure.Recognition.Prep ( mkEntityLookup, + binTuplesHM, ) where import Control.Arrow ((&&&)) import Data.HashMap.Strict qualified as HM +import Data.HashSet qualified as HS import Data.Hashable (Hashable) import Data.Int (Int32) import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty qualified as NE -import Data.Maybe (catMaybes) +import Data.List.Split (wordsBy) +import Data.Maybe (catMaybes, mapMaybe) import Data.Semigroup (sconcat) import Data.Tuple (swap) import Swarm.Game.Scenario.Topography.Structure.Recognition.Type @@ -71,21 +74,15 @@ mkRowLookup neList = mkEntityLookup :: (Hashable a, Eq a) => [StructureWithGrid b a] -> - HM.HashMap a (AutomatonInfo a (AtomicKeySymbol a) (StructureSearcher b a)) + HM.HashMap a (AutomatonNewInfo b a) mkEntityLookup grids = HM.map mkRowAutomatons rowsByEntityParticipation where - -- The input here are all rows across all structures - -- that share the same entity sequence. - mkSmValue ksms singleRows = - StructureSearcher sm2D ksms singleRows - where - structureRowsNE = NE.map myRow singleRows - sm2D = mkRowLookup structureRowsNE - - -- Produces a list of automatons to evaluate whenever a given entity + -- Produces an automaton to evaluate whenever a given entity -- is encountered. - mkRowAutomatons neList = AutomatonInfo bounds sm searchPatternsAndSubAutomatons + mkRowAutomatons neList = + AutomatonNewInfo bounds searchPatternsAndSubAutomatons $ + PiecewiseRecognition smPiecewise extractedChunksForLookup where searchPatternsAndSubAutomatons = NE.map (\(a, b) -> (a, mkSmValue a b)) groupedByUniqueRow where @@ -93,18 +90,52 @@ mkEntityLookup grids = binTuplesHMasListNE $ NE.map (rowContent . myRow &&& id) neList + -- The input here are all rows across all structures + -- that share the same entity sequence. + mkSmValue ksms singleRows = + StructureSearcher sm2D ksms singleRows + where + structureRowsNE = NE.map myRow singleRows + sm2D = mkRowLookup structureRowsNE + bounds = sconcat $ NE.map expandedOffsets neList - sm = makeStateMachine $ NE.toList searchPatternsAndSubAutomatons + + extractedChunksForStateMachine = + HS.fromList . concat . NE.toList $ + NE.map (map chunkContents . contiguousChunks) neList + + -- TODO: These should be grouped by + -- row content to avoid redundant checks + extractedChunksForLookup = NE.map f neList + where + f x = RowChunkMatchingReference (myRow x) (mkRightMap x) + mkRightMap = binTuplesHM . map (chunkContents &&& chunkStartPos) . contiguousChunks + + smPiecewise = + makeStateMachine $ + map (NE.toList . fmap Just &&& id) $ + HS.toList extractedChunksForStateMachine -- The values of this map are guaranteed to contain only one -- entry per row of each structure, even if some of those -- rows contain repetition of the same entity. + -- That is not to say that there are not recurrences of identical rows, + -- though, if the same structure or a different structure has some identical rows. rowsByEntityParticipation = binTuplesHM . map (myEntity &&& id) . concatMap explodeRowEntities $ allStructureRows grids +getContiguousChunks :: [Maybe a] -> [PositionedChunk a] +getContiguousChunks rowMembers = + map mkChunk + . mapMaybe (NE.nonEmpty . mapMaybe sequenceA) + . wordsBy (null . snd) + $ zip [0 :: Int ..] rowMembers + where + mkChunk xs = PositionedChunk (fst $ NE.head xs) (NE.map snd xs) + -- | All of the occurrences of each unique entity within a row -- are consolidated into one record, in which the repetitions are noted. -- @@ -117,8 +148,10 @@ explodeRowEntities :: explodeRowEntities annotatedRow@(StructureRow _ _ rowMembers) = map f $ HM.toList $ binTuplesHM unconsolidatedEntityOccurrences where + chunks = getContiguousChunks rowMembers + f (e, occurrences) = - SingleRowEntityOccurrences annotatedRow e occurrences $ + SingleRowEntityOccurrences annotatedRow e chunks $ sconcat $ NE.map deriveEntityOffsets occurrences diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs index fe7a92b15..9b26b6baf 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs @@ -9,37 +9,39 @@ module Swarm.Game.Scenario.Topography.Structure.Recognition.Tracking ( entityModified, ) where +import Control.Arrow (left, (&&&)) import Control.Lens ((%~), (&), (.~), (^.)) -import Control.Monad (forM, guard) +import Control.Monad (filterM, foldM, guard, unless) +import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) -import Data.Foldable (foldrM) +import Control.Monad.Trans.Writer.Strict +import Data.Either (partitionEithers) +import Data.Either.Extra (maybeToEither) +import Data.Function (on) import Data.HashMap.Strict qualified as HM +import Data.HashSet qualified as HS import Data.Hashable (Hashable) import Data.Int (Int32) +import Data.IntSet.NonEmpty qualified as NEIS import Data.List (sortOn) import Data.List.NonEmpty.Extra qualified as NE import Data.Map qualified as M import Data.Maybe (listToMaybe) import Data.Ord (Down (..)) import Data.Semigroup (Max (..), Min (..)) +import Data.Tuple (swap) import Linear (V2 (..)) import Swarm.Game.Location (Location) import Swarm.Game.Scenario.Topography.Structure.Recognition import Swarm.Game.Scenario.Topography.Structure.Recognition.Log +import Swarm.Game.Scenario.Topography.Structure.Recognition.Precompute (GenericEntLocator, ensureStructureIntact) +import Swarm.Game.Scenario.Topography.Structure.Recognition.Prep (binTuplesHM) import Swarm.Game.Scenario.Topography.Structure.Recognition.Registry import Swarm.Game.Scenario.Topography.Structure.Recognition.Type import Swarm.Game.Scenario.Topography.Terraform import Swarm.Game.Universe import Text.AhoCorasick --- | Interface that provides monadic access to --- querying entities at locations. --- The provider may be a 'State' monad or just --- a 'Reader'. --- --- 's' is the state variable, 'a' is the return type. -type GenericEntLocator s a = Cosmic Location -> s (Maybe a) - -- | A hook called from the centralized entity update function, -- 'Swarm.Game.Step.Util.updateEntityAt'. -- @@ -53,11 +55,14 @@ entityModified :: Cosmic Location -> StructureRecognizer b a -> s (StructureRecognizer b a) -entityModified entLoader modification cLoc recognizer = - case modification of +entityModified entLoader modification cLoc recognizer = do + (val, accumulatedLogs) <- runWriterT $ case modification of Add newEntity -> doAddition newEntity recognizer Remove _ -> doRemoval Swap _ newEntity -> doRemoval >>= doAddition newEntity + return $ + val + & recognitionState . recognitionLog %~ (reverse accumulatedLogs <>) where entLookup = recognizer ^. automatons . automatonsByEntity @@ -68,16 +73,15 @@ entityModified entLoader modification cLoc recognizer = Just finder -> do let logFinder f = EntityKeyedFinder - (f ^. inspectionOffsets) - (NE.map fst $ f ^. searchPairs) + (f ^. inspectionOffsets2) + (NE.map fst $ f ^. searchPairs2) mempty msg = FoundParticipatingEntity $ ParticipatingEntity newEntity $ logFinder finder - stateRevision' = oldRecognitionState & recognitionLog %~ (msg :) - - foldrM (registerRowMatches entLoader cLoc) stateRevision' [finder] + tell $ pure msg + registerRowMatches entLoader cLoc finder oldRecognitionState return $ r & recognitionState .~ stateRevision @@ -87,10 +91,10 @@ entityModified entLoader modification cLoc recognizer = structureRegistry = oldRecognitionState ^. foundStructures stateRevision <- case M.lookup cLoc $ foundByLocation structureRegistry of Nothing -> return oldRecognitionState - Just fs -> + Just fs -> do + tell $ pure $ StructureRemoved structureName return $ oldRecognitionState - & recognitionLog %~ (StructureRemoved structureName :) & foundStructures %~ removeStructure fs where structureName = getName $ originalDefinition $ structureWithGrid fs @@ -140,55 +144,109 @@ getWorldRow entLoader registry cLoc (InspectionOffsets (Min offsetLeft) (Max off -- to bottom, but swarm world coordinates increase from bottom to top. mkLoc x = cLoc `offsetBy` V2 x (negate yOffset) -logRowCandidates :: [Maybe e] -> [Position (StructureSearcher b e)] -> SearchLog e -logRowCandidates entitiesRow candidates = - FoundRowCandidates $ map mkCandidateLogEntry candidates - where - mkCandidateLogEntry c = - FoundRowCandidate - (HaystackContext entitiesRow (HaystackPosition $ pIndex c)) - (needleContent $ pVal c) - rowMatchInfo +-- | This runs once per non-overlapping subset of found chunks +checkChunksCombination :: + (Monad m, Hashable a, Eq b) => + GenericEntLocator m a -> + Cosmic Location -> + InspectionOffsets -> + NE.NonEmpty (RowChunkMatchingReference b a) -> + [Position (NE.NonEmpty a)] -> + WriterT [SearchLog a] m [FoundStructure b a] +checkChunksCombination + entLoader + cLoc + horizontalOffsets + rowChunkReferences + candidatesChunked = do + tell . pure . FoundPiecewiseChunks . map swap $ HM.toList $ fmap NEIS.elems foundRowChunksLookup + + let (candidateFailures, candidateExpectedLists) = + partitionEithers $ + map subsetChecker $ + NE.toList rowChunkReferences + + tell . pure . ChunkFailures $ candidateFailures + + let candidateExpected = concatMap NE.toList candidateExpectedLists + tell . pure . ChunksMatchingExpected $ + map (modifyChunkedRowMatch $ fmap $ getName . originalDefinition . wholeStructure) candidateExpected + + let structurePositionsToCheck = map mkFoundStructure candidateExpected + filterM validateIntactness2d structurePositionsToCheck where - rowMatchInfo :: [MatchingRowFrom] - rowMatchInfo = NE.toList . NE.map (f . myRow) . singleRowItems $ pVal c + foundRowChunksLookup = fmap NEIS.fromList $ binTuplesHM $ map (pVal &&& pIndex) candidatesChunked + + mkFoundStructure x = + FoundStructure + (wholeStructure $ chunkStructure $ foundChunkRow x) + (cLoc `offsetBy` structurePositionOffset (foundChunkRow x)) + + validateIntactness2d fs = do + intactness <- lift $ ensureStructureIntact entLoader fs + tell . pure . ChunkIntactnessVerification $ + IntactPlacementLog + intactness + (getName . originalDefinition . structureWithGrid $ fs) + (upperLeftCorner fs) + return intactness + + subsetChecker (RowChunkMatchingReference r chunkPositionMap) = + left (ChunkMatchFailureReason $ getName . originalDefinition . wholeStructure $ r) $ do + let isKeysSubset = referenceChunksKeys `HS.isSubsetOf` foundChunksKeys + unless isKeysSubset . Left $ + NoKeysSubset $ + (FoundChunkComparison `on` HS.toList) foundChunksKeys referenceChunksKeys + + let theIntersection = + HM.intersectionWith + FoundAndExpectedChunkPositions + foundRowChunksLookup + modifiedChunkPositionMap + intersectionWithSizeDifferences = HM.map (criteria &&& id) theIntersection + where + criteria x = (subtract `on` NEIS.size) (expectedPositions x) (foundPositions x) + + -- Remove the pairings that have fewer occurrences than the required number + let withSufficientCoverage = HM.filter ((>= 0) . fst) intersectionWithSizeDifferences + sortedByAlignmentChoices = sortOn (fst . snd) $ HM.toList withSufficientCoverage + + nonEmptyPairs <- + maybeToEither EmptyIntersection $ + NE.nonEmpty sortedByAlignmentChoices + + let maybeViables = do + possibles <- generatePossibleOffsets $ snd $ NE.head nonEmptyPairs + foldM findCoveringOffsets possibles $ NE.map (snd . snd) nonEmptyPairs + + viableRowOffsets <- maybeToEither EmptyIntersection maybeViables + return $ NE.map (mkRowMatch theIntersection) $ NEIS.toList viableRowOffsets where - f x = - MatchingRowFrom (rowIndex x) $ distillLabel . wholeStructure $ x + mkRowMatch theIntersection rowOffset = + ChunkedRowMatch + (map swap $ HM.toList theIntersection) + (FoundRowFromChunk rowOffset (rowIndex r) (V2 horizontalStructurePosition (rowIndex r)) r) + where + horizontalStructurePosition = fromIntegral rowOffset + getMin (startOffset horizontalOffsets) --- | This is the first (one-dimensional) stage --- in a two-stage (two-dimensional) search. --- --- It searches for any structure row that happens to + modifiedChunkPositionMap = fmap NEIS.fromList chunkPositionMap + foundChunksKeys = HM.keysSet foundRowChunksLookup + referenceChunksKeys = HM.keysSet chunkPositionMap + + modifyChunkedRowMatch f (ChunkedRowMatch x y) = ChunkedRowMatch x (f y) + +-- | Search for any structure row that happens to -- contain the placed entity. registerRowMatches :: (Monad s, Hashable a, Eq b) => GenericEntLocator s a -> Cosmic Location -> - AutomatonInfo a (AtomicKeySymbol a) (StructureSearcher b a) -> + AutomatonNewInfo b a -> RecognitionState b a -> - s (RecognitionState b a) -registerRowMatches entLoader cLoc (AutomatonInfo horizontalOffsets sm _) rState = do - maskChoices <- attemptSearchWithEntityMask - - let logEntry = uncurry logRowCandidates maskChoices - rState2 = rState & recognitionLog %~ (logEntry :) - candidates = snd maskChoices - - candidates2Dpairs <- - forM candidates $ - checkVerticalMatch entLoader registry cLoc horizontalOffsets - - let (verticalSpans, candidates2D) = unzip candidates2Dpairs - rState3 = rState2 & recognitionLog %~ (VerticalSearchSpans verticalSpans :) - - return $ - registerStructureMatches (concat candidates2D) rState3 - where - registry = rState ^. foundStructures - - attemptSearchWithEntityMask = do - entitiesRow <- + WriterT [SearchLog a] s (RecognitionState b a) +registerRowMatches entLoader cLoc (AutomatonNewInfo horizontalOffsets _ pwMatcher) rState = do + entitiesRow <- + lift $ getWorldRow entLoader registry @@ -196,83 +254,40 @@ registerRowMatches entLoader cLoc (AutomatonInfo horizontalOffsets sm _) rState horizontalOffsets 0 - -- All of the eligible structure rows found - -- within this horizontal swath of world cells - return (entitiesRow, findAll sm entitiesRow) + tell $ pure $ StartSearchAt cLoc horizontalOffsets --- | Examines contiguous rows of entities, accounting --- for the offset of the initially found row. -checkVerticalMatch :: - (Monad s, Hashable a) => - GenericEntLocator s a -> - FoundRegistry b a -> - Cosmic Location -> - -- | Horizontal search offsets - InspectionOffsets -> - Position (StructureSearcher b a) -> - s (VerticalSearch a, [FoundStructure b a]) -checkVerticalMatch entLoader registry cLoc (InspectionOffsets (Min searchOffsetLeft) _) foundRow = do - ((x, y), z) <- getMatches2D entLoader registry cLoc horizontalFoundOffsets $ automaton2D searcherVal - return (VerticalSearch x rowStructureNames y, z) - where - searcherVal = pVal foundRow - rowStructureNames = NE.toList . NE.map (distillLabel . wholeStructure . myRow) . singleRowItems $ searcherVal + tell . pure . ExpectedChunks $ NE.map (HM.keys . confirmationMap) pwMaps - foundLeftOffset = searchOffsetLeft + fromIntegral (pIndex foundRow) - foundRightInclusiveIndex = foundLeftOffset + fromIntegral (pLength foundRow) - 1 - horizontalFoundOffsets = InspectionOffsets (pure foundLeftOffset) (pure foundRightInclusiveIndex) + let candidatesChunked = findAll pwSM entitiesRow -getFoundStructures :: - Hashable keySymb => - (Int32, Int32) -> - Cosmic Location -> - StateMachine keySymb (StructureWithGrid b a) -> - [keySymb] -> - [FoundStructure b a] -getFoundStructures (offsetTop, offsetLeft) cLoc sm entityRows = - map mkFound candidates + intactStructuresLists <- checkCombo candidatesChunked + + registerStructureMatches intactStructuresLists rState where - candidates = findAll sm entityRows - mkFound candidate = FoundStructure (pVal candidate) $ cLoc `offsetBy` loc - where - -- NOTE: We negate the yOffset because structure rows are numbered increasing from top - -- to bottom, but swarm world coordinates increase from bottom to top. - loc = V2 offsetLeft $ negate $ offsetTop + fromIntegral (pIndex candidate) + registry = rState ^. foundStructures + PiecewiseRecognition pwSM pwMaps = pwMatcher -getMatches2D :: - (Monad s, Hashable a) => - GenericEntLocator s a -> - FoundRegistry b a -> - Cosmic Location -> - -- | Horizontal found offsets (inclusive indices) - InspectionOffsets -> - AutomatonInfo a (SymbolSequence a) (StructureWithGrid b a) -> - s ((InspectionOffsets, [[Maybe a]]), [FoundStructure b a]) -getMatches2D - entLoader - registry - cLoc - horizontalFoundOffsets@(InspectionOffsets (Min offsetLeft) _) - (AutomatonInfo vRange@(InspectionOffsets (Min offsetTop) (Max offsetBottom)) sm _) = do - entityRows <- mapM getRow vertOffsets - return ((vRange, entityRows), getFoundStructures (offsetTop, offsetLeft) cLoc sm entityRows) - where - getRow = getWorldRow entLoader registry cLoc horizontalFoundOffsets - vertOffsets = [offsetTop .. offsetBottom] + checkCombo = + checkChunksCombination + entLoader + cLoc + horizontalOffsets + pwMaps -- | -- We only allow an entity to participate in one structure at a time, -- so multiple matches require a tie-breaker. -- The largest structure (by area) shall win. registerStructureMatches :: - (Eq a, Eq b) => + (Monad s, Eq a, Eq b) => [FoundStructure b a] -> RecognitionState b a -> - RecognitionState b a -registerStructureMatches unrankedCandidates oldState = - oldState - & (recognitionLog %~ (newMsg :)) - & foundStructures %~ maybe id addFound (listToMaybe rankedCandidates) + WriterT [SearchLog a] s (RecognitionState b a) +registerStructureMatches unrankedCandidates oldState = do + tell $ pure newMsg + return $ + oldState + & foundStructures %~ maybe id addFound (listToMaybe rankedCandidates) where -- Sorted by decreasing order of preference. rankedCandidates = sortOn Down unrankedCandidates diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs index 0d6023038..8ba241e1f 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs @@ -93,6 +93,25 @@ data PositionWithinRow b a = PositionWithinRow , structureRow :: StructureRow b a } +-- | A chunkified version of a structure row. +-- Each unique structure row will need to test one of these +-- against the world row being examined. +data RowChunkMatchingReference b a = RowChunkMatchingReference + { locatableRow :: StructureRow b a + , confirmationMap :: HashMap (NonEmpty a) (NonEmpty Int) + } + +data PiecewiseRecognition b a = PiecewiseRecognition + { piecewiseSM :: StateMachine (AtomicKeySymbol a) (NonEmpty a) + , -- , picewiseLookup :: NonEmpty (HashMap (NonEmpty a) (NonEmpty Int)) + picewiseLookup :: NonEmpty (RowChunkMatchingReference b a) + } + +data PositionedChunk a = PositionedChunk + { chunkStartPos :: Int + , chunkContents :: NonEmpty a + } + -- Represents all of the locations that particular entity -- occurs within a specific row of a particular structure. -- @@ -107,7 +126,7 @@ data PositionWithinRow b a = PositionWithinRow data SingleRowEntityOccurrences b a = SingleRowEntityOccurrences { myRow :: StructureRow b a , myEntity :: a - , entityOccurrences :: NonEmpty (PositionWithinRow b a) + , contiguousChunks :: [PositionedChunk a] , expandedOffsets :: InspectionOffsets } @@ -207,13 +226,24 @@ data AutomatonInfo en k v = AutomatonInfo makeLenses ''AutomatonInfo +data AutomatonNewInfo v k = AutomatonNewInfo + { _inspectionOffsets2 :: InspectionOffsets + , _searchPairs2 :: NonEmpty ([AtomicKeySymbol k], StructureSearcher v k) + -- ^ these are the tuples input to the 'makeStateMachine' function, + -- for debugging purposes. + , piecewiseStuff :: PiecewiseRecognition v k + } + deriving (Generic) + +makeLenses ''AutomatonNewInfo + -- | The complete set of data needed to identify applicable -- structures, based on a just-placed entity. data RecognizerAutomatons b a = RecognizerAutomatons { _originalStructureDefinitions :: Map OriginalName (StructureInfo b a) -- ^ all of the structures that shall participate in automatic recognition. -- This list is used only by the UI and by the 'Floorplan' command. - , _automatonsByEntity :: HashMap a (AutomatonInfo a (AtomicKeySymbol a) (StructureSearcher b a)) + , _automatonsByEntity :: HashMap a (AutomatonNewInfo b a) } deriving (Generic) diff --git a/swarm.cabal b/swarm.cabal index fcdca6e6a..baec86a58 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -505,6 +505,7 @@ library swarm-topography linear, nonempty-containers, servant-docs, + split, text, transformers, unordered-containers,