Boolean expressions of objective prerequisites (#927)

Closes #795

![image](https://user-images.githubusercontent.com/261693/210162423-4dadf9d8-8e32-437f-b854-b7106dd4bbe8.png)

![Screenshot from 2022-12-31 22-58-59](https://user-images.githubusercontent.com/261693/210163446-e53c4abd-7bc1-4271-8a7c-cf0165f9ced8.png)
![image](https://user-images.githubusercontent.com/261693/210162466-3d3ae29e-9b35-44e6-93bc-df1efe59d7e9.png)

## Tasks
- [x] Vendor a subset of the `boolexpr` package
- [x] New display logic for parallel goals
- [x] Add a check for whether winning is impossible (due to a "not" prerequisite being achieved)
- [x] Add a "Lose" dialog analogous to the "Win" dialog
- [x] Validate no dependency cycles in prerequisites specs
- [x] Add tests for negative validation result from scenario parsing
- [x] Web API to inspect status of incomplete, completed, optional, and prerequisite objectives
- [x] Update all of the multi-goal scenarios to use prerequisites

## For follow-up PRs:
- [ ] Upload new GHC8-compatible version of `boolexpr` to Hackage (perhaps [take over the package](https://wiki.haskell.org/Taking_over_a_package)?)
- [ ] Add prerequisites and its logical operators to JSON schema
- [ ] Reverse topological sort of goals before evaluation, with a test that demonstrates necessity
- [ ] Automatically skip over the header rows when navigating the Goals list
- [ ] Add indicators for optional and hidden goals
- [ ] New Challenge for strategizing optional and mutually-exclusive goals
This commit is contained in:
Karl Ostmo 2023-01-24 20:07:00 -08:00 committed by GitHub
parent 1678b49a52
commit b80503f16e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1915 additions and 137 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ stan.html
.swarm_history
*.orig
*.aux
*.log
docs/ott/*.tex

View File

@ -5,7 +5,9 @@ description: |
Start a wool industry from the local fauna.
creative: false
objectives:
- goal:
- id: enclose_sheep
teaser: Paddock sheep
goal:
- |
You've homesteaded on a small island in the ocean.
It's time to gather resources to trade.
@ -274,7 +276,9 @@ objectives:
} {
return false;
}
- goal:
- id: feed_sheep
teaser: Feed sheep
goal:
- |
Safe! Your sheep are now hungry.
Offer them something tasty and you may be rewarded.
@ -311,6 +315,7 @@ objectives:
end;
anySheep (has "clover") 3;
prerequisite: enclose_sheep
- goal:
- |
Yum! Contented, well-fed sheep may drop wool.
@ -323,6 +328,7 @@ objectives:
as base {
has "sweater";
};
prerequisite: feed_sheep
robots:
- name: base
dir: [0, 1]

View File

@ -54,7 +54,9 @@ robots:
program: |
run "scenarios/Challenges/_bucket-brigade/powerplant.sw"
objectives:
- goal:
- id: deliver_coal_lump
teaser: Get coal to base
goal:
- Deliver a "coal lump" to the base.
- |
To excavate coal from the "lignite mine" (M), a robot needs to
@ -75,6 +77,7 @@ objectives:
condition: |
hauler <- robotnamed "hauler";
as hauler {has "coal briquette"}
prerequisite: deliver_coal_lump
solution: |
run "scenarios/Challenges/_bucket-brigade/brigade.sw"
entities:

View File

@ -6,7 +6,9 @@ description: |
seed: 2
creative: false
objectives:
- goal:
- id: prepare_playfield
teaser: Wait patiently
goal:
- |
First, wait for the playfield to be set up,
and then your path forward will be cleared.
@ -23,6 +25,7 @@ objectives:
leftward or rightward direction,
or vertically in either the upward or downward direction.
Diagonal appearances are not valid.
prerequisite: prepare_playfield
condition: |
/**
Algorithm:

View File

@ -14,6 +14,7 @@
684-swap.yaml
699-movement-fail
858-inventory
795-prerequisite
710-multi-robot.yaml
920-meet.yaml
955-heading.yaml

View File

@ -3,7 +3,9 @@ name: Complete two objectives in succession.
description: |
First, get some trees; then, use them to build a harvester.
objectives:
- goal:
- id: get_trees
teaser: Get trees
goal:
- Your first goal is to get three trees.
condition: |
try {
@ -14,6 +16,7 @@ objectives:
- Nice job. Now, build a harvester.
condition: |
try { as base {has "harvester"} } {return false}
prerequisite: get_trees
solution: |
build {turn right; move; move; grab; move; grab; move; grab; turn back; move; move; move; move};
wait 16;

View File

@ -0,0 +1,4 @@
795-prerequisite-or.yaml
795-prerequisite-and.yaml
795-prerequisite-mutually-exclusive.yaml
795-prerequisite-cycle-with-not.yaml

View File

@ -0,0 +1,50 @@
version: 1
name: |
Prerequisite objectives: AND
description: |
Complete an objective with a prerequisite entailing both of two other objectives.
objectives:
- goal:
- Achieve both of two other objectives
condition: |
return true;
prerequisite:
previewable: true
logic:
and:
- have_furnace
- have_gear
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
optional: true
- id: have_gear
goal:
- Make a "wooden gear".
condition: |
as base {has "wooden gear"};
optional: true
solution: |
make "furnace";
make "wooden gear";
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
- grabber
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, null, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,42 @@
version: 1
name: |
Prerequisite objectives: non-dependency cycle due to the NOT
description: |
This should NOT be rejected by the parser.
The two objectives do reference each other,
but the NOT is a distinct reference from the non-negated goal.
objectives:
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
prerequisite:
not: have_gear
- id: have_gear
goal:
- Make a "wooden gear".
condition: |
as base {has "wooden gear"};
prerequisite: have_furnace
solution: |
make "wooden gear";
make "furnace";
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, null, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,61 @@
version: 1
name: |
Mutually exclusive prerequisites
description: |
Complete an objective with a prerequisite of either of two other objectives.
objectives:
- goal:
- Either obtain a "furnace" WITHOUT a "flower", or obtain both a "wooden gear" and "flower".
condition: |
return true;
prerequisite:
previewable: true
logic:
or:
- and:
- id: have_furnace
- not:
id: have_flower
- and:
- id: have_gear
- id: have_flower
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
optional: true
- id: have_gear
goal:
- Make a "wooden gear".
condition: |
as base {has "wooden gear"};
optional: true
- id: have_flower
goal:
- Possess a "flower".
condition: |
as base {has "flower"};
optional: true
solution: |
make "furnace"
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
- grabber
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, flower, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,49 @@
version: 1
name: |
Prerequisite objectives: OR
description: |
Complete an objective with a prerequisite of either of two other objectives.
objectives:
- goal:
- Achieve one of two other objectives
condition: |
return true;
prerequisite:
previewable: true
logic:
or:
- id: have_furnace
- id: have_gear
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
optional: true
- id: have_gear
goal:
- Make a "wooden gear".
condition: |
as base {has "wooden gear"};
optional: true
solution: |
make "wooden gear"
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
- grabber
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, flower, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,38 @@
version: 1
name: |
Prerequisite objectives: dependency cycle
description: |
This should be rejected by the parser.
objectives:
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
prerequisite: have_gear
- id: have_gear
goal:
- Make a "wooden gear".
condition: |
as base {has "wooden gear"};
prerequisite: have_furnace
solution: |
make "wooden gear"
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, null, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,38 @@
version: 1
name: |
Prerequisite objectives: Nonexistent reference
description: |
This scenario should be unparseable due to typo "shave_furnace" vs "have_furnace"
objectives:
- goal:
- Achieve one of two other objectives
condition: |
return true;
prerequisite: shave_furnace
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
optional: true
solution: |
make "furnace"
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
- grabber
inventory:
- [2, board]
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, flower, base]
upperleft: [0, 0]
map: |
x

View File

@ -0,0 +1,31 @@
version: 1
name: |
Prerequisite objectives: dependency cycle (self-reference)
description: |
This should be rejected by the parser.
objectives:
- id: have_furnace
goal:
- Make a "furnace".
condition: |
as base {has "furnace"};
prerequisite: have_furnace
solution: |
make "furnace"
robots:
- name: base
display:
char: 'Ω'
attr: robot
dir: [0, 1]
devices:
- workbench
inventory:
- [5, rock]
world:
default: [blank]
palette:
'x': [grass, null, base]
upperleft: [0, 0]
map: |
x

View File

@ -4,7 +4,9 @@ creative: false
description: |
Learn about command return types and how to bind the results.
objectives:
- goal:
- id: grab_artifact
teaser: Retrieve artifact
goal:
- |
A pedestal stands conspicuously empty in the center of the room.
Perhaps its intended contents lie nearby?
@ -56,6 +58,7 @@ objectives:
w <- as p {ishere "Hastur"};
return w;
} { return false }
prerequisite: grab_artifact
solution: |
run "data/scenarios/Tutorials/bind2-solution.sw"
entities:
@ -105,4 +108,4 @@ world:
│...xxxH..│
│.........│
│Ω┌───────┘
└─┘........
└─┘........

View File

@ -3,7 +3,9 @@ name: Farming
description: |
Learn how to build a farm to grow and harvest items.
objectives:
- goal:
- id: get_many_lambdas
teaser: Get 256 lambdas
goal:
- Lambdas are an essential item for building robots, but they
are somewhat rare in the wild. Therefore, it makes sense to farm
them in order to create a reliable supply.
@ -47,6 +49,7 @@ objectives:
- Now go forth and build your swarm!
condition: |
try {as base {has "curry"}} {return false}
prerequisite: get_many_lambdas
solution: |
run "scenarios/Tutorials/farming.sw";
run "scenarios/Tutorials/make_curry.sw";

View File

@ -3,7 +3,9 @@ name: Moving
description: |
Learn how to move and chain commands.
objectives:
- goal:
- id: move_to_first_flower
teaser: Get the flower
goal:
- Robots can use the `move` command to move forward one unit
in the direction they are currently facing.
- To complete this challenge, move your robot two spaces to the right,
@ -14,7 +16,9 @@ objectives:
condition: |
r <- robotNamed "check1";
loc <- as r {has "Win"};
- goal:
- id: move_along_corridor
teaser: Down the corridor
goal:
- Good! Now you need to learn how to effectively repeat actions.
- |
Previously you could move twice by chaining the move command:
@ -29,7 +33,10 @@ objectives:
condition: |
r <- robotNamed "check2";
loc <- as r {has "Win"};
- goal:
prerequisite: move_to_first_flower
- id: move_northeast_corner
teaser: To northeast corner
goal:
- Well done! In addition to `move`, you can use the `turn` command
to turn your robot, for example, `turn right` or `turn east`.
- Switch to the inventory view in the upper left (by clicking on it or typing `Alt+E`)
@ -51,6 +58,7 @@ objectives:
condition: |
r <- robotNamed "check3";
loc <- as r {has "Win"};
prerequisite: move_along_corridor
- goal:
- Good job! You are now ready to move and turn on your own.
- To complete this challenge, move your robot to the northeast corner,
@ -61,6 +69,7 @@ objectives:
condition: |
r <- robotNamed "check4";
loc <- as r {has "Win"};
prerequisite: move_northeast_corner
solution: |
// 0
move;move;

View File

@ -3,7 +3,9 @@ name: First steps
description: |
Learn some of the first steps you might take on the new planet.
objectives:
- goal:
- id: get_trees
teaser: Get 3 trees
goal:
- You're ready to graduate to something a bit more complex! This
multi-step tutorial will walk you through a few of the first
steps you might take in exploring the new planet, using many
@ -26,7 +28,9 @@ objectives:
n <- as base {count "tree"};
return (n >= 3)
} { return false }
- goal:
- id: get_harvester
teaser: Make a harvester
goal:
- Nice work! Now, use the trees to make a harvester device.
This will require several intermediate products; try making
various things, and take a look at your available recipes (F3)
@ -34,6 +38,7 @@ objectives:
you may end up needing some additional trees.
condition: |
try { as base {has "harvester"} } {return false}
prerequisite: get_trees
- goal:
- Now that you have a harvester, you can use `harvest` instead of `grab`
whenever you pick up a growing item (check for the word "growing" at the
@ -50,6 +55,7 @@ objectives:
coordinates."
condition: |
try { as base {has "lambda"} } {return false}
prerequisite: get_harvester
robots:
- name: base
display:

337
src/Data/BoolExpr.hs Normal file
View File

@ -0,0 +1,337 @@
{-
Copyright (c) 2008, 2009, Nicolas Pouillard
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of the copyright holders nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE NoDeriveAnyClass #-}
--------------------------------------------------------------------
--------------------------------------------------------------------
-- |
-- Module : Data.BoolExpr
-- Copyright : (c) Nicolas Pouillard 2008,2009
-- License : BSD3
--
-- Maintainer: Nicolas Pouillard <nicolas.pouillard@gmail.com>
-- Stability : provisional
-- Portability:
--
-- Boolean expressions and various representations.
module Data.BoolExpr (
-- * A boolean class
Boolean (..),
-- * Boolean trees
BoolExpr (..),
reduceBoolExpr,
evalBoolExpr,
-- * Boolean evaluation semantic
Eval (..),
runEvalId,
-- * Signed constants
Signed (..),
negateSigned,
evalSigned,
constants,
negateConstant,
-- * Conjunctive Normal Form
CNF (..),
Conj (..),
boolTreeToCNF,
-- * Disjunctive Normal Form
Disj (..),
DNF (..),
boolTreeToDNF,
-- * Other transformations
dualize,
fromBoolExpr,
pushNotInwards,
)
where
import Control.Monad (ap)
import Data.Aeson
import Data.Char (toLower)
import Data.Traversable
import GHC.Generics (Generic)
-- | Signed values are either positive or negative.
data Signed a = Positive a | Negative a
deriving (Eq, Ord, Generic, Show, Read)
instance Functor Signed where
fmap f (Positive x) = Positive (f x)
fmap f (Negative x) = Negative (f x)
instance Traversable Signed where
traverse f (Positive x) = Positive <$> f x
traverse f (Negative x) = Negative <$> f x
instance Foldable Signed where
foldMap = foldMapDefault
instance Applicative Signed where
pure = Positive
(<*>) = ap
instance Monad Signed where
Positive x >>= f = f x
Negative x >>= f = negateSigned $ f x
infix 9 /\
infix 9 \/
-- | A boolean type class.
class Boolean f where
(/\) :: f a -> f a -> f a
(\/) :: f a -> f a -> f a
bNot :: f a -> f a
bTrue :: f a
bFalse :: f a
bConst :: Signed a -> f a
-- | Syntax of boolean expressions parameterized over a
-- set of leaves, named constants.
data BoolExpr a
= BAnd (BoolExpr a) (BoolExpr a)
| BOr (BoolExpr a) (BoolExpr a)
| BNot (BoolExpr a)
| BTrue
| BFalse
| BConst (Signed a)
deriving (Eq, Ord, Generic, Show {-! derive : Arbitrary !-})
encodingOptions :: Options
encodingOptions =
defaultOptions
{ sumEncoding = ObjectWithSingleField
, constructorTagModifier = map toLower
}
instance (ToJSON a) => ToJSON (Signed a) where
toJSON = genericToJSON encodingOptions
instance (ToJSON a) => ToJSON (BoolExpr a) where
toJSON = genericToJSON encodingOptions
instance (ToJSON a) => ToJSON (DNF a) where
toJSON = genericToJSON encodingOptions
instance (ToJSON a) => ToJSON (CNF a) where
toJSON = genericToJSON encodingOptions
instance (ToJSON a) => ToJSON (Conj a) where
toJSON = genericToJSON encodingOptions
instance (ToJSON a) => ToJSON (Disj a) where
toJSON = genericToJSON encodingOptions
instance Functor BoolExpr where
fmap f (BAnd a b) = BAnd (fmap f a) (fmap f b)
fmap f (BOr a b) = BOr (fmap f a) (fmap f b)
fmap f (BNot t) = BNot (fmap f t)
fmap _ BTrue = BTrue
fmap _ BFalse = BFalse
fmap f (BConst x) = BConst (fmap f x)
instance Traversable BoolExpr where
traverse f (BAnd a b) = BAnd <$> traverse f a <*> traverse f b
traverse f (BOr a b) = BOr <$> traverse f a <*> traverse f b
traverse f (BNot t) = BNot <$> traverse f t
traverse _ BTrue = pure BTrue
traverse _ BFalse = pure BFalse
traverse f (BConst x) = BConst <$> traverse f x
instance Foldable BoolExpr where
foldMap = foldMapDefault
instance Boolean BoolExpr where
(/\) = BAnd
(\/) = BOr
bNot = BNot
bTrue = BTrue
bFalse = BFalse
bConst = BConst
newtype Eval b a = Eval {runEval :: (a -> b) -> b}
runEvalId :: Eval a a -> a
runEvalId e = runEval e id
instance b ~ Bool => Boolean (Eval b) where
(/\) = liftE2 (&&)
(\/) = liftE2 (||)
bNot = liftE not
bTrue = Eval $ const True
bFalse = Eval $ const False
bConst = Eval . flip evalSigned
liftE :: (b -> b) -> Eval b a -> Eval b a
liftE f (Eval x) = Eval (f . x)
liftE2 :: (b -> b -> b) -> Eval b a -> Eval b a -> Eval b a
liftE2 f (Eval x) (Eval y) = Eval (\e -> f (x e) (y e))
-- | Turns a boolean tree into any boolean type.
fromBoolExpr :: Boolean f => BoolExpr a -> f a
fromBoolExpr (BAnd l r) = fromBoolExpr l /\ fromBoolExpr r
fromBoolExpr (BOr l r) = fromBoolExpr l \/ fromBoolExpr r
fromBoolExpr (BNot t) = bNot $ fromBoolExpr t
fromBoolExpr BTrue = bTrue
fromBoolExpr BFalse = bFalse
fromBoolExpr (BConst c) = bConst c
--- | Disjunction of atoms ('a')
newtype Disj a = Disj {unDisj :: [a]}
deriving (Show, Generic, Functor, Semigroup, Monoid)
--- | Conjunction of atoms ('a')
newtype Conj a = Conj {unConj :: [a]}
deriving (Show, Generic, Functor, Semigroup, Monoid)
--- | Conjunctive Normal Form
newtype CNF a = CNF {unCNF :: Conj (Disj (Signed a))}
deriving (Show, Generic, Semigroup, Monoid)
--- | Disjunctive Normal Form
newtype DNF a = DNF {unDNF :: Disj (Conj (Signed a))}
deriving (Show, Generic, Semigroup, Monoid)
instance Functor CNF where
fmap f (CNF x) = CNF (fmap (fmap (fmap f)) x)
instance Boolean CNF where
l /\ r = l `mappend` r
l \/ r =
CNF $
Conj
[ x `mappend` y | x <- unConj $ unCNF l, y <- unConj $ unCNF r
]
bNot = error "bNot on CNF"
bTrue = CNF $ Conj []
bFalse = CNF $ Conj [Disj []]
bConst x = CNF $ Conj [Disj [x]]
instance Functor DNF where
fmap f (DNF x) = DNF (fmap (fmap (fmap f)) x)
instance Boolean DNF where
l /\ r =
DNF $
Disj
[ x `mappend` y | x <- unDisj $ unDNF l, y <- unDisj $ unDNF r
]
l \/ r = l `mappend` r
bNot = error "bNot on CNF"
bTrue = DNF $ Disj [Conj []]
bFalse = DNF $ Disj []
bConst x = DNF $ Disj [Conj [x]]
-- | Reduce a boolean tree annotated by booleans to a single boolean.
reduceBoolExpr :: BoolExpr Bool -> Bool
reduceBoolExpr = evalBoolExpr id
-- Given a evaluation function of constants, returns an evaluation
-- function over boolean trees.
--
-- Note that since 'BoolExpr' is a functor, one can simply use
-- 'reduceBoolExpr':
--
-- @
-- evalBoolExpr f = reduceBoolExpr . fmap (f$)
-- @
evalBoolExpr :: (a -> Bool) -> (BoolExpr a -> Bool)
evalBoolExpr env expr = runEval (fromBoolExpr expr) env
-- | Returns constants used in a given boolean tree, these
-- constants are returned signed depending one how many
-- negations stands over a given constant.
constants :: BoolExpr a -> [Signed a]
constants = go True
where
go sign (BAnd a b) = go sign a ++ go sign b
go sign (BOr a b) = go sign a ++ go sign b
go sign (BNot t) = go (not sign) t
go _ BTrue = []
go _ BFalse = []
go sign (BConst x) = [if sign then x else negateSigned x]
dualize :: Boolean f => BoolExpr a -> f a
dualize (BAnd l r) = dualize l \/ dualize r
dualize (BOr l r) = dualize l /\ dualize r
dualize BTrue = bFalse
dualize BFalse = bTrue
dualize (BConst c) = negateConstant c
dualize (BNot e) = fromBoolExpr e
-- When dualize is used by pushNotInwards not BNot remain,
-- hence it makes sense to assert that dualize does not
-- have to work on BNot. However `dualize` can be freely
-- used as a fancy `bNot`.
-- dualize (BNot _) = error "dualize: impossible"
-- | Push the negations inwards as much as possible.
-- The resulting boolean tree no longer use negations.
pushNotInwards :: Boolean f => BoolExpr a -> f a
pushNotInwards (BAnd l r) = pushNotInwards l /\ pushNotInwards r
pushNotInwards (BOr l r) = pushNotInwards l \/ pushNotInwards r
pushNotInwards (BNot t) = dualize $ pushNotInwards t
pushNotInwards BTrue = bTrue
pushNotInwards BFalse = bFalse
pushNotInwards (BConst c) = bConst c
-- | Convert a boolean tree to a conjunctive normal form.
boolTreeToCNF :: BoolExpr a -> CNF a
boolTreeToCNF = pushNotInwards
-- | Convert a boolean tree to a disjunctive normal form.
boolTreeToDNF :: BoolExpr a -> DNF a
boolTreeToDNF = pushNotInwards
evalSigned :: (a -> Bool) -> Signed a -> Bool
evalSigned f (Positive x) = f x
evalSigned f (Negative x) = not $ f x
negateSigned :: Signed a -> Signed a
negateSigned (Positive x) = Negative x
negateSigned (Negative x) = Positive x
negateConstant :: Boolean f => Signed a -> f a
negateConstant = bConst . negateSigned

View File

@ -0,0 +1,57 @@
-- | Simplification logic for boolean expressions that is not
-- provided in the 'boolexpr' package.
module Data.BoolExpr.Simplify (
cannotBeTrue,
replace,
) where
import Data.BoolExpr
import Data.List qualified as L
import Data.Map (Map)
import Data.Map qualified as M
import Data.Set qualified as S
-- | Used only by "hasContradiction".
-- Note that the Booleans returned in this tuple are not actually used
-- as conditions, and therefore their semantic convention (e.g. associating
-- True = Positive and False = Negative) is irrelevant.
-- Rather, they are collected into sets
-- to determine whether both True and False exist for a key.
extractConstFromSigned :: Signed a -> (a, Bool)
extractConstFromSigned v = case v of
Negative x -> (x, False)
Positive x -> (x, True)
hasContradiction :: Ord a => Conj (Signed a) -> Bool
hasContradiction (Conj items) =
not $
M.null $
M.filter ((> 1) . S.size) $
M.fromListWith (<>) $
fmap (fmap S.singleton . extractConstFromSigned) items
simplifyDNF :: Ord a => DNF a -> DNF a
simplifyDNF (DNF (Disj disjunctions)) =
DNF $ Disj $ L.filter (not . hasContradiction) disjunctions
isAlwaysFalse :: Ord a => DNF a -> Bool
isAlwaysFalse (DNF (Disj disjunctions)) = L.null disjunctions
cannotBeTrue :: Ord a => BoolExpr a -> Bool
cannotBeTrue = isAlwaysFalse . simplifyDNF . boolTreeToDNF
replace :: Ord a => Map a Bool -> BoolExpr a -> BoolExpr a
replace f (BAnd a b) = BAnd (replace f a) (replace f b)
replace f (BOr a b) = BOr (replace f a) (replace f b)
replace f (BNot t) = BNot (replace f t)
replace _ BTrue = BTrue
replace _ BFalse = BFalse
replace m c@(BConst x) = case M.lookup varname m of
Nothing -> c
Just val ->
if txform val
then BTrue
else BFalse
where
(varname, isPositive) = extractConstFromSigned x
txform = if isPositive then id else not

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}

View File

@ -1,5 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}

View File

@ -1,5 +1,4 @@
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
@ -17,11 +16,6 @@
-- conditions, which can be used both for building interactive
-- tutorials and for standalone puzzles and scenarios.
module Swarm.Game.Scenario (
-- * Objectives
Objective,
objectiveGoal,
objectiveCondition,
-- * WorldDescription
PCell (..),
Cell,
@ -59,6 +53,7 @@ import Control.Carrier.Lift (Lift, sendIO)
import Control.Carrier.Throw.Either (Throw, throwError)
import Control.Lens hiding (from, (<.>))
import Control.Monad (filterM)
import Data.Aeson
import Data.Maybe (catMaybes, isNothing, listToMaybe)
import Data.Text (Text)
import Data.Text qualified as T
@ -68,6 +63,7 @@ import Swarm.Game.Recipe
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.WorldDescription
import Swarm.Language.Pipeline (ProcessedTerm)
@ -108,6 +104,7 @@ instance FromJSONE EntityMap Scenario where
-- parse custom entities
em <- liftE (buildEntityMap <$> (v .:? "entities" .!= []))
-- extend ambient EntityMap with custom entities
withE em $ do
-- parse 'known' entity names and make sure they exist
known <- liftE (v .:? "known" .!= [])
@ -134,7 +131,7 @@ instance FromJSONE EntityMap Scenario where
<*> pure known
<*> localE (,rsMap) (v ..: "world")
<*> pure rs
<*> liftE (v .:? "objectives" .!= [])
<*> (liftE (v .:? "objectives" .!= []) >>= validateObjectives)
<*> liftE (v .:? "solution")
<*> liftE (v .:? "stepsPerTick")

View File

@ -1,24 +1,73 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Swarm.Game.Scenario.Objective where
import Control.Applicative ((<|>))
import Control.Lens hiding (from, (<.>))
import Data.Aeson
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Yaml as Y
import GHC.Generics (Generic)
import Swarm.Game.Scenario.Objective.Logic as L
import Swarm.Language.Pipeline (ProcessedTerm)
import Swarm.TUI.Model.Achievement.Definitions
import Swarm.Util (reflow)
------------------------------------------------------------
-- Scenario objectives
------------------------------------------------------------
data PrerequisiteConfig = PrerequisiteConfig
{ previewable :: Bool
-- ^ Typically, only the currently "active" objectives are
-- displayed to the user in the Goals dialog. An objective
-- is "active" if all of its prerequisites are met.
--
-- However, some objectives may be "high-level", in that they may
-- explain the broader intention behind potentially multiple
-- prerequisites.
--
-- Set this to option True to display this goal in the "upcoming" section even
-- if the objective has currently unmet prerequisites.
, logic :: Prerequisite ObjectiveLabel
-- ^ Boolean expression of dependencies upon other objectives. Variables in this expression
-- are the "id"s of other objectives, and become "true" if the corresponding objective is completed.
-- The "condition" of the objective at hand shall not be evaluated until its
-- prerequisite expression evaluates as True.
--
-- Note that the achievement of these objective dependencies is
-- persistent; once achieved, they still count even if their "condition"
-- might not still hold. The condition is never re-evaluated once True.
}
deriving (Eq, Show, Generic, ToJSON)
instance FromJSON PrerequisiteConfig where
-- Parsing JSON/YAML 'PrerequisiteConfig' has a shorthand option
-- in which the boolean expression can be written directly,
-- bypassing the "logic" key.
-- Furthermore, an "Id" in a boolean expressions can be written
-- as a bare string without needing the "id" key.
parseJSON val = preLogic val <|> preObject val
where
preObject = withObject "prerequisite" $ \v ->
PrerequisiteConfig
<$> (v .:? "previewable" .!= False)
<*> v .: "logic"
preLogic = fmap (PrerequisiteConfig False) . parseJSON
-- | An objective is a condition to be achieved by a player in a
-- scenario.
data Objective = Objective
{ _objectiveGoal :: [Text]
, _objectiveTeaser :: Maybe Text
, _objectiveCondition :: ProcessedTerm
, _objectiveId :: Maybe ObjectiveLabel
, _objectiveOptional :: Bool
, _objectivePrerequisite :: Maybe PrerequisiteConfig
, _objectiveHidden :: Bool
, _objectiveAchievement :: Maybe AchievementInfo
}
deriving (Eq, Show, Generic, ToJSON)
@ -28,14 +77,119 @@ makeLensesWith (lensRules & generateSignatures .~ False) ''Objective
-- during play. It is represented as a list of paragraphs.
objectiveGoal :: Lens' Objective [Text]
-- | A very short (3-5 words) description of the goal for
-- displaying on the left side of the Objectives modal.
objectiveTeaser :: Lens' Objective (Maybe Text)
-- | A winning condition for the objective, expressed as a
-- program of type @cmd bool@. By default, this program will be
-- run to completion every tick (the usual limits on the number
-- of CESK steps per tick do not apply).
objectiveCondition :: Lens' Objective ProcessedTerm
-- | Optional name by which this objective may be referenced
-- as a prerequisite for other objectives.
objectiveId :: Lens' Objective (Maybe Text)
-- | Indicates whether the objective is not required in order
-- to "win" the scenario. Useful for (potentially hidden) achievements.
-- If the field is not supplied, it defaults to False (i.e. the
-- objective is mandatory to "win").
objectiveOptional :: Lens' Objective Bool
-- | Dependencies upon other objectives
objectivePrerequisite :: Lens' Objective (Maybe PrerequisiteConfig)
-- | Whether the goal is displayed in the UI before completion.
-- The goal will always be revealed after it is completed.
--
-- This attribute often goes along with an Achievement.
objectiveHidden :: Lens' Objective Bool
-- | An optional Achievement that is to be registered globally
-- when this objective is completed.
objectiveAchievement :: Lens' Objective (Maybe AchievementInfo)
instance FromJSON Objective where
parseJSON = withObject "objective" $ \v ->
Objective
<$> (fmap . map) reflow (v .:? "goal" .!= [])
<*> (v .:? "teaser")
<*> (v .: "condition")
<*> (v .:? "id")
<*> (v .:? "optional" .!= False)
<*> (v .:? "prerequisite")
<*> (v .:? "hidden" .!= False)
<*> (v .:? "achievement")
data CompletionBuckets = CompletionBuckets
{ incomplete :: [Objective]
, completed :: [Objective]
, unwinnable :: [Objective]
}
deriving (Show, Generic, FromJSON, ToJSON)
data ObjectiveCompletion = ObjectiveCompletion
{ completionBuckets :: CompletionBuckets
-- ^ This is the authoritative "completion status"
-- for all objectives.
-- Note that there is a separate Set to store the
-- completion status of prerequisite objectives, which
-- must be carefully kept in sync with this.
-- Those prerequisite objectives are required to have
-- labels, but other objectives are not.
-- Therefore only prerequisites exist in the completion
-- map keyed by label.
, completedIDs :: Set.Set ObjectiveLabel
}
deriving (Show, Generic, FromJSON, ToJSON)
-- | Concatenates all incomplete and completed objectives.
listAllObjectives :: CompletionBuckets -> [Objective]
listAllObjectives (CompletionBuckets x y z) = x <> y <> z
addCompleted :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
addCompleted obj (ObjectiveCompletion buckets cmplIds) =
ObjectiveCompletion newBuckets newCmplById
where
newBuckets =
buckets
{ completed = obj : completed buckets
}
newCmplById = case _objectiveId obj of
Nothing -> cmplIds
Just lbl -> Set.insert lbl cmplIds
addUnwinnable :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
addUnwinnable obj (ObjectiveCompletion buckets cmplIds) =
ObjectiveCompletion newBuckets cmplIds
where
newBuckets =
buckets
{ unwinnable = obj : unwinnable buckets
}
setIncomplete ::
([Objective] -> [Objective]) ->
ObjectiveCompletion ->
ObjectiveCompletion
setIncomplete f (ObjectiveCompletion buckets cmplIds) =
ObjectiveCompletion newBuckets cmplIds
where
newBuckets =
buckets
{ incomplete = f $ incomplete buckets
}
addIncomplete :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
addIncomplete obj = setIncomplete (obj :)
-- | Returns the "ObjectiveCompletion" with the "incomplete" goals
-- extracted to a separate tuple member.
-- This is intended as input to a "fold".
extractIncomplete :: ObjectiveCompletion -> (ObjectiveCompletion, [Objective])
extractIncomplete oc =
(withoutIncomplete, incompleteGoals)
where
incompleteGoals = incomplete $ completionBuckets oc
withoutIncomplete = setIncomplete (const []) oc

View File

@ -0,0 +1,144 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
-- | Utilities for performing graph analysis on Objective prerequisites
module Swarm.Game.Scenario.Objective.Graph where
import Control.Arrow ((&&&))
import Data.Aeson
import Data.BoolExpr (Signed (Positive))
import Data.BoolExpr qualified as BE
import Data.Graph (Graph, SCC (AcyclicSCC), graphFromEdges, stronglyConnComp)
import Data.Map (Map)
import Data.Map.Strict qualified as M
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Tuple (swap)
import GHC.Generics (Generic)
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Logic as L
import Swarm.Game.Scenario.Objective.WinCheck
-- | This is only needed for constructing a Graph,
-- which requires all nodes to have a key.
data ObjectiveId
= Label (Signed ObjectiveLabel)
| -- | for unlabeled objectives
Ordinal Int
deriving (Eq, Ord, Show, Generic, ToJSON)
data GraphInfo = GraphInfo
{ actualGraph :: Graph
, isAcyclic :: Bool
, sccInfo :: [SCC Objective]
, nodeIDs :: [ObjectiveId]
}
deriving (Show, Generic, ToJSON)
instance ToJSON (SCC Objective) where
toJSON = String . T.pack . show
instance ToJSON Graph where
toJSON = String . T.pack . show
getConstFromSigned :: BE.Signed a -> a
getConstFromSigned = \case
BE.Positive x -> x
BE.Negative x -> x
-- | Collect all of the constants that have a negation.
-- This is necessary for enumerating all of the distinct
-- nodes when constructing a Graph, as we treat a constant
-- and its negation as distinct nodes.
getNegatedIds :: [Objective] -> Map ObjectiveLabel Objective
getNegatedIds objs =
M.fromList $ mapMaybe f allConstants
where
objectivesById = getObjectivesById objs
allPrereqExpressions = mapMaybe _objectivePrerequisite objs
allConstants =
mapMaybe onlyNegative
. Set.toList
. Set.unions
. map (getDistinctConstants . logic)
$ allPrereqExpressions
f = sequenceA . \x -> (x, M.lookup x objectivesById)
onlyNegative = \case
BE.Negative x -> Just x
_ -> Nothing
getObjectivesById :: [Objective] -> Map ObjectiveLabel Objective
getObjectivesById objs =
M.fromList $
map swap $
mapMaybe (sequenceA . (id &&& _objectiveId)) objs
-- | Uses the textual labels for those objectives that
-- have them, and assigns arbitrary integer IDs for
-- the remaining.
--
-- Only necessary for constructing a "Graph".
assignIds :: [Objective] -> Map ObjectiveId Objective
assignIds objs =
unlabeledObjsMap <> labeledObjsMap
where
objectivesById = getObjectivesById objs
labeledObjsMap = M.mapKeys (Label . Positive) objectivesById
unlabeledObjs = filter (null . _objectiveId) objs
unlabeledObjsMap = M.fromList $ zipWith (\x y -> (Ordinal x, y)) [0 ..] unlabeledObjs
type Edges = [(Objective, ObjectiveId, [ObjectiveId])]
-- | NOTE: Based strictly on the goal labels, the graph could
-- potentially contain a cycle, if there exist
-- mutually-exclusive goals. That is, if goal A depends on the NOT
-- of "goal B". Goal B could then also depend on "NOT Goal A" (re-enforcing the
-- mutual-exclusivity), or it could mandate a completion order, e.g.:
-- Goal A and Goal B are simultaneously available to pursue. However, if the
-- player completes Goal B first, then it closes off the option to complete
-- Goal A. However, if Goal A is completed first, then the user is also allowed
-- to complete Goal B.
--
-- To avoid a "cycle" in this circumstance, "A" needs to exist as a distinct node
-- from "NOT A" in the graph.
makeGraph :: Edges -> Graph
makeGraph edges =
myGraph
where
(myGraph, _, _) = graphFromEdges edges
makeGraphEdges :: [Objective] -> Edges
makeGraphEdges objectives =
rootTuples <> negatedTuples
where
rootTuples = map f $ M.toList $ assignIds objectives
negatedTuples = map gg $ M.toList $ getNegatedIds objectives
gg (k, v) = (v, Label $ BE.Negative k, [])
f (k, v) = (v, k, maybe [] (map Label . g) $ _objectivePrerequisite v)
g = Set.toList . getDistinctConstants . logic
isAcyclicGraph :: [SCC Objective] -> Bool
isAcyclicGraph =
all isAcyclicVertex
where
isAcyclicVertex = \case
AcyclicSCC _ -> True
_ -> False
makeGraphInfo :: ObjectiveCompletion -> GraphInfo
makeGraphInfo oc =
GraphInfo
(makeGraph edges)
(isAcyclicGraph connectedComponents)
connectedComponents
(M.keys $ assignIds objs)
where
edges = makeGraphEdges objs
connectedComponents = stronglyConnComp edges
objs = listAllObjectives $ completionBuckets oc

View File

@ -0,0 +1,46 @@
-- | A model for defining boolean expressions for Objective prerequisites.
--
-- This model is intended to be user-facing in the .yaml files, and is
-- distinct from that in 'Data.BoolExpr'.
module Swarm.Game.Scenario.Objective.Logic where
import Control.Applicative ((<|>))
import Data.Aeson
import Data.BoolExpr
import Data.Char (toLower)
import Data.List.NonEmpty (NonEmpty)
import Data.Text (Text)
import GHC.Generics (Generic)
type ObjectiveLabel = Text
-- | In contrast with the "BoolExpr" type,
-- "And" and "Or" can have /one or more/ children
-- instead of /exactly two/.
data Prerequisite a
= And (NonEmpty (Prerequisite a))
| Or (NonEmpty (Prerequisite a))
| Not (Prerequisite a)
| Id a
deriving (Eq, Show, Generic, Functor, Foldable)
prerequisiteOptions :: Options
prerequisiteOptions =
defaultOptions
{ sumEncoding = ObjectWithSingleField
, constructorTagModifier = map toLower
}
instance ToJSON (Prerequisite ObjectiveLabel) where
toJSON = genericToJSON prerequisiteOptions
instance FromJSON (Prerequisite ObjectiveLabel) where
parseJSON x = preString x <|> genericParseJSON prerequisiteOptions x
where
preString = withText "prerequisite" $ pure . Id
toBoolExpr :: Prerequisite a -> BoolExpr a
toBoolExpr (And xs) = foldr1 BAnd (fmap toBoolExpr xs)
toBoolExpr (Or xs) = foldr1 BOr (fmap toBoolExpr xs)
toBoolExpr (Not x) = BNot $ toBoolExpr x
toBoolExpr (Id x) = BConst $ pure x

View File

@ -0,0 +1,105 @@
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE NoGeneralizedNewtypeDeriving #-}
-- A UI-centric model for Objective presentation.
module Swarm.Game.Scenario.Objective.Presentation.Model where
import Brick.Widgets.List qualified as BL
import Control.Lens (makeLenses)
import Data.Aeson
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (mapMaybe)
import GHC.Generics (Generic)
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.WinCheck
import Swarm.TUI.Model.Name
-- | These are intended to be used as keys in a map
-- of lists of goals.
data GoalStatus
= -- | Goals in this category have other goals as prerequisites.
-- However, they are only displayed if the "previewable" attribute
-- is `true`.
Upcoming
| -- | Goals in this category may be pursued in parallel.
-- However, they are only displayed if the "hidden" attribute
-- is `false`.
Active
| -- | A goal's programmatic condition, as well as all its prerequisites, were completed.
-- This is a "latch" mechanism; at some point the conditions required to meet the goal may
-- no longer hold. Nonetheless, the goal remains "completed".
Completed
| -- | A goal that can no longer be achieved.
-- If this goal is not an "optional" goal, then the player
-- also "Loses" the scenario.
--
-- Note that currently the only way to "Fail" a goal is by way
-- of a negative prerequisite that was completed.
Failed
deriving (Show, Eq, Ord, Bounded, Enum, Generic, ToJSON, ToJSONKey)
-- | TODO: #1044 Could also add an "ObjectiveFailed" constructor...
newtype Announcement
= ObjectiveCompleted Objective
deriving (Show, Generic, ToJSON)
type CategorizedGoals = Map GoalStatus (NonEmpty Objective)
data GoalEntry
= Header GoalStatus
| Goal GoalStatus Objective
isHeader :: GoalEntry -> Bool
isHeader = \case
Header _ -> True
_ -> False
data GoalTracking = GoalTracking
{ announcements :: [Announcement]
-- ^ TODO: #1044 the actual contents of these are not used yet,
-- other than as a flag to pop up the Goal dialog.
, goals :: CategorizedGoals
}
deriving (Generic, ToJSON)
data GoalDisplay = GoalDisplay
{ _goalsContent :: GoalTracking
, _listWidget :: BL.List Name GoalEntry
-- ^ required for maintaining the selection/navigation
-- state among list items
}
makeLenses ''GoalDisplay
emptyGoalDisplay :: GoalDisplay
emptyGoalDisplay =
GoalDisplay (GoalTracking mempty mempty) $
BL.list ObjectivesList mempty 1
hasAnythingToShow :: GoalTracking -> Bool
hasAnythingToShow (GoalTracking ann g) = not (null ann && null g)
constructGoalMap :: Bool -> ObjectiveCompletion -> CategorizedGoals
constructGoalMap isCheating objectiveCompletion@(ObjectiveCompletion buckets _) =
M.fromList $
mapMaybe (traverse nonEmpty) categoryList
where
categoryList =
[ (Upcoming, displayableInactives)
, (Active, suppressHidden activeGoals)
, (Completed, completed buckets)
, (Failed, unwinnable buckets)
]
displayableInactives =
suppressHidden $
filter (maybe False previewable . _objectivePrerequisite) inactiveGoals
suppressHidden =
if isCheating
then id
else filter $ not . _objectiveHidden
(activeGoals, inactiveGoals) = partitionActiveObjectives objectiveCompletion

View File

@ -0,0 +1,80 @@
{-# LANGUAGE OverloadedStrings #-}
-- Display logic for Objectives.
module Swarm.Game.Scenario.Objective.Presentation.Render where
import Brick hiding (Direction, Location)
import Brick.Widgets.Center
import Brick.Widgets.List qualified as BL
import Control.Applicative ((<|>))
import Control.Lens hiding (Const, from)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as M
import Data.Maybe (listToMaybe)
import Data.Vector qualified as V
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Presentation.Model
import Swarm.TUI.Attr
import Swarm.TUI.Model.Name
import Swarm.TUI.View.Util
makeListWidget :: GoalTracking -> BL.List Name GoalEntry
makeListWidget (GoalTracking _announcements categorizedObjs) =
BL.listMoveTo 1 $ BL.list ObjectivesList (V.fromList objList) 1
where
objList = concatMap f $ M.toList categorizedObjs
f (h, xs) = Header h : map (Goal h) (NE.toList xs)
renderGoalsDisplay :: GoalDisplay -> Widget Name
renderGoalsDisplay gd =
padAll 1 $
if goalsCount > 1
then
hBox
[ leftSide
, hLimitPercent 70 goalElaboration
]
else goalElaboration
where
lw = _listWidget gd
leftSide =
hLimitPercent 30 $
vBox
[ hCenter $ str "Goals"
, padAll 1 $
vLimit 10 $
BL.renderList (const drawGoalListItem) True lw
]
goalsCount = sum . M.elems . M.map NE.length . goals $ gd ^. goalsContent
goalElaboration =
padLeft (Pad 2) $
maybe emptyWidget (singleGoalDetails . snd) $
BL.listSelectedElement lw
getCompletionIcon :: Objective -> GoalStatus -> Widget Name
getCompletionIcon obj = \case
Upcoming -> withAttr yellowAttr $ txt ""
Active -> withAttr cyanAttr $ txt ""
Failed -> withAttr redAttr $ txt ""
Completed -> withAttr colorattr $ txt ""
where
colorattr =
if obj ^. objectiveHidden
then magentaAttr
else greenAttr
drawGoalListItem ::
GoalEntry ->
Widget Name
drawGoalListItem = \case
Header gs -> withAttr boldAttr $ str $ show gs
Goal gs obj -> getCompletionIcon obj gs <+> titleWidget
where
textSource = obj ^. objectiveTeaser <|> obj ^. objectiveId <|> listToMaybe (obj ^. objectiveGoal)
titleWidget = maybe (txt "?") withEllipsis textSource
singleGoalDetails :: GoalEntry -> Widget Name
singleGoalDetails = \case
Header _gs -> displayParagraphs [" "]
Goal _gs obj -> displayParagraphs $ obj ^. objectiveGoal

View File

@ -0,0 +1,50 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Validity checking for Objective prerequisites
module Swarm.Game.Scenario.Objective.Validation where
import Control.Monad (unless)
import Data.Foldable (for_, toList)
import Data.Graph (stronglyConnComp)
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import Data.Text qualified as T
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Graph
import Swarm.Util (quote)
import Witch (into)
-- | Performs monadic validation before returning
-- the "pure" construction of a wrapper record.
-- This validation entails:
-- 1) Ensuring that all goal references utilized in prerequisites
-- actually exist
-- 2) Ensuring that the graph of dependencies is acyclic.
validateObjectives ::
MonadFail m =>
[Objective] ->
m [Objective]
validateObjectives objectives = do
for_ objectives $ \x -> case _objectivePrerequisite x of
Just p ->
unless (null remaining) $
fail . into @String $
T.unwords
[ "Reference to undefined objective(s)"
, T.intercalate ", " (map quote $ Set.toList remaining) <> "."
, "Defined are:"
, T.intercalate ", " (map quote $ Set.toList allIds)
]
where
refs = Set.fromList $ toList $ logic p
remaining = Set.difference refs allIds
Nothing -> return ()
unless (isAcyclicGraph connectedComponents) $
fail . into @String $
T.unwords ["There are dependency cycles in the prerequisites."]
return objectives
where
connectedComponents = stronglyConnComp $ makeGraphEdges objectives
allIds = Set.fromList $ mapMaybe _objectiveId objectives

View File

@ -0,0 +1,83 @@
-- | Utilities to check whether conditions are met for a game win/loss.
module Swarm.Game.Scenario.Objective.WinCheck where
import Data.Aeson
import Data.BoolExpr qualified as BE
import Data.BoolExpr.Simplify qualified as Simplify
import Data.List (partition)
import Data.Map qualified as M
import Data.Set (Set)
import Data.Set qualified as Set
import GHC.Generics (Generic)
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Logic as L
-- | We have "won" if all of the "unwinnable" or remaining "incomplete" objectives are "optional".
didWin :: ObjectiveCompletion -> Bool
didWin oc = all _objectiveOptional $ incomplete buckets <> unwinnable buckets
where
buckets = completionBuckets oc
-- | We have "lost" if any of the "unwinnable" objectives not "optional".
didLose :: ObjectiveCompletion -> Bool
didLose oc = not $ all _objectiveOptional $ unwinnable buckets
where
buckets = completionBuckets oc
isPrereqsSatisfied :: ObjectiveCompletion -> Objective -> Bool
isPrereqsSatisfied completions =
maybe True f . _objectivePrerequisite
where
f = BE.evalBoolExpr getTruth . L.toBoolExpr . logic
getTruth :: ObjectiveLabel -> Bool
getTruth label = Set.member label $ completedIDs completions
isUnwinnablePrereq :: Set ObjectiveLabel -> Prerequisite ObjectiveLabel -> Bool
isUnwinnablePrereq completedObjectives =
Simplify.cannotBeTrue . Simplify.replace boolMap . L.toBoolExpr
where
boolMap =
M.fromList $
map (,True) $
Set.toList completedObjectives
isUnwinnable :: ObjectiveCompletion -> Objective -> Bool
isUnwinnable completions obj =
maybe False (isUnwinnablePrereq (completedIDs completions) . logic) $ _objectivePrerequisite obj
-- | The first element of the returned tuple consists of "active" objectives,
-- the second element "inactive".
partitionActiveObjectives :: ObjectiveCompletion -> ([Objective], [Objective])
partitionActiveObjectives oc =
partition (isPrereqsSatisfied oc) $
incomplete $
completionBuckets oc
getActiveObjectives :: ObjectiveCompletion -> [Objective]
getActiveObjectives =
fst . partitionActiveObjectives
-- | For debugging only (via Web API)
data PrereqSatisfaction = PrereqSatisfaction
{ objective :: Objective
, deps :: Set (BE.Signed ObjectiveLabel)
, prereqsSatisfied :: Bool
}
deriving (Generic, ToJSON)
-- | Used only by the web interface for debugging
getSatisfaction :: ObjectiveCompletion -> [PrereqSatisfaction]
getSatisfaction oc =
map f $
listAllObjectives $
completionBuckets oc
where
f y =
PrereqSatisfaction
y
(maybe mempty (getDistinctConstants . logic) $ _objectivePrerequisite y)
(isPrereqsSatisfied oc y)
getDistinctConstants :: (Ord a) => Prerequisite a -> Set (BE.Signed a)
getDistinctConstants = Set.fromList . BE.constants . toBoolExpr

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
@ -20,10 +19,12 @@ module Swarm.Game.State (
-- * Game state record and related types
ViewCenterRule (..),
REPLStatus (..),
WinStatus (..),
WinCondition (..),
ObjectiveCompletion (..),
_NoWinCondition,
_WinConditions,
_Won,
Announcement (..),
RunStatus (..),
Seed,
GameState,
@ -33,6 +34,7 @@ module Swarm.Game.State (
winCondition,
winSolution,
gameAchievements,
announcementQueue,
runStatus,
paused,
robotMap,
@ -122,7 +124,6 @@ import Data.IntSet (IntSet)
import Data.IntSet qualified as IS
import Data.IntSet.Lens (setOf)
import Data.List (partition, sortOn)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map (Map)
import Data.Map qualified as M
@ -145,6 +146,8 @@ import Swarm.Game.Recipe (
reqRecipeMap,
)
import Swarm.Game.Robot
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Presentation.Model
import Swarm.Game.ScenarioInfo
import Swarm.Game.Terrain (TerrainType (..))
import Swarm.Game.Value (Value)
@ -198,15 +201,28 @@ data REPLStatus
REPLWorking (Typed (Maybe Value))
deriving (Eq, Show, Generic, FromJSON, ToJSON)
data WinStatus
= -- | There are one or more objectives remaining that the player
-- has not yet accomplished.
Ongoing
| -- | The player has won.
-- The boolean indicates whether they have
-- already been congratulated.
Won Bool
| -- | The player has completed certain "goals" that preclude
-- (via negative prerequisites) the completion of all of the
-- required goals.
-- The boolean indicates whether they have
-- already been informed.
Unwinnable Bool
deriving (Show, Generic, FromJSON, ToJSON)
data WinCondition
= -- | There is no winning condition.
NoWinCondition
| -- | There are one or more objectives remaining that the player
-- has not yet accomplished.
WinConditions (NonEmpty Objective)
| -- | The player has won. The boolean indicates whether they have
-- already been congratulated.
Won Bool
| -- | NOTE: It is possible to continue to achieve "optional" objectives
-- even after the game has been won (or deemed unwinnable).
WinConditions WinStatus ObjectiveCompletion
deriving (Show, Generic, FromJSON, ToJSON)
makePrisms ''WinCondition
@ -261,6 +277,7 @@ data GameState = GameState
, _winCondition :: WinCondition
, _winSolution :: Maybe ProcessedTerm
, _gameAchievements :: Map GameplayAchievement Attainment
, _announcementQueue :: Seq Announcement
, _runStatus :: RunStatus
, _robotMap :: IntMap Robot
, -- A set of robots to consider for the next game tick. It is guaranteed to
@ -340,6 +357,13 @@ winSolution :: Lens' GameState (Maybe ProcessedTerm)
-- | Map of in-game achievements that were attained
gameAchievements :: Lens' GameState (Map GameplayAchievement Attainment)
-- | A queue of global announcments.
-- Note that this is distinct from the "messageQueue",
-- which is for messages emitted by robots.
--
-- Note that we put the newest entry to the right.
announcementQueue :: Lens' GameState (Seq Announcement)
-- | The current 'RunStatus'.
runStatus :: Lens' GameState RunStatus
@ -717,6 +741,7 @@ initGameState = do
, -- This does not need to be initialized with anything,
-- since the master list of achievements is stored in UIState
_gameAchievements = mempty
, _announcementQueue = mempty
, _runStatus = Running
, _robotMap = IM.empty
, _robotsByLocation = M.empty
@ -876,7 +901,12 @@ scenarioToGameState scenario userSeed toRun g = do
(genRobots, wf) = buildWorld em (scenario ^. scenarioWorld)
theWorld = W.newWorld . wf
theWinCondition = maybe NoWinCondition WinConditions (NE.nonEmpty (scenario ^. scenarioObjectives))
theWinCondition =
maybe
NoWinCondition
(\x -> WinConditions Ongoing (ObjectiveCompletion (CompletionBuckets (NE.toList x) mempty mempty) mempty))
(NE.nonEmpty (scenario ^. scenarioObjectives))
initGensym = length robotList - 1
addRecipesWith f gRs = IM.unionWith (<>) (f $ scenario ^. scenarioRecipes) (g ^. gRs)

View File

@ -26,7 +26,7 @@ import Control.Effect.Error
import Control.Effect.Lens
import Control.Effect.Lift
import Control.Lens as Lens hiding (Const, distrib, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=))
import Control.Monad (forM, forM_, guard, msum, unless, when)
import Control.Monad (foldM, forM, forM_, guard, msum, unless, when)
import Data.Array (bounds, (!))
import Data.Bifunctor (second)
import Data.Bool (bool)
@ -38,11 +38,10 @@ 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 (NonEmpty ((:|)))
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))
import Data.Sequence ((><))
import Data.Sequence qualified as Seq
import Data.Set (Set)
import Data.Set qualified as S
@ -58,7 +57,8 @@ import Swarm.Game.Entity qualified as E
import Swarm.Game.Exception
import Swarm.Game.Recipe
import Swarm.Game.Robot
import Swarm.Game.Scenario (objectiveCondition)
import Swarm.Game.Scenario.Objective qualified as OB
import Swarm.Game.Scenario.Objective.WinCheck qualified as WC
import Swarm.Game.State
import Swarm.Game.Value
import Swarm.Game.World qualified as W
@ -132,37 +132,123 @@ gameTick = do
-- Possibly see if the winning condition for the current objective is met.
wc <- use winCondition
case wc of
WinConditions (obj :| objs) -> do
WinConditions winState oc -> do
g <- get @GameState
-- Execute the win condition check *hypothetically*: i.e. in a
-- fresh CESK machine, using a copy of the current game state.
v <- runThrow @Exn . evalState @GameState g $ evalPT (obj ^. objectiveCondition)
let markWin = winCondition .= maybe (Won False) WinConditions (NE.nonEmpty objs)
let h = hypotheticalRobot (Out VUnit emptyStore []) 0
case stripVResult <$> v of
-- Log exceptions in the message queue so we can check for them in tests
Left exn -> do
em <- use entityMap
m <- evalState @Robot h $ createLogEntry (ErrorTrace Critical) (formatExn em exn)
emitMessage m
Right (VBool res) -> when res markWin
Right val -> do
m <-
evalState @Robot h $
createLogEntry (ErrorTrace Critical) $
T.unwords
[ "Non boolean value:"
, prettyValue val
, "real:"
, T.pack (show val)
]
emitMessage m
em <- use entityMap
hypotheticalWinCheck em g winState oc
_ -> return ()
-- Advance the game time by one.
ticks += 1
-- | An accumulator for folding over the incomplete
-- objectives to evaluate for their completion
data CompletionsWithExceptions = CompletionsWithExceptions
{ exceptions :: [Text]
, completions :: ObjectiveCompletion
, completionAnnouncementQueue :: [OB.Objective]
-- ^ Upon completion, an objective is enqueued.
-- It is dequeued when displayed on the UI.
}
-- | Execute the win condition check *hypothetically*: i.e. in a
-- fresh CESK machine, using a copy of the current game state.
--
-- The win check is performed only on "active" goals; that is,
-- the goals that are currently unmet and have had all of their
-- prerequisites satisfied.
-- Note that it may be possible, while traversing through the
-- goal list, for one goal to be met earlier in the list that
-- happens to be a prerequisite later in the traversal. This
-- is why:
-- 1) We must not pre-filter the goals to be traversed based
-- on satisfied prerequisites (i.e. we cannot use the
-- "getActiveObjectives" function).
-- 2) The traversal order must be "reverse topological" order, so
-- that prerequisites are evaluated before dependent goals.
-- 3) The iteration needs to be a "fold", so that state is updated
-- after each element.
hypotheticalWinCheck ::
(Has (State GameState) sig m, Has (Lift IO) sig m) =>
EntityMap ->
GameState ->
WinStatus ->
ObjectiveCompletion ->
m ()
hypotheticalWinCheck em g ws oc = do
-- We can fully and accurately evaluate the new state of the objectives DAG
-- in a single pass, so long as we visit it in reverse topological order.
--
-- N.B. The "reverse" is essential due to the re-population of the
-- "incomplete" goal list by cons-ing.
finalAccumulator <-
foldM foldFunc initialAccumulator $
reverse incompleteGoals
let newWinState = case ws of
Ongoing -> getNextWinState $ completions finalAccumulator
_ -> ws
winCondition .= WinConditions newWinState (completions finalAccumulator)
announcementQueue %= (>< Seq.fromList (map ObjectiveCompleted $ completionAnnouncementQueue finalAccumulator))
mapM_ handleException $ exceptions finalAccumulator
where
getNextWinState completedObjs
| WC.didWin completedObjs = Won False
| WC.didLose completedObjs = Unwinnable False
| otherwise = Ongoing
(withoutIncomplete, incompleteGoals) = OB.extractIncomplete oc
initialAccumulator = CompletionsWithExceptions [] withoutIncomplete []
-- All of the "incomplete" goals have been emptied from the initial accumulator, and
-- these are what we iterate over with the fold.
-- Each iteration, we either place the goal back into the "incomplete" bucket, or
-- we determine that it has been met or impossible and place it into the "completed"
-- or "unwinnable" bucket, respectively.
foldFunc (CompletionsWithExceptions exnTexts currentCompletions announcements) obj = do
v <-
if WC.isPrereqsSatisfied currentCompletions obj
then runThrow @Exn . evalState @GameState g $ evalPT $ obj ^. OB.objectiveCondition
else return $ Right $ VBool False
let simplified = simplifyResult $ stripVResult <$> v
return $ case simplified of
Left exnText ->
CompletionsWithExceptions
(exnText : exnTexts)
currentCompletions
announcements
Right boolResult ->
CompletionsWithExceptions
exnTexts
(modifyCompletions obj currentCompletions)
(modifyAnnouncements announcements)
where
(modifyCompletions, modifyAnnouncements)
| boolResult = (OB.addCompleted, (obj :))
| WC.isUnwinnable currentCompletions obj = (OB.addUnwinnable, id)
| otherwise = (OB.addIncomplete, id)
simplifyResult = \case
Left exn -> Left $ formatExn em exn
Right (VBool x) -> Right x
Right val ->
Left $
T.unwords
[ "Non boolean value:"
, prettyValue val
, "real:"
, T.pack (show val)
]
-- Log exceptions in the message queue so we can check for them in tests
handleException exnText = do
m <- evalState @Robot h $ createLogEntry (ErrorTrace Critical) exnText
emitMessage m
where
h = hypotheticalRobot (Out VUnit emptyStore []) 0
evalPT ::
(Has (Lift IO) sig m, Has (Throw Exn) sig m, Has (State GameState) sig m) =>
ProcessedTerm ->

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GADTs #-}

View File

@ -1,4 +1,3 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveDataTypeable #-}
-- |

View File

@ -40,6 +40,7 @@ module Swarm.TUI.Attr (
infoAttr,
boldAttr,
dimAttr,
magentaAttr,
cyanAttr,
yellowAttr,
blueAttr,
@ -83,6 +84,7 @@ swarmAttrMap =
, (blueAttr, fg V.blue)
, (yellowAttr, fg V.yellow)
, (cyanAttr, fg V.cyan)
, (magentaAttr, fg V.magenta)
, -- Default attribute
(defAttr, V.defAttr)
]
@ -161,12 +163,13 @@ dimAttr = attrName "dim"
defAttr = attrName "def"
-- | Some basic colors used in TUI.
redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr :: AttrName
redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr, magentaAttr :: AttrName
redAttr = attrName "red"
greenAttr = attrName "green"
blueAttr = attrName "blue"
yellowAttr = attrName "yellow"
cyanAttr = attrName "cyan"
magentaAttr = attrName "magenta"
instance ToJSON AttrName where
toJSON = toJSON . head . attrNameComponents

View File

@ -54,6 +54,7 @@ import Control.Monad.Extra (whenJust)
import Control.Monad.State
import Data.Bits
import Data.Either (isRight)
import Data.Foldable (toList)
import Data.Int (Int32)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
@ -69,6 +70,8 @@ import Linear
import Swarm.Game.CESK (cancel, emptyStore, initMachine)
import Swarm.Game.Entity hiding (empty)
import Swarm.Game.Robot
import Swarm.Game.Scenario.Objective.Presentation.Model
import Swarm.Game.Scenario.Objective.Presentation.Render qualified as GR
import Swarm.Game.ScenarioInfo
import Swarm.Game.State
import Swarm.Game.Step (gameTick)
@ -254,7 +257,8 @@ handleMainEvent ev = do
-- ctrl-q works everywhere
ControlChar 'q' ->
case s ^. gameState . winCondition of
Won _ -> toggleModal WinModal
WinConditions (Won _) _ -> toggleModal WinModal
WinConditions (Unwinnable _) _ -> toggleModal LoseModal
_ -> toggleModal QuitModal
VtyEvent (V.EvResize _ _) -> invalidateCacheEntry WorldCache
Key V.KEsc
@ -278,9 +282,10 @@ handleMainEvent ev = do
FKey 5 | not (null (s ^. gameState . messageNotifications . notificationsContent)) -> do
toggleModal MessagesModal
gameState . lastSeenMessageTime .= s ^. gameState . ticks
ControlChar 'g' -> case s ^. uiState . uiGoal of
Just g | g /= [] -> toggleModal (GoalModal g)
_ -> continueWithoutRedraw
ControlChar 'g' ->
if hasAnythingToShow $ s ^. uiState . uiGoal . goalsContent
then toggleModal GoalModal
else continueWithoutRedraw
MetaChar 'h' -> do
t <- liftIO $ getTime Monotonic
h <- use $ uiState . uiHideRobotsUntil
@ -401,8 +406,14 @@ handleModalEvent = \case
Brick.zoom (uiState . uiModal . _Just . modalDialog) (handleDialogEvent ev)
modal <- preuse $ uiState . uiModal . _Just . modalType
case modal of
Just GoalModal -> do
lw <- use $ uiState . uiGoal . listWidget
newList <- refreshList lw
uiState . uiGoal . listWidget .= newList
Just _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
_ -> return ()
where
refreshList lw = nestEventM' lw $ handleListEventWithSeparators ev isHeader
-- | Write the @ScenarioInfo@ out to disk when exiting a game.
saveScenarioInfoOnQuit :: (MonadIO m, MonadState AppState m) => m ()
@ -418,7 +429,10 @@ saveScenarioInfoOnQuit = do
gs <- use $ gameState . scenarios
p <- liftIO $ normalizeScenarioPath gs p'
t <- liftIO getZonedTime
won <- isJust <$> preuse (gameState . winCondition . _Won)
wc <- use $ gameState . winCondition
let won = case wc of
WinConditions (Won _) _ -> True
_ -> False
ts <- use $ gameState . ticks
let currentScenarioInfo :: Traversal' AppState ScenarioInfo
currentScenarioInfo = gameState . scenarios . scenarioItemByPath p . _SISingle . _2
@ -703,35 +717,72 @@ updateUI = do
oldBotMore <- uiState . uiMoreInfoBot <<.= botMore
return $ oldTopMore /= topMore || oldBotMore /= botMore
-- Decide whether we need to update the current goal text, and pop
-- up a modal dialog.
curGoal <- use (uiState . uiGoal)
newGoal <-
preuse (gameState . winCondition . _WinConditions . _NonEmpty . _1 . objectiveGoal)
goalOrWinUpdated <- doGoalUpdates
let goalUpdated = curGoal /= newGoal
when goalUpdated $ do
uiState . uiGoal .= newGoal
case newGoal of
Just goal | goal /= [] -> do
toggleModal (GoalModal goal)
_ -> return ()
-- Decide whether to show a pop-up modal congratulating the user on
-- successfully completing the current challenge.
winModalUpdated <- do
w <- use (gameState . winCondition)
case w of
Won False -> do
gameState . winCondition .= Won True
toggleModal WinModal
uiState . uiMenu %= advanceMenu
return True
_ -> return False
let redraw = g ^. needsRedraw || inventoryUpdated || replUpdated || logUpdated || infoPanelUpdated || goalUpdated || winModalUpdated
let redraw = g ^. needsRedraw || inventoryUpdated || replUpdated || logUpdated || infoPanelUpdated || goalOrWinUpdated
pure redraw
-- | Either pops up the updated Goals modal
-- or pops up the Congratulations (Win) modal, or pops
-- up the Condolences (Lose) modal.
-- The Win modal will take precendence if the player
-- has met the necessary conditions to win the game.
--
-- If the player chooses to "Keep Playing" from the Win modal, the
-- updated Goals will then immediately appear.
-- This is desirable for:
-- * feedback as to the final goal the player accomplished,
-- * as a summary of all of the goals of the game
-- * shows the player more "optional" goals they can continue to pursue
doGoalUpdates :: EventM Name AppState Bool
doGoalUpdates = do
curGoal <- use (uiState . uiGoal . goalsContent)
isCheating <- use (uiState . uiCheatMode)
curWinCondition <- use (gameState . winCondition)
announcementsSeq <- use (gameState . announcementQueue)
let announcementsList = toList announcementsSeq
-- Decide whether we need to update the current goal text and pop
-- up a modal dialog.
case curWinCondition of
NoWinCondition -> return False
WinConditions (Unwinnable False) x -> do
-- This clears the "flag" that the Lose dialog needs to pop up
gameState . winCondition .= WinConditions (Unwinnable True) x
openModal LoseModal
uiState . uiMenu %= advanceMenu
return True
WinConditions (Won False) x -> do
-- This clears the "flag" that the Win dialog needs to pop up
gameState . winCondition .= WinConditions (Won True) x
openModal WinModal
uiState . uiMenu %= advanceMenu
return True
WinConditions _ oc -> do
let newGoalTracking = GoalTracking announcementsList $ constructGoalMap isCheating oc
-- The "uiGoal" field is intialized with empty members, so we know that
-- this will be the first time showing it if it will be nonempty after previously
-- being empty.
isFirstGoalDisplay = hasAnythingToShow newGoalTracking && not (hasAnythingToShow curGoal)
goalWasUpdated = isFirstGoalDisplay || not (null announcementsList)
-- Decide whether to show a pop-up modal congratulating the user on
-- successfully completing the current challenge.
when goalWasUpdated $ do
-- The "uiGoal" field is necessary at least to "persist" the data that is needed
-- if the player chooses to later "recall" the goals dialog with CTRL+g.
uiState . uiGoal .= GoalDisplay newGoalTracking (GR.makeListWidget newGoalTracking)
-- This clears the "flag" that indicate that the goals dialog needs to be
-- automatically popped up.
gameState . announcementQueue .= mempty
openModal GoalModal
return goalWasUpdated
-- | Make sure all tiles covering the visible part of the world are
-- loaded.
loadVisibleRegion :: EventM Name AppState ()

View File

@ -10,18 +10,18 @@ data ExpectedEffort
| Easy
| Moderate
| Gruelling
deriving (Eq, Ord, Show, Bounded, Enum)
deriving (Eq, Ord, Show, Bounded, Enum, Generic, FromJSON, ToJSON)
data Quotation = Quotation
{ attribution :: Text
, content :: Text
}
deriving (Show)
deriving (Eq, Show, Generic, FromJSON, ToJSON)
data FlavorText
= Freeform Text
| FTQuotation Quotation
deriving (Show)
deriving (Eq, Show, Generic, FromJSON, ToJSON)
data AchievementInfo = AchievementInfo
{ title :: Text
@ -41,7 +41,7 @@ data AchievementInfo = AchievementInfo
-- ^ Hides the attainment process until after the achievement is attained.
-- Best when the title + elaboration constitute a good clue.
}
deriving (Show)
deriving (Eq, Show, Generic, FromJSON, ToJSON)
data CategorizedAchievement
= GlobalAchievement GlobalAchievement

View File

@ -41,10 +41,11 @@ data ModalType
| MessagesModal
| RobotsModal
| WinModal
| LoseModal
| QuitModal
| KeepPlayingModal
| DescriptionModal Entity
| GoalModal [Text]
| GoalModal
deriving (Show)
data ButtonSelection

View File

@ -30,6 +30,8 @@ data Name
MenuList
| -- | The list of achievements.
AchievementList
| -- | The list of goals/ojbectives.
ObjectivesList
| -- | The list of scenario choices.
ScenarioList
| -- | The scrollable viewport for the info panel.

View File

@ -21,6 +21,7 @@ import Data.Text (Text)
import Data.Time (ZonedTime, getZonedTime)
import Swarm.Game.Log (ErrorLevel (..), LogSource (ErrorTrace))
import Swarm.Game.Scenario (loadScenario)
import Swarm.Game.Scenario.Objective.Presentation.Model (emptyGoalDisplay)
import Swarm.Game.ScenarioInfo (
ScenarioInfo (..),
ScenarioInfoPair,
@ -130,7 +131,7 @@ scenarioToUIState siPair u = do
return $
u
& uiPlaying .~ True
& uiGoal .~ Nothing
& uiGoal .~ emptyGoalDisplay
& uiFocusRing .~ initFocusRing
& uiInventory .~ Nothing
& uiInventorySort .~ defaultSortOptions

View File

@ -6,6 +6,7 @@
module Swarm.TUI.Model.UI (
UIState (..),
GoalDisplay (..),
uiMenu,
uiPlaying,
uiCheatMode,
@ -54,6 +55,7 @@ import Data.Map (Map)
import Data.Map qualified as M
import Data.Text (Text)
import Data.Text qualified as T
import Swarm.Game.Scenario.Objective.Presentation.Model
import Swarm.Game.ScenarioInfo (
ScenarioInfoPair,
)
@ -89,7 +91,7 @@ data UIState = UIState
, _uiScrollToEnd :: Bool
, _uiError :: Maybe Text
, _uiModal :: Maybe Modal
, _uiGoal :: Maybe [Text]
, _uiGoal :: GoalDisplay
, _uiAchievements :: Map CategorizedAchievement Attainment
, _uiShowFPS :: Bool
, _uiShowZero :: Bool
@ -169,7 +171,7 @@ uiModal :: Lens' UIState (Maybe Modal)
-- | Status of the scenario goal: whether there is one, and whether it
-- has been displayed to the user initially.
uiGoal :: Lens' UIState (Maybe [Text])
uiGoal :: Lens' UIState GoalDisplay
-- | Map of achievements that were attained
uiAchievements :: Lens' UIState (Map CategorizedAchievement Attainment)
@ -281,7 +283,7 @@ initUIState showMainMenu cheatMode = do
, _uiScrollToEnd = False
, _uiError = Nothing
, _uiModal = Nothing
, _uiGoal = Nothing
, _uiGoal = emptyGoalDisplay
, _uiAchievements = M.fromList $ map (view achievement &&& id) achievements
, _uiShowFPS = False
, _uiShowZero = True

View File

@ -78,6 +78,8 @@ import Swarm.Game.Entity as E
import Swarm.Game.Recipe
import Swarm.Game.Robot
import Swarm.Game.Scenario (scenarioAuthor, scenarioDescription, scenarioName, scenarioObjectives)
import Swarm.Game.Scenario.Objective.Presentation.Model (goalsContent, hasAnythingToShow)
import Swarm.Game.Scenario.Objective.Presentation.Render qualified as GR
import Swarm.Game.ScenarioInfo (
ScenarioItem (..),
ScenarioStatus (..),
@ -468,9 +470,17 @@ drawModal s = \case
CommandsModal -> commandsListWidget (s ^. gameState)
MessagesModal -> availableListWidget (s ^. gameState) MessageList
WinModal -> padBottom (Pad 1) $ hCenter $ txt "Congratulations!"
LoseModal ->
padBottom (Pad 1) $
vBox $
map
(hCenter . txt)
[ "Condolences!"
, "This scenario is no longer winnable."
]
DescriptionModal e -> descriptionWidget s e
QuitModal -> padBottom (Pad 1) $ hCenter $ txt (quitMsg (s ^. uiState . uiMenu))
GoalModal g -> padLeftRight 1 (displayParagraphs g)
GoalModal -> padLeftRight 1 $ GR.renderGoalsDisplay (s ^. uiState . uiGoal)
KeepPlayingModal -> padLeftRight 1 (displayParagraphs ["Have fun! Hit Ctrl-Q whenever you're ready to proceed to the next challenge or return to the menu."])
robotsListWidget :: AppState -> Widget Name
@ -759,9 +769,7 @@ drawKeyMenu s =
viewingBase = (s ^. gameState . viewCenterRule) == VCRobot 0
creative = s ^. gameState . creativeMode
cheat = s ^. uiState . uiCheatMode
goal = case s ^. uiState . uiGoal of
Just g | g /= [] -> True
_ -> False
goal = hasAnythingToShow $ s ^. uiState . uiGoal . goalsContent
showZero = s ^. uiState . uiShowZero
inventorySort = s ^. uiState . uiInventorySort
ctrlMode = s ^. uiState . uiREPL . replControlMode
@ -1155,12 +1163,3 @@ drawREPL s = vBox $ latestHistory <> [currentPrompt]
base = s ^. gameState . robotMap . at 0
fmt (REPLEntry e) = txt $ "> " <> e
fmt (REPLOutput t) = txt t
------------------------------------------------------------
-- Utility
------------------------------------------------------------
-- | Display a list of text-wrapped paragraphs with one blank line after
-- each.
displayParagraphs :: [Text] -> Widget Name
displayParagraphs = vBox . map (padBottom (Pad 1) . txtWrap)

View File

@ -55,6 +55,21 @@ generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth
)
, sum (map length [nextMsg, stopMsg, continueMsg]) + 32
)
LoseModal ->
let stopMsg = fromMaybe "Return to the menu" haltingMessage
continueMsg = "Keep playing"
maybeStartOver = sequenceA ("Start over", StartOverButton currentSeed <$> currentScenario)
in ( ""
, Just
( 0
, catMaybes
[ Just (stopMsg, QuitButton)
, maybeStartOver
, Just (continueMsg, KeepPlayingButton)
]
)
, sum (map length [stopMsg, continueMsg]) + 32
)
DescriptionModal e -> (descriptionTitle e, Nothing, descriptionWidth)
QuitModal ->
let stopMsg = fromMaybe ("Quit to" ++ maybe "" (" " ++) (into @String <$> curMenuName s) ++ " menu") haltingMessage
@ -70,11 +85,11 @@ generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth
)
, T.length (quitMsg (s ^. uiState . uiMenu)) + 4
)
GoalModal _ ->
GoalModal ->
let goalModalTitle = case currentScenario of
Nothing -> "Goal"
Just (scenario, _) -> scenario ^. scenarioName
in (" " <> T.unpack goalModalTitle <> " ", Nothing, 80)
in (" " <> T.unpack goalModalTitle <> " ", Nothing, descriptionWidth)
KeepPlayingModal -> ("", Just (0, [("OK", CancelButton)]), 80)
-- | Render the type of the current REPL input to be shown to the user.
@ -102,3 +117,21 @@ quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this
quitAction = case m of
NoMenu -> "quit"
_ -> "return to the menu"
-- | Display a list of text-wrapped paragraphs with one blank line after
-- each.
displayParagraphs :: [Text] -> Widget Name
displayParagraphs = vBox . map (padBottom (Pad 1) . txtWrap)
withEllipsis :: Text -> Widget Name
withEllipsis t =
Widget Greedy Fixed $ do
ctx <- getContext
let w = ctx ^. availWidthL
ellipsis = T.replicate 3 $ T.singleton '.'
tLength = T.length t
newText =
if tLength > w
then T.take (w - T.length ellipsis) t <> ellipsis
else t
render $ txt newText

View File

@ -34,6 +34,10 @@ import Network.Wai qualified
import Network.Wai.Handler.Warp qualified as Warp
import Servant
import Swarm.Game.Robot
import Swarm.Game.Scenario.Objective
import Swarm.Game.Scenario.Objective.Graph
import Swarm.Game.Scenario.Objective.Presentation.Model
import Swarm.Game.Scenario.Objective.WinCheck
import Swarm.Game.State
import Swarm.TUI.Model
import Swarm.TUI.Model.UI
@ -42,12 +46,22 @@ import System.Timeout (timeout)
type SwarmApi =
"robots" :> Get '[JSON] [Robot]
:<|> "robot" :> Capture "id" Int :> Get '[JSON] (Maybe Robot)
:<|> "goals" :> "prereqs" :> Get '[JSON] [PrereqSatisfaction]
:<|> "goals" :> "active" :> Get '[JSON] [Objective]
:<|> "goals" :> "graph" :> Get '[JSON] (Maybe GraphInfo)
:<|> "goals" :> "uigoal" :> Get '[JSON] GoalTracking
:<|> "goals" :> Get '[JSON] WinCondition
:<|> "repl" :> "history" :> "full" :> Get '[JSON] [T.Text]
mkApp :: IORef AppState -> Servant.Server SwarmApi
mkApp appStateRef =
robotsHandler
:<|> robotHandler
:<|> prereqsHandler
:<|> activeGoalsHandler
:<|> goalsGraphHandler
:<|> uiGoalHandler
:<|> goalsHandler
:<|> replHandler
where
robotsHandler = do
@ -56,6 +70,27 @@ mkApp appStateRef =
robotHandler rid = do
appState <- liftIO (readIORef appStateRef)
pure $ IM.lookup rid (appState ^. gameState . robotMap)
prereqsHandler = do
appState <- liftIO (readIORef appStateRef)
case appState ^. gameState . winCondition of
WinConditions _winState oc -> return $ getSatisfaction oc
_ -> return []
activeGoalsHandler = do
appState <- liftIO (readIORef appStateRef)
case appState ^. gameState . winCondition of
WinConditions _winState oc -> return $ getActiveObjectives oc
_ -> return []
goalsGraphHandler = do
appState <- liftIO (readIORef appStateRef)
return $ case appState ^. gameState . winCondition of
WinConditions _winState oc -> Just $ makeGraphInfo oc
_ -> Nothing
uiGoalHandler = do
appState <- liftIO (readIORef appStateRef)
return $ appState ^. uiState . uiGoal . goalsContent
goalsHandler = do
appState <- liftIO (readIORef appStateRef)
return $ appState ^. gameState . winCondition
replHandler = do
appState <- liftIO (readIORef appStateRef)
let replHistorySeq = appState ^. uiState . uiREPL . replHistory . replSeq

View File

@ -81,7 +81,9 @@ common ghc2021-extensions
library
import: stan-config, common, ghc2021-extensions
exposed-modules: Swarm.App
exposed-modules: Data.BoolExpr
Data.BoolExpr.Simplify
Swarm.App
Swarm.DocGen
Swarm.Game.CESK
Swarm.Game.Display
@ -92,6 +94,12 @@ library
Swarm.Game.Robot
Swarm.Game.Scenario
Swarm.Game.Scenario.Cell
Swarm.Game.Scenario.Objective.Logic
Swarm.Game.Scenario.Objective.Graph
Swarm.Game.Scenario.Objective.Presentation.Model
Swarm.Game.Scenario.Objective.Presentation.Render
Swarm.Game.Scenario.Objective.Validation
Swarm.Game.Scenario.Objective.WinCheck
Swarm.Game.Scenario.Objective
Swarm.Game.Scenario.RobotLookup
Swarm.Game.Scenario.WorldDescription
@ -232,6 +240,7 @@ test-suite swarm-unit
TestNotification
TestLanguagePipeline
TestPretty
TestBoolExpr
TestLSP
TestUtil

View File

@ -6,13 +6,14 @@
module Main where
import Control.Lens (Ixed (ix), to, use, view, (&), (.~), (<&>), (<>~), (^.), (^..), (^?!))
import Control.Monad (filterM, forM_, unless, void, when)
import Control.Monad (filterM, forM_, unless, when)
import Control.Monad.State (StateT (runStateT), gets)
import Control.Monad.Trans.Except (runExceptT)
import Data.Char (isSpace)
import Data.Containers.ListUtils (nubOrd)
import Data.Foldable (Foldable (toList), find)
import Data.IntSet qualified as IS
import Data.List (partition)
import Data.Map qualified as M
import Data.Maybe (isJust)
import Data.Sequence (Seq)
@ -28,7 +29,8 @@ import Swarm.Game.Robot (LogEntry, defReqs, leText, machine, robotContext, robot
import Swarm.Game.Scenario (Scenario)
import Swarm.Game.State (
GameState,
WinCondition (Won),
WinCondition (WinConditions),
WinStatus (Won),
activeRobots,
baseRobot,
initGameStateForScenario,
@ -45,16 +47,21 @@ import Swarm.Language.Pipeline (ProcessedTerm (..), processTerm)
import Swarm.Util.Yaml (decodeFileEitherE)
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.Environment (getEnvironment)
import System.FilePath (splitDirectories)
import System.FilePath.Posix (takeExtension, (</>))
import System.Timeout (timeout)
import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase)
import Witch (into)
isUnparseableTest :: (FilePath, String) -> Bool
isUnparseableTest (fp, _) = "_Validation" `elem` splitDirectories fp
main :: IO ()
main = do
examplePaths <- acquire "example" "sw"
scenarioPaths <- acquire "data/scenarios" "yaml"
let (unparseableScenarios, parseableScenarios) = partition isUnparseableTest scenarioPaths
scenarioPrograms <- acquire "data/scenarios" "sw"
ci <- any (("CI" ==) . fst) <$> getEnvironment
entities <- loadEntities
@ -66,7 +73,8 @@ main = do
"Tests"
[ exampleTests examplePaths
, exampleTests scenarioPrograms
, scenarioTests em scenarioPaths
, scenarioParseTests em parseableScenarios
, scenarioParseInvalidTests em unparseableScenarios
, testScenarioSolution ci em
, testEditorFiles
]
@ -81,19 +89,34 @@ exampleTest (path, fileContent) =
where
value = processTerm $ into @Text fileContent
scenarioTests :: EntityMap -> [(FilePath, String)] -> TestTree
scenarioTests em inputs = testGroup "Test scenarios" (map (scenarioTest em) inputs)
scenarioParseTests :: EntityMap -> [(FilePath, String)] -> TestTree
scenarioParseTests em inputs =
testGroup
"Test scenarios parse"
(map (scenarioTest Parsed em) inputs)
scenarioTest :: EntityMap -> (FilePath, String) -> TestTree
scenarioTest em (path, _) =
testCase ("parse scenario " ++ show path) (void $ getScenario em path)
scenarioParseInvalidTests :: EntityMap -> [(FilePath, String)] -> TestTree
scenarioParseInvalidTests em inputs =
testGroup
"Test invalid scenarios fail to parse"
(map (scenarioTest Failed em) inputs)
getScenario :: EntityMap -> FilePath -> IO Scenario
getScenario em p = do
data ParseResult = Parsed | Failed
scenarioTest :: ParseResult -> EntityMap -> (FilePath, String) -> TestTree
scenarioTest expRes em (path, _) =
testCase ("parse scenario " ++ show path) (getScenario expRes em path)
getScenario :: ParseResult -> EntityMap -> FilePath -> IO ()
getScenario expRes em p = do
res <- decodeFileEitherE em p :: IO (Either ParseException Scenario)
case res of
Left err -> assertFailure (prettyPrintParseException err)
Right s -> return s
case expRes of
Parsed -> case res of
Left err -> assertFailure (prettyPrintParseException err)
Right _s -> return ()
Failed -> case res of
Left _err -> return ()
Right _s -> assertFailure "Unexpectedly parsed invalid scenario!"
acquire :: FilePath -> String -> IO [(FilePath, String)]
acquire dir ext = do
@ -272,7 +295,7 @@ testScenarioSolution _ci _em =
w <- use winCondition
b <- gets badErrorsInLogs
when (null b) $ case w of
Won _ -> return ()
WinConditions (Won _) _ -> return ()
_ -> gameTick >> playUntilWin
noBadErrors :: GameState -> Assertion

View File

@ -20,6 +20,7 @@ import Test.Tasty.QuickCheck (
testProperty,
(==>),
)
import TestBoolExpr (testBoolExpr)
import TestEval (testEval)
import TestInventory (testInventory)
import TestLSP (testLSP)
@ -42,6 +43,7 @@ tests g =
"Tests"
[ testLanguagePipeline
, testPrettyConst
, testBoolExpr
, testEval g
, testModel
, testInventory

103
test/unit/TestBoolExpr.hs Normal file
View File

@ -0,0 +1,103 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Boolean expression unit tests
module TestBoolExpr where
import Data.BoolExpr qualified as BE
import Data.BoolExpr.Simplify qualified as Simplify
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Set qualified as Set
import Swarm.Game.Scenario.Objective.Logic
import Swarm.Game.Scenario.Objective.WinCheck qualified as WC
import Test.Tasty
import Test.Tasty.HUnit
testBoolExpr :: TestTree
testBoolExpr =
testGroup
"Boolean evaluation"
[ testGroup
"Expression simplification"
[ testGroup
"Return true if the expression can be simplified to False"
[ testGroup
"Effect of constant literals"
[ testCase
"False input via single literal"
$ expectTrue BE.BFalse
, testCase
"True input via composed literals"
$ expectFalse BE.BTrue
, testCase
"False input via composed literals"
$ expectTrue
$ BE.BOr BE.BFalse BE.BFalse
, testCase
"True input via composed literals"
$ expectFalse
$ BE.BOr BE.BFalse BE.BTrue
, testCase
"Constant OR'd with False"
$ expectFalse
$ BE.BOr BE.BFalse (BE.BConst (BE.Positive "foo"))
, testCase
"Constant OR'd with True"
$ expectFalse
$ BE.BOr (BE.BConst (BE.Positive "foo")) BE.BTrue
, testCase
"Constant AND'd with False"
$ expectTrue
$ BE.BAnd BE.BFalse (BE.BConst (BE.Positive "foo"))
, testCase
"Constant AND'd with True"
$ expectFalse
$ BE.BAnd (BE.BConst (BE.Positive "foo")) BE.BTrue
, testCase
"Nested Constants AND'd with False within OR"
$ expectTrue
$ BE.BOr
(BE.BAnd BE.BFalse (BE.BConst (BE.Positive "foo")))
(BE.BAnd (BE.BConst (BE.Positive "bar")) BE.BFalse)
, testCase
"Deeply nested Constants AND'd with False within OR with multiple negations"
$ expectTrue
$ BE.BOr
(BE.BAnd (BE.BNot BE.BTrue) (BE.BNot (BE.BNot (BE.BNot (BE.BConst (BE.Positive "foo"))))))
(BE.BAnd (BE.BConst (BE.Positive "bar")) (BE.BNot (BE.BNot BE.BFalse)))
]
, testGroup
"Effect of contradicting named constants"
[ testCase
"via NOT operator"
$ expectTrue
$ BE.BAnd (BE.BNot (BE.BConst (BE.Positive "foo"))) (BE.BConst (BE.Positive "foo"))
, testCase
"via signedness"
$ expectTrue
$ BE.BAnd (BE.BConst (BE.Positive "foo")) (BE.BConst (BE.Negative "foo"))
]
]
]
, testGroup
"Prerequisite expressions"
[ testCase
"A negated goal is completed"
$ assertBool "Should have returned true"
$ WC.isUnwinnablePrereq (Set.singleton "b") demoPrereqs
, testCase
"A non-negated goal is completed"
$ assertBool "Should have returned false"
$ not
$ WC.isUnwinnablePrereq (Set.singleton "c") demoPrereqs
]
]
where
expectTrue, expectFalse :: BE.BoolExpr String -> Assertion
expectTrue = assertBool "Should have returned true" . Simplify.cannotBeTrue
expectFalse = assertBool "Should have returned false" . not . Simplify.cannotBeTrue
demoPrereqs :: Prerequisite ObjectiveLabel
demoPrereqs =
And $
Id "a"
:| [Not (Id "b"), Id "c"]