wyvern-0.1: Main.hs
-- boilerplate {{{
{-# LANGUAGE GeneralizedNewtypeDeriving, FlexibleContexts, FlexibleInstances, MultiParamTypeClasses #-}
import Control.Arrow
import Control.Monad.Error
import Control.Monad.Identity
import Control.Monad.State
import Control.Monad.Writer
import Data.Char
import Data.ConfigFile (CPError(..), ConfigParser(..))
import Data.Function
import Data.List
import Data.List.Split
import Data.Map (Map(..))
import Data.Maybe
import Data.SGF hiding (Point)
import Network.DGS (DGS, LoginResult(..), MoveResult(..), Point)
import Prelude hiding (log)
import System.Cmd
import System.Console.GetOpt
import System.Directory
import System.Environment (getArgs, getProgName)
import System.Exit (exitWith, ExitCode(..))
import System.FilePath ((</>))
import System.IO
import Text.ParserCombinators.Parsec (parse)
import Text.Parsec.Prim
import qualified Data.ByteString as BS
import qualified Data.ConfigFile as Config
import qualified Data.Map as Map
import qualified Network.DGS as DGS
transCatch :: MonadIO m => IO a -> (IOError -> m a) -> m a
transCatch io m = join . liftIO $ catch (liftM return io) (return . m)
-- }}}
-- Mode {{{
showMode :: Mode -> String
readMode :: String -> Mode
data Mode = Batch | Interactive deriving (Eq, Ord, Show, Read, Enum, Bounded)
showMode = map toLower . show
readMode s = case map toLower s of
('b':_) -> Batch
_ -> Interactive
-- }}}
-- Configuration {{{
data Configuration = Configuration {
username :: Maybe String,
password :: Maybe String,
mode :: Mode,
editor :: String,
server :: String,
dataDir :: String,
logLevel :: Maybe LogLevel
} deriving (Eq, Ord, Show, Read)
defaultConfig :: IO Configuration
defaultConfig = do
dir <- getAppUserDataDirectory "wyvern"
return Configuration {
username = Nothing,
password = Nothing,
mode = Interactive,
editor = "cgoban3 #f",
server = DGS.production,
dataDir = dir,
logLevel = Nothing
}
-- }}}
-- Wyvern type {{{
newtype Wyvern a = Wyvern { unWyvern :: StateT Configuration DGS a } deriving (
Functor,
Monad,
MonadIO,
MonadState Configuration
)
runWyvern :: Wyvern a -> Configuration -> IO a
runWyvern (Wyvern m) = DGS.browseDGS . fmap fst . runStateT m
dgs :: DGS a -> Wyvern a
dgs = Wyvern . lift
-- }}}
-- logging {{{
data LogLevel = Quiet | Noisy | Raucous deriving (Eq, Ord, Show, Read, Enum, Bounded)
incrementLogLevel :: Configuration -> Configuration
incrementLogLevel conf = conf { logLevel = inc (logLevel conf) } where
levels = Nothing : map Just [Quiet, Noisy, Raucous, Raucous]
inc l = dropWhile (/= l) levels !! 1
log :: LogLevel -> String -> Wyvern ()
log l s = do
level <- gets logLevel
when (Just l <= level) (liftIO $ putStrLn s)
whisper, say, shout :: String -> Wyvern ()
[whisper, say, shout] = map log [Quiet, Noisy, Raucous]
warn :: MonadIO m => String -> m ()
warn = liftIO . hPutStrLn stderr . ("Warning: " ++)
die :: MonadIO m => Int -> String -> m a
die n extraInfo = liftIO (speech >> death) where
speech = hPutStrLn stderr message
death = exitWith (ExitFailure n)
defaultError = "Unknown error (#" ++ show n ++ "), please report a bug with this number."
message = "Error: " ++ fromMaybe defaultError (lookup n problems) ++ separator ++ extraInfo
separator = case extraInfo of
"" -> ""
_ -> "\n"
problems :: [(Int, String)]
problems = zip [1..] [
"Bad command-line parameters",
"Extraneous command-line parameters",
"The impossible happened during configuration; please\nreport the following information in a bug report.",
"Parsing configuration file failed",
"Wrong username",
"Wrong password",
"Unknown problem during login attempt",
"The server issued a totally unexpected error in response to\nan attempt to make a move. Please report this in a bug,\nalong with the following error code.",
"The impossible happened; splitOn returned an empty list!\nPlease report this as a bug, along with your editor setting:"
]
succeedString :: String -> Wyvern a
succeedString message = liftIO (speech >> death) where
speech = hPutStrLn stderr message
death = exitWith ExitSuccess
-- }}}
-- getters/setters/misc {{{
getLogLevel :: Wyvern (Maybe LogLevel)
getUsername, getPassword, getEditor, getServer, getDataDir :: Wyvern String
setUsername, setPassword, setEditor, setServer, setDataDir :: String -> Wyvern ()
clearUsername, clearPassword, clearLogLevel :: Wyvern ()
getLogLevel = do
l <- gets logLevel
shout $ "Retrieved log level <" ++ maybe "Nothing" show l ++ ">"
return l
getMode = do
m <- gets mode
shout $ "Retrieved mode <" ++ showMode m ++ ">"
return m
[getUsername, getPassword] = map getX [
("Username", username, setUsername, True),
("Password", password, setPassword, False)
] where
getX (x, selector, setter, echo)
= gets selector
>>= maybe (promptX x setter echo) (shoutX x)
shoutX x val = do
shout $ "Retrieved " ++ map toLower x ++ " <" ++ val ++ ">"
return val
promptX x setter echo = do
val <- liftIO $ do
hSetEcho stdin echo
putStr $ x ++ ": "
hFlush stdout
getLine
liftIO $ hSetEcho stdin True >> when (not echo) (putStrLn "")
setter val
return val
[getEditor, getServer, getDataDir] = zipWith getX
["SGF editor", "server", "configuration directory"]
[editor, server, dataDir] where
getX x selector = do
v <- gets selector
shout $ "Retrieved " ++ x ++ " <" ++ v ++ ">"
return v
[setUsername, setPassword, setEditor, setServer, setDataDir] = zipWith setX
["username", "password", "SGF editor", "server", "configuration directory"]
[\s conf -> conf { username = Just s },
\s conf -> conf { password = Just s },
\s conf -> conf { editor = s },
\s conf -> conf { server = s },
\s conf -> conf { dataDir = s }
] where
setX x set s = do
shout $ concat ["Setting ", x, " to <", s, ">"]
modify (set s)
[clearUsername, clearPassword, clearLogLevel] = zipWith clearX
["username", "password", "verbosity"]
[\conf -> conf { username = Nothing },
\conf -> conf { password = Nothing },
\conf -> conf { logLevel = Nothing }
] where
clearX x clear = do
shout $ "Resetting " ++ x
modify clear
setMode :: Mode -> Wyvern ()
setMode m = do
shout $ "Setting mode to <" ++ showMode m ++ ">"
modify (\conf -> conf { mode = m })
modifyConfig :: (Configuration -> Configuration) -> Wyvern ()
modifyConfig f = do
new <- gets f
put new
shout $ "Setting configuration to " ++ show new
rm :: FilePath -> Wyvern ()
rm file = do
dataDir <- getDataDir
whisper $ "Deleting <" ++ file ++ ">"
liftIO . removeFile $ dataDir </> file
-- }}}
-- DGS interaction {{{
status :: Wyvern [DGS.Game]
status = do
server <- getServer
username <- getUsername
whisper $ "Retrieving games list for <" ++ username ++ "> from <" ++ server ++ ">"
dgs $ DGS.statusUser server username
login :: Wyvern (String, String)
login = do
server <- getServer
username <- getUsername
password <- getPassword
status <- dgs (DGS.login server username password)
case status of
WrongUsername -> die 5 $ "Server <" ++ server ++ ">, username <" ++ username ++ ">"
WrongPassword -> die 6 $ ""
LoginProblem s -> die 7 $ "The server says <" ++ s ++ ">"
LoginSuccess -> shout "Successfully logged in" >> return (server, username)
-- }}}
-- options {{{
options :: String -> String -> String -> String -> [OptDescr (Maybe (Configuration -> Configuration))]
options m e s c = [
Option "u" ["user", "username" ] (arg (\s conf -> conf { username = Just s }) "username") "Username",
Option "p" ["pass", "password" ] (arg (\s conf -> conf { password = Just s }) "password") "Password",
Option "m" ["mode" ] (arg (\s conf -> conf { mode = readMode s }) "mode" ) (def m "Play mode"),
Option "e" ["editor", "launcher" ] (arg (\s conf -> conf { editor = s }) "editor") (def e "SGF editor"),
Option "s" ["server", "url" ] (arg (\s conf -> conf { server = s }) "server") (def s "URL of the go server"),
Option "c" ["configuration", "dir"] (arg (\s conf -> conf { dataDir = s }) "dir" ) (def c "Configuration and SGF directory"),
Option "h?" ["help" ] (NoArg Nothing ) "Show usage information",
Option "v" ["verbose"] (NoArg (Just incrementLogLevel)) "Increase verbosity"
] where
def v n = concat [n, " (default ", v, ")"]
arg = ReqArg . (Just .)
usage :: String -> String -> String -> String -> String -> Wyvern String
usage extra m e s c = do
name <- liftIO getProgName
return $ usageInfo (extra ++ "Usage: " ++ name ++ " [options]") (options m e s c) ++ unlines [""
, "Valid modes are \"" ++ intercalate "\" and \"" (map showMode [minBound..maxBound]) ++ "."
, "The first \"#f\" in the editor line will be changed to the name of an SGF"
, " file. If there is no \"#f\", one will be appended for you."
, "Verbosity can be increased up to three times. The third verbosity level"
, " may print your password."
]
readArgs :: Wyvern ()
readArgs = do
say "Parsing command-line arguments"
args <- liftIO getArgs
shout $ "Got args " ++ show args
m <- liftM showMode getMode
e <- getEditor
s <- getServer
c <- getDataDir
case getOpt Permute (options m e s c) args of
(_, _, errors@(_:_)) -> usage (concat errors) m e s c >>= die 1
(_, nonopts@(_:_), _) -> usage (unwords nonopts ++ "\n") m e s c >>= die 2
(modifiers, _, _) -> case sequence modifiers of
Nothing -> usage "" m e s c >>= succeedString
Just ms -> do
shout "Successfully read arguments"
clearLogLevel -- we readArgs twice, so gotta reset this each time
modifyConfig (foldr (.) id ms)
-- }}}
-- config file {{{
rcName :: FilePath -> FilePath
rcName = (</> "config")
yesConfig :: FilePath -> IO (Either CPError ConfigParser)
yesConfig = Config.readfile Config.emptyCP . rcName
createConfigFile :: FilePath -> ConfigParser -> IO ()
createConfigFile dataDir cp = do
hSetBuffering stdin NoBuffering
putStr "Create the configuration file now? [Yn] "
hFlush stdout
response <- getChar
when (response /= '\n') (putChar '\n')
when (response `elem` "Yy\n")
(createDirectoryIfMissing True dataDir >>
writeFile (rcName dataDir) (Config.to_string cp))
noConfig :: Wyvern (Either CPError ConfigParser)
noConfig = do
dataDir <- getDataDir
warn $ "File <" ++ rcName dataDir ++ "> does not exist."
-- get the important bits of the configuration
username <- getUsername
password <- getPassword
mode <- liftM showMode getMode
editor <- getEditor
server <- getServer
-- create the config
whisper "Creating an in-memory copy of this configuration"
let set' opt val cp = Config.set cp "DEFAULT" opt val
ecp = return Config.emptyCP
>>= set' "username" username
>>= set' "password" password
>>= set' "mode" mode
>>= set' "editor" editor
>>= set' "server" server
either (die 3 . show) (liftIO . createConfigFile dataDir) ecp
return ecp
processConfig :: Either CPError ConfigParser -> Wyvern ()
processConfig (Left (err, location)) = die 4 $ "In " ++ location ++ ":\n" ++ show err
processConfig (Right cp) = do
whisper "Updating configuration"
mapM_ (ignoreWarn "section") sections
mapM_ (ignoreWarn "option" ) (options \\ map fst actions)
mapM_ proc actions
where
ignoreWarn s s' = warn $ "Ignoring unknown " ++ s ++ " <" ++ s' ++ ">"
actions = [("username", setUsername), ("password", setPassword), ("mode", setMode . readMode), ("editor", setEditor), ("server", setServer)]
sections = Config.sections cp
Right options = Config.options cp "DEFAULT"
proc (opt, f) = case Config.get cp "DEFAULT" opt of
Right val -> (if opt == "password" then shout else say) ("Read " ++ opt ++ " as <" ++ val ++ ">")
>> f val
Left _ -> return ()
readConfigFile :: Wyvern ()
readConfigFile = do
dataDir <- getDataDir
whisper $ "Attempting to read configuration <" ++ rcName dataDir ++ ">"
ecp <- transCatch (yesConfig dataDir) (const noConfig)
processConfig ecp
-- }}}
-- ByteString wrapper for Parsec {{{
newtype Word8String = Word8String { unString :: BS.ByteString }
instance Monad m => Stream Word8String m Word8 where
uncons = return . fmap (second Word8String) . BS.uncons . unString
-- }}}
-- SGF file management {{{
-- sample file name: dmwit-violist-520005-20091209.sgf
-- (however, note that uids are allowed to have '-' in them, which
-- complicates things somewhat)
gameIDOf :: FilePath -> [String]
gameIDOf = map reverse . take 1 . drop 1 . sepBy "-" . reverse
hasUsername :: String -> FilePath -> Bool
hasUsername username filename = first || second where
udash = username ++ "-"
dashudash = "-" ++ udash
first = udash `isPrefixOf` filename
second = dashudash `isInfixOf` filename
validGameID :: FilePath -> Bool
validGameID = foldr (/=) False . map (all isDigit) . gameIDOf
isSGF :: FilePath -> Bool
isSGF = (".sgf" `isSuffixOf`)
empty :: [a] -> b -> b -> b
empty [] v _ = v
empty _ _ v = v
emptyM :: Monad m => [a] -> m b -> m [c] -> m [c]
emptyM xs m = empty xs (m >> return [])
lsSGF :: Wyvern [(FilePath, Collection)]
lsSGF = do
dataDir <- getDataDir
whisper $ "Scanning <" ++ dataDir ++ "> for SGF files"
files' <- liftIO $ catch (getDirectoryContents dataDir)
(\_ -> return [])
mapM_ shout $ "Found files:" : files'
let files = filter (\f -> isSGF f && validGameID f) files'
mapM_ say $ "Found well-tagged SGFs:" : files
emptyM files (say "No files found, no need to ask for username") $ do
username <- getUsername
let result = filter (hasUsername username) files
mapM_ whisper $ "Processing these files:" : result
parseSGFs result
parseResult :: (Stream stream Identity Word8) => String -> (input -> stream) -> input -> Wyvern (Maybe Collection)
parseResult file cast contents = case parse collection file (cast contents) of
Left err -> do
warn $ "Skipping <" ++ file ++ ">: it does not appear to be a valid SGF file"
whisper $ "The parse error is: " ++ show err
return Nothing
Right (collection, warnings) -> do
empty warnings
(say ("Game <" ++ file ++ "> parsed cleanly"))
(whisper ("Game <" ++ file ++ "> has correctable errors") >> mapM_ (say . show) warnings)
return (Just collection)
parseSGF :: FilePath -> Wyvern (Maybe Collection)
parseSGF file | not (validGameID file) = return Nothing
parseSGF file = do
dataDir <- getDataDir
contents <- liftIO . BS.readFile $ dataDir </> file
parseResult file Word8String contents
parseSGFs :: [FilePath] -> Wyvern [(FilePath, Collection)]
parseSGFs files = liftM (catMaybes . zipWith (fmap . (,)) files) (mapM parseSGF files)
colorFor :: Map Integer Bool -> FilePath -> Collection -> Maybe (FilePath, Bool, Collection)
colorFor m file coll = do
gid <- listToMaybe (gameIDOf file)
color <- Map.lookup (read gid) m
return (file, color, coll)
-- only keep games that need a move
filterSGFBatch :: [(FilePath, Collection)] -> Wyvern [(FilePath, Bool, Collection)]
filterSGFBatch collections = emptyM collections (shout "No valid SGF files") $ do
games <- status
let gids = Map.fromList [(gid, color) | (gid, _, color, _, _) <- games]
shout $ "Games " ++ show (Map.keys gids) ++ " awaiting a move"
return . catMaybes . map (uncurry (colorFor gids)) $ collections
handleBatch :: FilePath -> Integer -> String -> String -> Bool -> Collection -> Collection -> Wyvern ()
handleBatch file gid server username color disk network = case collectionDiff disk network of
Wait s -> say $ "Skipping <" ++ file ++ ">: " ++ s
Delete s -> rm file >> say s
Do p n -> do
whisper $ "Making move " ++ show n ++ " in game " ++ show gid
status <- dgs (DGS.move server gid color p n)
case status of
NotYourTurn -> warnSkip "did you play a move when I wasn't looking?"
MoveAlreadyPlayed -> warnSkip "did you play a move when I wasn't looking?"
IllegalPosition -> warnSkip "the server claims this causes a ko or some other illegal position."
MoveSuccess -> shout "...success"
_ -> die 8 $ show status
where warnSkip s = warn $ "Skipping game <" ++ show gid ++ ">; " ++ s
handleAllBatch :: [(FilePath, Bool, Collection)] -> Wyvern ()
handleAllBatch cs = empty cs (whisper "Not logging in") $ do
(server, username) <- login
forM_ cs $ \(file, color, disk) ->
let gid = head . map read . gameIDOf $ file
networkFileName = "network (" ++ show gid ++ ")"
in dgs (DGS.sgf server gid True)
>>= parseResult networkFileName (map (toEnum . fromEnum))
>>= maybe (return ()) (handleBatch file gid server username color disk)
filterSGFInteractive :: [(FilePath, Collection)] -> Wyvern [Integer]
filterSGFInteractive collections = do
gidsWaitingMove <- liftM (map (\(gid, _, _, _, _) -> gid)) status
let gidsWithMove = [read gid | (file, _) <- collections, gid <- gameIDOf file]
return (gidsWaitingMove \\ gidsWithMove)
handleInteractive :: FilePath -> String -> String -> String -> Integer -> Wyvern ()
handleInteractive dataDir editor server username gid = do
shout $ "Fetching game <" ++ show gid ++ ">"
sgfContents <- dgs (DGS.sgf server gid True)
say $ "Writing <" ++ tempFileName ++ ">"
shout $ "File contents:\n" ++ sgfContents ++ "EOF\n"
liftIO $ writeFile fullName sgfContents
shout $ "Wrote file, running editor"
success <- command
case success of
ExitSuccess -> shout $ "Successfully ran editor on <" ++ tempFileName ++">"
ExitFailure n -> warn $ "Editor exited with unsuccessful exit code <" ++ show n ++ "> when run on <" ++ tempFileName ++ ">"
where
tempFileName = intercalate "-" [username, "opponent", show gid, "0.sgf"]
fullName = dataDir </> tempFileName
wrappedName = "\"" ++ fullName ++ "\""
command = case splitOn "#f" editor of
[] -> die 9 editor
[e] -> liftIO . system . unwords $ [e, wrappedName]
es -> liftIO . system . unwords $ intersperse wrappedName es
handleAllInteractive :: [Integer] -> Wyvern ()
handleAllInteractive gids = empty gids (whisper "No games awaiting moves, not logging in") $ do
dataDir <- getDataDir
editor <- getEditor
(server, username) <- login
mapM_ (handleInteractive dataDir editor server username) gids
execute :: Mode -> Wyvern ()
execute Batch = lsSGF >>= filterSGFBatch >>= handleAllBatch
execute Interactive = lsSGF >>= filterSGFInteractive >>= handleAllInteractive
-- }}}
-- decision {{{
data Decision = Do Point Point | Wait String | Delete String
collectionDiff :: Collection -> Collection -> Decision
collectionDiff [disk] [network] = gameDiff disk network
collectionDiff _ _ = Wait "More than one game in the collection"
gameDiff :: Game -> Game -> Decision
gameDiff disk network
| on (/=) size disk network = Delete "File board size does not match DGS board size"
| otherwise = on treeDiff tree disk network
treeDiff :: GameTree -> GameTree -> Decision
treeDiff (TreeGo disk) (TreeGo network) = treeGoDiff disk network
treeDiff _ _ = Wait "Game tree for game type other than go"
treeGoDiff :: TreeGo -> TreeGo -> Decision
treeGoDiff disk network = on (forestDiff (rootLabel disk)) subForest disk network
forestDiff :: NodeGo -> [TreeGo] -> [TreeGo] -> Decision
forestDiff prev disk [ ] = decisionForNodes prev disk
forestDiff prev disk [network] =
case filter (on (==) rootLabel network) disk of
[disk] -> treeGoDiff disk network
_ -> Delete "No move in file matches DGS move"
forestDiff _ _ _ = Wait "True game tree has multiple branches"
decisionForNodes :: NodeGo -> [TreeGo] -> Decision
decisionForNodes _ [ ] = Delete "Reached end of move tree in file"
decisionForNodes
( GameNode { action = Right Move { move = Just (_, Play p) } } )
[Node { rootLabel = GameNode { action = Right Move { move = Just (_, Play c) } } }]
= Do p c
decisionForNodes _
[Node { rootLabel = GameNode { action = Right Move { move = Just (_, Play c) } } }]
= Wait "True game tree has non-play node"
decisionForNodes _ [_] = Wait "Predicted game tree has non-play node"
decisionForNodes _ nodes = Wait "Several valid continuations available"
-- }}}
wyvern = do
readArgs
readConfigFile
readArgs
dgs DGS.silence
getMode >>= execute
main = defaultConfig >>= runWyvern wyvern