packages feed

wyvern (empty) → 0.1

raw patch · 4 files changed

+609/−0 lines, 4 filesdep +ConfigFiledep +basedep +bytestringsetup-changed

Dependencies added: ConfigFile, base, bytestring, containers, dgs, directory, filepath, mtl, parsec, process, sgf, split

Files

+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) Daniel Wagner++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions+are met:+1. Redistributions of source code must retain the above copyright+   notice, this list of conditions and the following disclaimer.+2. 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.+3. Neither the name of the author nor the names of his contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++!! This software is provided by the regents 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 authors 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.
+ Main.hs view
@@ -0,0 +1,539 @@+-- 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
+ Setup.lhs view
@@ -0,0 +1,4 @@+#!/usr/bin/env runhaskell++> import Distribution.Simple+> main = defaultMain
+ wyvern.cabal view
@@ -0,0 +1,39 @@+name:               wyvern+version:            0.1+homepage:           http://dmwit.com/wyvern+synopsis:           An autoresponder for Dragon Go Server.+description:        Wyvern will play pre-planned sequences for you on the+                    Dragon Go Server.  Just feed it some SGF files outlining+                    your plan, and it will make moves in any games that are+                    waiting.+category:           Game+license:            BSD3+license-file:       LICENSE+author:             Daniel Wagner+maintainer:         daniel@wagner-home.com+cabal-version:      >= 1.6+build-type:         Simple++executable wyvern+    main-is:        Main.hs+    build-depends:  base >= 3 && < 4,+                    bytestring >= 0.9,+                    ConfigFile >= 1.0,+                    containers >= 0.2,+                    dgs >= 0.2,+                    directory >= 1.0,+                    filepath >= 1.1,+                    mtl >= 1.1,+                    parsec >= 3.0,+                    process >= 1.0,+                    sgf >= 0.1.1,+                    split >= 0.1++source-repository head+    type:       git+    location:   http://www.dmwit.com/wyvern/.git++source-repository this+    type:       git+    location:   http://www.dmwit.com/wyvern/.git+    tag:        0.1