├── ai_dungeon_cli ├── impl │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── client.py │ ├── utils │ │ ├── __init__.py │ │ └── debug_print.py │ ├── user_interaction.py │ └── conf.py ├── res │ ├── __init__.py │ ├── opening-ascii.txt │ └── opening-utf8.txt └── __init__.py ├── requirements.txt ├── pkg ├── .gitignore └── PKGBUILD ├── environment.yml ├── .github └── workflows │ └── python.yml ├── setup.py ├── LICENSE ├── .gitignore └── README.md /ai_dungeon_cli/impl/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ai_dungeon_cli/res/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/api/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/utils/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests >= 2.23.0 2 | PyYAML >= 5.1.2 3 | gql == v3.0.0a1 4 | pyreadline >= 2.1;platform_system=='Windows' 5 | -------------------------------------------------------------------------------- /pkg/.gitignore: -------------------------------------------------------------------------------- 1 | # ArchLinuxPackages.gitignore: 2 | *.tar 3 | *.tar.* 4 | *.jar 5 | *.exe 6 | *.msi 7 | *.zip 8 | *.tgz 9 | *.log 10 | *.log.* 11 | *.sig 12 | 13 | pkg/ 14 | src/ 15 | 16 | ai-dungeon-cli-git/ 17 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: ai-dungeon-cli-env 2 | dependencies: 3 | - python=3 4 | - pip 5 | - requests=2.* 6 | - PyYAML=5.* 7 | - pip: 8 | # - git+git://github.com/graphql-python/gql@v3.0.0a1#egg=gql 9 | - gql == v3.0.0a1 10 | - pyreadline >= 2.1;platform_system=='Windows' 11 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/utils/debug_print.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | 3 | 4 | # ------------------------------------------------------------------------- 5 | # STATE 6 | 7 | DEBUG = False 8 | 9 | def activate_debug(): 10 | global DEBUG 11 | DEBUG = True 12 | 13 | 14 | # ------------------------------------------------------------------------- 15 | # FNS 16 | 17 | def debug_print(msg): 18 | if DEBUG: 19 | print(msg) 20 | 21 | def debug_pprint(msg): 22 | if DEBUG: 23 | pprint(msg) 24 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | 3 | on: 4 | push: 5 | branches: [ '*' ] 6 | pull_request: 7 | branches: [ '*' ] 8 | 9 | jobs: 10 | Lint: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-20.04'] 15 | 16 | fail-fast: true 17 | max-parallel: 3 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Dependencies 23 | run: pip3 install -r requirements.txt 24 | 25 | - uses: TrueBrain/actions-flake8@master 26 | with: 27 | ignore: F,W,C90,F401 28 | only_warn: 1 29 | 30 | - uses: TrueBrain/actions-flake8@master 31 | with: 32 | ignore: E,F401 33 | -------------------------------------------------------------------------------- /ai_dungeon_cli/res/opening-ascii.txt: -------------------------------------------------------------------------------- 1 | 2 | ccc $$3 "3$$$$$c $ $$ $$$c $ c$$$$ 3$$$$$ :$$$$$ $$$c $ 3 | :$$$$c 3$$: 3$' $ $ $$: $$ '$ $ $$ '" '$ " :$$: $$ $$ '$ $ 4 | :$$ '$c :$$: $$ $ $$ :$$ 3$$ '$ $$ :$$ ccc $$$ :$$. $$ 3$$ '$ $$: 5 | .$$cccc$$ .$$. 3$$c ! 3$ .$$ 3$$: :$$ .3$ $$3 3$ c $$ $$ 3$$: :$$: 6 | 3$ 3$$ .$F. 3$$$$3: :$$$$3 :$$. `$$ .:3$$F' :$$F. `$$$3: :$$. `$$. 7 | :: 3:$ .3 :: : :: : : . :. : : .: : .. :. .. :.:.:. . :. : : 8 | : :: . : . . : : ::. . . . .. . :. . . . . . . : :. . .. . :. 9 | . : : . . . . ... . . . . . . . . . . . . : . . . 10 | . . . . . . . . . . . . 11 | . 12 | -------------------------------------------------------------------------------- /ai_dungeon_cli/res/opening-utf8.txt: -------------------------------------------------------------------------------- 1 | 2 | ▄▄▄ ██▓ ▓█████▄ █ ██ ███▄ █ ▄████ ▓█████ ▒█████ ███▄ █ 3 | ▒████▄ ▓██▒ ▒██▀ ██▌ ██ ▓██▒ ██ ▀█ █ ██▒ ▀█▒▓█ ▀ ▒██▒ ██▒ ██ ▀█ █ 4 | ▒██ ▀█▄ ▒██▒ ░██ █▌▓██ ▒██░▓██ ▀█ ██▒▒██░▄▄▄░▒███ ▒██░ ██▒▓██ ▀█ ██▒ 5 | ░██▄▄▄▄██ ░██░ ░▓█▄ ▌▓▓█ ░██░▓██▒ ▐▌██▒░▓█ ██▓▒▓█ ▄ ▒██ ██░▓██▒ ▐▌██▒ 6 | ▓█ ▓██▒░██░ ░▒████▓ ▒▒█████▓ ▒██░ ▓██░░▒▓███▀▒░▒████▒░ ████▓▒░▒██░ ▓██░ 7 | ▒▒ ▓▒█░░▓ ▒▒▓ ▒ ░▒▓▒ ▒ ▒ ░ ▒░ ▒ ▒ ░▒ ▒ ░░ ▒░ ░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ 8 | ▒ ▒▒ ░ ▒ ░ ░ ▒ ▒ ░░▒░ ░ ░ ░ ░░ ░ ▒░ ░ ░ ░ ░ ░ ░ ▒ ▒░ ░ ░░ ░ ▒░ 9 | ░ ▒ ▒ ░ ░ ░ ░ ░░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ 10 | ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ 11 | ░ 12 | -------------------------------------------------------------------------------- /pkg/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Otreblan 2 | 3 | pkgname=ai-dungeon-cli-git 4 | pkgver=r22.15dcf04 5 | pkgrel=1 6 | pkgdesc="Play ai dungeon on your terminal" 7 | arch=('any') 8 | url="https://github.com/Eigenbahn/ai-dungeon-cli" 9 | license=('mit') 10 | groups=() 11 | depends=("python-requests" "python-yaml") 12 | makedepends=("python-setuptools") 13 | checkdepends=() 14 | optdepends=() 15 | provides=(${pkgname%-git}) 16 | conflicts=(${pkgname%-git}) 17 | replaces=() 18 | backup=() 19 | options=() 20 | install= 21 | changelog= 22 | source=("$pkgname::git+file://$(git rev-parse --show-toplevel)") 23 | noextract=() 24 | sha256sums=("SKIP") 25 | 26 | pkgver() { 27 | cd "$srcdir/$pkgname" 28 | ( set -o pipefail 29 | git describe --long 2>/dev/null | sed 's/^v-//;s/\([^-]*-g\)/r\1/;s/-/./g' || 30 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 31 | ) 32 | } 33 | 34 | build() { 35 | cd "$pkgname" 36 | 37 | python setup.py build 38 | } 39 | 40 | package() { 41 | cd "$pkgname" 42 | 43 | python setup.py install --root="$pkgdir/" --optimize=1 --skip-build 44 | } 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="ai-dungeon-cli", 8 | version_format='{tag}', 9 | author="Jordan Besly", 10 | author_email="", 11 | description="Play ai dungeon from your terminal", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/Eigenbahn/ai-dungeon-cli", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | ], 20 | install_requires=[ 21 | "requests>=2.23.0", 22 | "PyYAML>=5.1.2", 23 | "gql==v3.0.0a1", 24 | "pyreadline >= 2.1;platform_system=='Windows'" 25 | ], 26 | setup_requires=['setuptools-git-version'], 27 | entry_points={ 28 | "console_scripts": [ 29 | "ai-dungeon-cli = ai_dungeon_cli.__init__:main", 30 | ], 31 | }, 32 | package_data={ 33 | "": ["*.txt"] 34 | }, 35 | python_requires='>=3.3', 36 | ) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jordan Besly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | 3 | # Python.gitignore: 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # Pycharm 90 | .idea/ 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | .idea/workspace.xml 132 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/user_interaction.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from abc import ABC, abstractmethod 4 | import textwrap 5 | import shutil 6 | 7 | from time import sleep 8 | from random import randint 9 | 10 | # NB: import doesn't appear to be used but in fact overrides definition for 11 | # the input() method 12 | try: 13 | import readline 14 | except ImportError: 15 | import pyreadline as readline 16 | 17 | 18 | # ------------------------------------------------------------------------- 19 | # ABSTRACT 20 | 21 | class UserIo(ABC): 22 | def handle_user_input(self, prompt: str = '') -> str: 23 | pass 24 | 25 | def handle_basic_output(self, text: str): 26 | pass 27 | 28 | def handle_story_output(self, text: str): 29 | self.handle_basic_output(text) 30 | 31 | 32 | # ------------------------------------------------------------------------- 33 | # IMPLEM: BASIC 34 | 35 | class TermIo(UserIo): 36 | def __init__(self, prompt: str = ''): 37 | self.prompt = prompt 38 | 39 | def handle_user_input(self) -> str: 40 | user_input = input(self.prompt) 41 | print() 42 | return user_input 43 | 44 | def handle_basic_output(self, text: str): 45 | for line in text.split("\n"): 46 | print("\n".join(textwrap.wrap(line, self.get_width()))) 47 | print() 48 | 49 | # def handle_story_output(self, text: str): 50 | # self.handle_basic_output(text) 51 | 52 | def get_width(self): 53 | terminal_size = shutil.get_terminal_size((80, 20)) 54 | return terminal_size.columns 55 | 56 | def display_splash(self): 57 | filename = os.path.dirname(os.path.realpath(__file__)) 58 | locale = None 59 | term = None 60 | if "LC_ALL" in os.environ: 61 | locale = os.environ["LC_ALL"] 62 | if "TERM" in os.environ: 63 | term = os.environ["TERM"] 64 | 65 | if locale == "C" or (term and term.startswith("vt")): 66 | filename += "/../res/opening-ascii.txt" 67 | else: 68 | filename += "/../res/opening-utf8.txt" 69 | 70 | with open(filename, "r", encoding="utf8") as splash_image: 71 | print(splash_image.read()) 72 | 73 | def clear(self): 74 | if os.name == "nt": 75 | _ = os.system("cls") 76 | else: 77 | _ = os.system("clear") 78 | 79 | 80 | # ------------------------------------------------------------------------- 81 | # IMPLEM: SLOW TYPING EFFECT 82 | 83 | class TermIoSlowStory(TermIo): 84 | def __init__(self, prompt: str = ''): 85 | sys.stdout = Unbuffered(sys.stdout) 86 | super().__init__(prompt) 87 | 88 | def handle_story_output(self, text: str): 89 | for line in text.split("\n"): 90 | for line2 in textwrap.wrap(line, self.get_width()): 91 | for letter in line2: 92 | print(letter, end='') 93 | sleep(randint(2, 10)*0.005) 94 | print() 95 | print() 96 | 97 | 98 | # allow unbuffered output for slow typing effect 99 | class Unbuffered(object): 100 | def __init__(self, stream): 101 | self.stream = stream 102 | def write(self, data): 103 | self.stream.write(data) 104 | self.stream.flush() 105 | def writelines(self, datas): 106 | self.stream.writelines(datas) 107 | self.stream.flush() 108 | def __getattr__(self, attr): 109 | return getattr(self.stream, attr) 110 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict 3 | import argparse 4 | import yaml 5 | 6 | 7 | # ------------------------------------------------------------------------- 8 | # UTILS: DICT 9 | 10 | def exists(cfg: Dict[str, str], key: str) -> str: 11 | return key in cfg and cfg[key] 12 | 13 | 14 | # ------------------------------------------------------------------------- 15 | # CONF OBJECT 16 | 17 | class Config: 18 | def __init__(self): 19 | self.prompt: str = "> " 20 | self.slow_typing_effect: bool = False 21 | 22 | self.auth_token: str = None 23 | self.email: str = None 24 | self.password: str = None 25 | 26 | self.character_name: str = None 27 | self.public_adventure_id: str = None 28 | 29 | self.debug: bool = False 30 | 31 | @staticmethod 32 | def merged(confs): 33 | default_conf = Config() 34 | conf = Config() 35 | for c in confs: 36 | for a in ['prompt', 'slow_typing_effect', 37 | 'auth_token', 'email', 'password', 38 | 'character_name', 'public_adventure_id', 39 | 'debug']: 40 | v = getattr(c, a) 41 | if getattr(default_conf, a) != v: 42 | setattr(conf, a, v) 43 | return conf 44 | 45 | @staticmethod 46 | def loaded_from_cli_args(): 47 | conf = Config() 48 | conf.load_from_cli_args() 49 | return conf 50 | 51 | def load_from_cli_args(self): 52 | parsed = Config.parse_cli_args() 53 | if hasattr(parsed, "prompt"): 54 | self.prompt = parsed.prompt 55 | if hasattr(parsed, "slow_typing"): 56 | self.slow_typing_effect = parsed.slow_typing 57 | if hasattr(parsed, "auth_token"): 58 | self.auth_token = parsed.auth_token 59 | if hasattr(parsed, "email"): 60 | self.email = parsed.email 61 | if hasattr(parsed, "password"): 62 | self.password = parsed.password 63 | if hasattr(parsed, "adventure"): 64 | self.public_adventure_id = parsed.adventure 65 | if hasattr(parsed, "name"): 66 | self.character_name = parsed.name 67 | if hasattr(parsed, "debug"): 68 | self.debug = parsed.debug 69 | 70 | @staticmethod 71 | def parse_cli_args(): 72 | parser = argparse.ArgumentParser(description='ai-dungeon-cli is a command-line client to play.aidungeon.io') 73 | parser.add_argument("--prompt", type=str, required=False, default="> ", 74 | help="text for user prompt") 75 | parser.add_argument("--slow-typing", action='store_const', const=True, 76 | help="enable slow typing effect for story") 77 | 78 | parser.add_argument("--auth-token", type=str, required=False, 79 | help="authentication token") 80 | parser.add_argument("--email", type=str, required=False, 81 | help="email (for authentication)") 82 | parser.add_argument("--password", type=str, required=False, 83 | help="password (for authentication)") 84 | 85 | parser.add_argument("--adventure", type=str, required=False, 86 | help="public multi-user adventure id to connect to") 87 | parser.add_argument("--name", type=str, required=False, 88 | help="character name for multi-user adventure") 89 | 90 | parser.add_argument("--debug", action='store_const', const=True, 91 | help="enable debug") 92 | 93 | parsed = parser.parse_args() 94 | 95 | if parsed.adventure and not parsed.name: 96 | parser.error("--name needs to be provided when joining a multi-user adventure (--adventure argument)") 97 | 98 | return parsed 99 | 100 | @staticmethod 101 | def loaded_from_file(): 102 | conf = Config() 103 | conf.load_from_file() 104 | return conf 105 | 106 | def load_from_file(self): 107 | cfg_file = "/config.yml" 108 | cfg_file_paths = [ 109 | os.path.dirname(os.path.realpath(__file__)) + cfg_file, 110 | os.path.expanduser("~") + "/.config/ai-dungeon-cli" + cfg_file, 111 | ] 112 | 113 | did_read_cfg_file = False 114 | 115 | cfg = {} 116 | for file in cfg_file_paths: 117 | try: 118 | with open(file, "r") as cfg_raw: 119 | cfg = yaml.load(cfg_raw, Loader=yaml.FullLoader) 120 | did_read_cfg_file = True 121 | except IOError: 122 | pass 123 | 124 | if exists(cfg, "prompt"): 125 | self.prompt = cfg["prompt"] 126 | if exists(cfg, "slow_typing_effect"): 127 | self.slow_typing_effect = cfg["slow_typing_effect"] 128 | if exists(cfg, "auth_token"): 129 | self.auth_token = cfg["auth_token"] 130 | if exists(cfg, "email"): 131 | self.email = cfg["email"] 132 | if exists(cfg, "password"): 133 | self.password = cfg["password"] 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Dungeon CLI 2 | 3 | This is basically a cli client to [play.aidungeon.io](https://play.aidungeon.io/). 4 | 5 | This allows playing AI Dungeon 2 inside a terminal. 6 | 7 | I primarily did this to play the game on a DEC VT320 hardware terminal for a more _faithful_ experience. 8 | 9 | For more context, read the [accompanying blog post](https://www.eigenbahn.com/2020/02/22/ai-dungeon-cli). 10 | 11 | ![AI Dungeon on a VT320](https://www.eigenbahn.com/assets/img/ai-dungeon-vt320.jpg) 12 | 13 | **WARNING:** As of writing the this client [does not work anymore](https://github.com/Eigenbahn/ai-dungeon-cli/issues/29#issuecomment-722294824). This seems to be associated with API changes necessary for the introduction of the stamina system. We'll eventually adapt to those new changes and if you're handy with python you can help speed things up. 14 | 15 | 16 | ## Installation 17 | 18 | #### pip 19 | 20 | [![PyPI version fury.io](https://badge.fury.io/py/ai-dungeon-cli.svg)](https://pypi.python.org/project/ai-dungeon-cli/) 21 | 22 | $ python3 -m pip install ai-dungeon-cli 23 | 24 | 25 | Or for unstable release from the source code: 26 | 27 | $ python3 -m pip install . 28 | 29 | 30 | #### Arch Linux 31 | 32 | [![AUR version](https://img.shields.io/aur/version/ai-dungeon-cli?logo=arch-linux)](https://aur.archlinux.org/packages/ai-dungeon-cli) 33 | 34 | Package is on [AUR](https://aur.archlinux.org/packages/ai-dungeon-cli-git/). 35 | 36 | Using [trizen](https://github.com/trizen/trizen): 37 | 38 | $ trizen -S ai-dungeon-cli-git 39 | 40 | Old school way: 41 | 42 | $ git clone https://aur.archlinux.org/ai-dungeon-cli-git.git 43 | $ cd ai-dungeon-cli-git 44 | $ makepkg -si 45 | 46 | 47 | ## Playing 48 | 49 | Unless specified, all user inputs are considered `Do` actions. 50 | 51 | Quoted input entries are automatically interpreted as `Say` actions, e.g.: 52 | 53 | > "Hey dragon! You didn't invite me to the latest BBQ party!" 54 | 55 | Do be explicit about the action type, prefix your input with a command: 56 | 57 | - `/do` 58 | - `/say` 59 | - `/story` 60 | - `/remember` 61 | 62 | For example, the previous `Say` prompt could also be written: 63 | 64 | > /say Hey dragon! You didn't invite me to the latest BBQ party! 65 | 66 | To quit, either press `Ctrl-C`, `Ctrl-D` or type in the special `/quit` command. 67 | 68 | ## Running 69 | 70 | In any case, you first need to create a configuration file. 71 | 72 | #### Installed 73 | 74 | $ ai-dungeon-cli 75 | 76 | #### From source 77 | 78 | With a conda env (assuming you're using [anaconda](https://www.anaconda.com/)): 79 | 80 | $ cd ai-dungeon-cli 81 | $ conda env create 82 | $ conda activate ai-dungeon-cli-env 83 | $ ./ai_dungeon_cli/__init__.py 84 | 85 | With a viltualenv: 86 | 87 | $ cd ai-dungeon-cli 88 | $ virtualenv -p $(command -v python3) ai-dungeon-cli-venv 89 | $ source ai-dungeon-cli-venv/bin/activate 90 | $ python3 -m pip install -r requirements.txt 91 | $ ./ai_dungeon_cli/__init__.py 92 | 93 | Please note that all those examples use a virtual env in order to not mess up with the main Python env on your system. 94 | 95 | 96 | ## Configuration (optional) 97 | 98 | Several things can be tuned by resorting to a config file. 99 | 100 | Create a file `config.yml` either: 101 | 102 | - in the same folder in your home folder: `$HOME/.config/ai-dungeon-cli/config.yml` 103 | - in the same folder as the sources: `./ai-dungeon-cli/ai_dungeon_cli/config.yml` 104 | 105 | 106 | #### Authentication 107 | 108 | By default, if no authentication configuration is provided, an anonymous session is created. 109 | 110 | ai-dungeon-cli supports 2 ways to configure user authentication. 111 | 112 | Either precise a couple of credentials in conf: 113 | 114 | ```yaml 115 | email: '' 116 | password: '' 117 | ``` 118 | 119 | Or sniff a _Authentication Token_ and use it directly: 120 | 121 | ```yaml 122 | auth_token: '' 123 | ``` 124 | 125 | To get this token, you need to first login in a web browser to [play.aidungeon.io](https://play.aidungeon.io/). 126 | 127 | Then you can find the token either in your browser [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) or in the content of the `connection_init` message of the websocket communication (first sent message). 128 | 129 | Either way, developer tools (`F12`) is your friend. 130 | 131 | 132 | #### Slow Typing Animation 133 | 134 | By default, responses are printed to the screen instantly. 135 | 136 | To enable a fun "slow" typing animation, use: 137 | 138 | ```yaml 139 | slow_typing_effect: True 140 | ``` 141 | 142 | 143 | #### Prompt 144 | 145 | The default user prompt is `'> '`. 146 | 147 | You can customize it with e.g. : 148 | 149 | ```yaml 150 | prompt: 'me: ' 151 | ``` 152 | 153 | 154 | ## Command-line arguments 155 | 156 | All configuration options are mapped to command-line arguments. 157 | 158 | Additionally, some features (such as multi-player support) are only available through those arguments. 159 | 160 | The list of all arguments can be retrieved by calling `ai-dungeon-cli` with either `-h` of `--help`. 161 | 162 | #### Authentication 163 | 164 | One can use either the `--auth-token ` or `--email --password ` arguments to authenticate. 165 | 166 | #### Slow Typing Animation 167 | 168 | Just append `--slow-typing` to your execution call to enable this fancy effect. 169 | 170 | 171 | #### Prompt 172 | 173 | The custom prompt can be set with `--prompt ''`. 174 | 175 | 176 | #### Multi-player 177 | 178 | To join an existing multi-player adventure, use arguments `--adventure --name `. 179 | 180 | 181 | #### Debug 182 | 183 | TO enable debug mode and see the responses from the play.aidungeon.io API, use `--debug`. This option is mainly useful for developers. 184 | 185 | 186 | ## Dependencies 187 | 188 | Please have a look at [requirements.txt](./requirements.txt). 189 | 190 | 191 | ## Limitations and future improvements 192 | 193 | Right now, the code is over-optimistic: we don't catch cleanly when the backend is down. 194 | 195 | A better user experience could be achieved with the use of the [curses](https://docs.python.org/3/library/curses.html) library. 196 | 197 | For now `/revert` and `/alter`special actions are not supported. 198 | 199 | It would also be nice to add support for browsing other players' stories (_Explore_ menu). 200 | 201 | 202 | ## Implementation details 203 | 204 | We fallback to a pure ASCII version of the splash logo if we detect an incompatible locale / terminal type. 205 | 206 | 207 | ## Support 208 | 209 | As you might have heard, hosting AI Dungeon costs a lot of money. 210 | 211 | This cli client relies on the same infrastructure as the online version ([play.aidungeon.io](https://play.aidungeon.io/)). 212 | 213 | So don't hesitate to [help support the hosting fees](https://aidungeon.io/) to keep the game up and running. 214 | 215 | 216 | ## Author 217 | 218 | Jordan Besly [@p3r7](https://github.com/p3r7) ([blog](https://www.eigenbahn.com/)). 219 | 220 | 221 | ## Contributors & acknowledgements 222 | 223 | Major contributions: 224 | - Idan Gur [@idangur](https://github.com/idangur): OOP rewrite of game logic 225 | - Alberto Oporto Ames [@otreblan](https://github.com/otreblan): packaging, submission to AUR, CI chain and general housekeeping 226 | - [@jgb95](https://github.com/jgb95): slow typing effect 227 | - Alexander Batyrgariev [@sasha00123](https://github.com/sasha00123): help on porting to new websocket/gql-based version of the API 228 | 229 | Minor contributions: 230 | - Robert Davis [@bdavs](https://github.com/bdavs): pip requirements 231 | - [@Jezza](https://github.com/Jezza): suggested login using creds 232 | 233 | Code for slow typing effect inspired by [this message](https://mail.python.org/pipermail/tutor/2003-November/026645.html) from [Magnus Lycka](https://github.com/magnus-lycka) on the [Python Tutor mailing list](https://mail.python.org/mailman/listinfo/tutor). 234 | 235 | 236 | ## Similar projects 237 | 238 | - [sasha00123/ai-dungeon-bot](https://github.com/sasha00123/ai-dungeon-bot), a bot for Telegram & VK, written in python 239 | - [SoptikHa2/aidungeon2-cli](https://github.com/SoptikHa2/aidungeon2-cli), written in Rust (unmaintained) 240 | 241 | People have also forked this code and adapted it to interact with GPT-3: 242 | - [@nakosung's fork](https://github.com/nakosung/ai-dungeon-cli) 243 | - [@wesky93's fork](https://github.com/wesky93/ai-dungeon-cli) 244 | -------------------------------------------------------------------------------- /ai_dungeon_cli/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import asyncio 6 | from gql import gql, Client, WebsocketsTransport 7 | import requests 8 | 9 | from abc import ABC, abstractmethod 10 | 11 | from typing import Dict 12 | 13 | from pprint import pprint 14 | 15 | # NB: this is hackish but seems necessary when downloaded from pypi 16 | main_path = os.path.dirname(os.path.realpath(__file__)) 17 | module_path = os.path.abspath(main_path) 18 | if module_path not in sys.path: 19 | sys.path.append(module_path) 20 | 21 | from impl.utils.debug_print import activate_debug, debug_print, debug_pprint 22 | from impl.api.client import AiDungeonApiClient 23 | from impl.conf import Config 24 | from impl.user_interaction import UserIo, TermIo, TermIoSlowStory 25 | 26 | 27 | # ------------------------------------------------------------------------- 28 | # EXCEPTIONS 29 | 30 | # Quit Session exception for easier error and exiting handling 31 | class QuitSession(Exception): 32 | """raise this when the user typed /quit in order to leave the session""" 33 | 34 | 35 | # ------------------------------------------------------------------------- 36 | # GAME LOGIC 37 | 38 | class AbstractAiDungeonGame(ABC): 39 | def __init__(self, api: AiDungeonApiClient, conf: Config, user_io: UserIo): 40 | self.stop_session: bool = False 41 | 42 | self.user_id: str = None 43 | self.session_id: str = None 44 | 45 | self.scenario_id: str = '' # REVIEW: maybe call it setting_id ? 46 | self.character_name: str = '' 47 | self.adventure_id: str = '' 48 | self.public_id: str = None 49 | 50 | self.story_pitch_template: str = '' 51 | self.story_pitch: str = '' 52 | self.quests: str = '' 53 | 54 | self.setting_name: str = None 55 | self.is_multiplayer: bool = False 56 | self.story_configuration: Dict[str, str] = {} 57 | self.session: requests.Session = requests.Session() 58 | 59 | self.api = api 60 | self.conf = conf 61 | self.user_io = user_io 62 | 63 | def update_session_auth(self): 64 | self.session.headers.update({"X-Access-Token": self.conf.auth_token}) 65 | 66 | def get_auth_token(self) -> str: 67 | return self.conf.auth_token 68 | 69 | def get_credentials(self): 70 | if self.conf.email and self.conf.password: 71 | return [self.conf.email, self.conf.password] 72 | 73 | def login(self): 74 | pass 75 | 76 | def choose_selection(self, allowed_values: Dict[str, str], k_or_v='v') -> str: 77 | 78 | if k_or_v == 'k': 79 | allowed_values = {v: k for k, v in allowed_values.items()} 80 | 81 | while True: 82 | choice = self.user_io.handle_user_input() 83 | choice = choice.strip() 84 | 85 | if choice == "/quit": 86 | raise QuitSession("/quit") 87 | 88 | elif choice in allowed_values.keys(): 89 | return allowed_values[choice] 90 | elif choice in allowed_values.values(): 91 | return choice 92 | else: 93 | self.user_io.handle_basic_output("Please enter a valid selection.") 94 | continue 95 | 96 | 97 | def make_user_choose_config(self): 98 | pass 99 | 100 | # Initialize story 101 | def init_story(self): 102 | pass 103 | 104 | def resume_story(self, session_id: str): 105 | pass 106 | 107 | # Function for when the input typed was ordinary 108 | def process_regular_action(self, user_input: str): 109 | pass 110 | 111 | # Function for when /remember is typed 112 | def process_remember_action(self, user_input: str): 113 | pass 114 | 115 | # Function that is called each iteration to process user inputs 116 | def process_next_action(self): 117 | user_input = self.user_io.handle_user_input() 118 | 119 | if user_input == "/quit": 120 | self.stop_session = True 121 | 122 | else: 123 | if user_input.startswith("/remember"): 124 | self.process_remember_action(user_input[len("/remember "):]) 125 | else: 126 | self.process_regular_action(user_input) 127 | 128 | def start_game(self): 129 | # Run until /quit is received inside the process_next_action func 130 | while not self.stop_session: 131 | self.process_next_action() 132 | 133 | 134 | ## -------------------------------- 135 | 136 | class AiDungeonGame(AbstractAiDungeonGame): 137 | def __init__(self, api: AiDungeonApiClient, conf: Config, user_io: UserIo): 138 | super().__init__(api, conf, user_io) 139 | 140 | 141 | def login(self): 142 | auth_token = self.get_auth_token() 143 | 144 | if auth_token: 145 | self.api.update_session_access_token(auth_token) 146 | else: 147 | creds = self.get_credentials() 148 | if creds: 149 | email, password = creds 150 | self.api.user_login(email, password) 151 | else: 152 | self.api.anonymous_login() 153 | 154 | 155 | def _choose_character_name(self): 156 | print("Enter your character's name...\n") 157 | 158 | character_name = self.user_io.handle_user_input() 159 | 160 | if character_name == "/quit": 161 | raise QuitSession("/quit") 162 | 163 | self.character_name = character_name # TODO: create a setter instead 164 | 165 | 166 | def join_multiplayer(self): 167 | self.is_multiplayer = True 168 | self.character_name = self.conf.character_name 169 | self.adventure_id = self.api.join_multi_adventure(self.conf.public_adventure_id) 170 | 171 | 172 | def make_user_choose_config(self): 173 | # self.api.perform_init_handshake() 174 | 175 | ## SETTING SELECTION 176 | 177 | prompt, settings = self.api.get_options(self.api.single_player_mode_id) 178 | 179 | print(prompt + "\n") 180 | 181 | setting_select_dict = {} 182 | for i, setting in settings.items(): 183 | setting_id, setting_name = setting 184 | print(str(i) + ") " + setting_name) 185 | setting_select_dict[str(i)] = setting_name 186 | # setting_select_dict['0'] = '0' # secret mode 187 | selected_i = self.choose_selection(setting_select_dict, 'k') 188 | setting_id, self.setting_name = settings[selected_i] 189 | self.scenario_id = setting_id 190 | 191 | if self.setting_name == "custom": 192 | return 193 | elif self.setting_name == "archive": 194 | while True: 195 | prompt, options = self.api.get_options(self.scenario_id) 196 | 197 | if options is None: 198 | self.story_pitch_template = prompt 199 | self._choose_character_name() 200 | self.story_pitch = self.api.make_story_pitch(self.story_pitch_template, 201 | self.character_name) 202 | return 203 | 204 | print(prompt + "\n") 205 | 206 | select_dict = {} 207 | for i, option in options.items(): 208 | option_id, option_name = option 209 | print(str(i) + ") " + option_name) 210 | select_dict[str(i)] = option_name 211 | # setting_select_dict['0'] = '0' # secret mode 212 | selected_i = self.choose_selection(select_dict, 'k') 213 | option_id, option_name = options[selected_i] 214 | self.scenario_id = option_id 215 | 216 | 217 | ## CHARACTER SELECTION 218 | 219 | prompt, characters = self.api.get_characters(self.scenario_id) 220 | 221 | print(prompt + "\n") 222 | 223 | character_select_dict = {} 224 | for i, character in characters.items(): 225 | character_id, character_type = character 226 | print(str(i) + ") " + character_type) 227 | character_select_dict[str(i)] = character_type 228 | selected_i = self.choose_selection(character_select_dict, 'k') 229 | character_id, character_type = characters[selected_i] 230 | self.scenario_id = character_id # TODO: create a setter instead 231 | 232 | self._choose_character_name() 233 | 234 | ## PITCH 235 | 236 | self.story_pitch_template = self.api.get_story_template_for_scenario(self.scenario_id) 237 | self.story_pitch = self.api.make_story_pitch(self.story_pitch_template, 238 | self.character_name) 239 | 240 | 241 | # Initialize story 242 | def init_story(self): 243 | if self.is_multiplayer: 244 | self.api.init_story_multi_adventure(self.conf.public_adventure_id) 245 | elif self.setting_name == "custom": 246 | self.init_story_custom() 247 | else: 248 | print("Generating story... Please wait...\n") 249 | self.adventure_id, self.public_id, self.story_pitch, self.quests = self.api.init_story(self.scenario_id, 250 | self.story_pitch) 251 | 252 | self.user_io.handle_story_output(self.story_pitch) 253 | 254 | 255 | def init_story_custom(self): 256 | self.user_io.handle_basic_output( 257 | "Enter a prompt that describes who you are and the first couple sentences of where you start out ex: " 258 | "'You are a knight in the kingdom of Larion. You are hunting the evil dragon who has been terrorizing " 259 | "the kingdom. You enter the forest searching for the dragon and see'" 260 | ) 261 | user_story_pitch = self.user_io.handle_user_input() 262 | 263 | self.story_pitch = None 264 | self.adventure_id, _ = self.api.create_adventure(self.scenario_id, self.story_pitch) 265 | self.story_pitch = self.api.init_custom_story_pitch(self.adventure_id, user_story_pitch) 266 | 267 | 268 | def find_action_type(self, user_input: str): 269 | user_input = user_input.strip() 270 | action = 'do' 271 | if user_input == '': 272 | return (action, user_input) 273 | elif user_input.lower().startswith('/do '): 274 | user_input = user_input[len('/do '):] 275 | action = 'do' 276 | elif user_input.lower().startswith('/say '): 277 | user_input = user_input[len('/say '):] 278 | action = 'say' 279 | elif user_input.lower().startswith('/story '): 280 | user_input = user_input[len('/story '):] 281 | action = 'story' 282 | elif user_input.lower().startswith('you say "') and user_input[-1] == '"': 283 | user_input = user_input[len('you say "'):-1] 284 | action = 'say' 285 | elif user_input[0] == '"' and user_input[-1] == '"': 286 | user_input = user_input[1:-1] 287 | action = 'say' 288 | return (action, user_input) 289 | 290 | 291 | # Function for when the input typed was ordinary 292 | def process_regular_action(self, user_input: str): 293 | 294 | (action, user_input) = self.find_action_type(user_input) 295 | 296 | resp = self.api.perform_regular_action(self.adventure_id, action, user_input, self.character_name) 297 | 298 | self.user_io.handle_story_output(resp) 299 | 300 | def process_remember_action(self, user_input: str): 301 | self.api.perform_remember_action(user_input, self.adventure_id) 302 | 303 | def process_next_action(self): 304 | user_input = self.user_io.handle_user_input() 305 | 306 | if user_input == "/quit": 307 | self.stop_session = True 308 | 309 | else: 310 | if user_input.startswith("/remember"): 311 | # pass 312 | self.process_remember_action(user_input[len("/remember "):]) 313 | else: 314 | self.process_regular_action(user_input) 315 | 316 | 317 | # ------------------------------------------------------------------------- 318 | # MAIN 319 | 320 | def main(): 321 | 322 | try: 323 | # Initialize the configuration from config file 324 | file_conf = Config.loaded_from_file() 325 | cli_args_conf = Config.loaded_from_cli_args() 326 | conf = Config.merged([file_conf, cli_args_conf]) 327 | 328 | if conf.debug: 329 | activate_debug() 330 | 331 | # Initialize the terminal I/O class 332 | if conf.slow_typing_effect: 333 | term_io = TermIoSlowStory(conf.prompt) 334 | else: 335 | term_io = TermIo(conf.prompt) 336 | 337 | api_client = AiDungeonApiClient() 338 | 339 | # Initialize the game logic class with the given auth_token and prompt 340 | ai_dungeon = AiDungeonGame(api_client, conf, term_io) 341 | 342 | # Clears the console 343 | term_io.clear() 344 | 345 | # Login 346 | ai_dungeon.login() 347 | 348 | # Displays the splash image accordingly 349 | if term_io.get_width() >= 80: 350 | term_io.display_splash() 351 | 352 | # Loads the current session configuration 353 | if conf.public_adventure_id: 354 | ai_dungeon.join_multiplayer() 355 | else: 356 | ai_dungeon.make_user_choose_config() 357 | 358 | # Initializes the story 359 | ai_dungeon.init_story() 360 | 361 | # Starts the game 362 | ai_dungeon.start_game() 363 | 364 | except QuitSession: 365 | term_io.handle_basic_output("Bye Bye!") 366 | 367 | except EOFError: 368 | term_io.handle_basic_output("Received Keyboard Interrupt. Bye Bye...") 369 | 370 | except KeyboardInterrupt: 371 | term_io.handle_basic_output("Received Keyboard Interrupt. Bye Bye...") 372 | 373 | except requests.exceptions.TooManyRedirects: 374 | term_io.handle_basic_output("Exceded max allowed number of HTTP redirects, API backend has probably changed") 375 | exit(1) 376 | 377 | except requests.exceptions.HTTPError as err: 378 | term_io.handle_basic_output("Unexpected response from API backend:") 379 | term_io.handle_basic_output(err) 380 | exit(1) 381 | 382 | except ConnectionError: 383 | term_io.handle_basic_output("Lost connection to the Ai Dungeon servers") 384 | exit(1) 385 | 386 | except requests.exceptions.RequestException as err: 387 | term_io.handle_basic_output("Totally unexpected exception:") 388 | term_io.handle_basic_output(err) 389 | exit(1) 390 | 391 | 392 | if __name__ == "__main__": 393 | main() 394 | -------------------------------------------------------------------------------- /ai_dungeon_cli/impl/api/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from gql import gql, Client, WebsocketsTransport 3 | 4 | from impl.utils.debug_print import debug_print, debug_pprint 5 | 6 | 7 | # ------------------------------------------------------------------------- 8 | # API CLIENT 9 | 10 | class AiDungeonApiClient: 11 | def __init__(self): 12 | self.url: str = 'wss://api.aidungeon.io/subscriptions' 13 | self.websocket = WebsocketsTransport(url=self.url) 14 | self.gql_client = Client(transport=self.websocket, 15 | # fetch_schema_from_transport=True, 16 | ) 17 | self.account_id: str = '' 18 | self.access_token: str = '' 19 | 20 | self.single_player_mode_id: str = 'scenario:458612' 21 | 22 | 23 | async def _execute_query_pseudo_async(self, query, params={}): 24 | async with Client( 25 | transport=self.websocket, 26 | # fetch_schema_from_transport=True, 27 | ) as session: 28 | return await session.execute(gql(query), variable_values=params) 29 | 30 | 31 | def _execute_query(self, query, params=None): 32 | return self.gql_client.execute(gql(query), variable_values=params) 33 | 34 | 35 | def update_session_access_token(self, access_token): 36 | self.websocket = WebsocketsTransport( 37 | url=self.url, 38 | init_payload={'token': access_token}) 39 | self.gql_client = Client(transport=self.websocket, 40 | # fetch_schema_from_transport=True, 41 | ) 42 | 43 | 44 | def user_login(self, email, password): 45 | debug_print("user login") 46 | result = self._execute_query(''' 47 | mutation ($email: String, $password: String, $anonymousId: String) { login(email: $email, password: $password, anonymousId: $anonymousId) { id accessToken __typename }} 48 | ''', 49 | { 50 | "email": email , 51 | "password": password 52 | } 53 | ) 54 | debug_print(result) 55 | self.account_id = result['login']['id'] 56 | self.access_token = result['login']['accessToken'] 57 | self.update_session_access_token(self.access_token) 58 | 59 | 60 | def anonymous_login(self): 61 | debug_print("anonymous login") 62 | result = self._execute_query(''' 63 | mutation { createAnonymousAccount { id accessToken __typename }} 64 | ''') 65 | debug_print(result) 66 | self.account_id = result['createAnonymousAccount']['id'] 67 | self.access_token = result['createAnonymousAccount']['accessToken'] 68 | self.update_session_access_token(self.access_token) 69 | 70 | 71 | 72 | def perform_init_handshake(self): 73 | # debug_print("query user details") 74 | # result = self._execute_query(''' 75 | # { user { id isDeveloper hasPremium lastAdventure { id mode __typename } newProductUpdates { id title description createdAt __typename } __typename }} 76 | # ''') 77 | # debug_print(result) 78 | 79 | 80 | debug_print("add device token") 81 | result = self._execute_query(''' 82 | mutation ($token: String, $platform: String) { addDeviceToken(token: $token, platform: $platform)} 83 | ''', 84 | { 'token': 'web', 85 | 'platform': 'web' }) 86 | debug_print(result) 87 | 88 | 89 | debug_print("send event start premium") 90 | result = self._execute_query(''' 91 | mutation ($input: EventInput) { sendEvent(input: $input)} 92 | ''', 93 | { 94 | "input": { 95 | "eventName":"start_premium_v5", 96 | "variation":"dont", 97 | # "variation":"show", 98 | "platform":"web" 99 | } 100 | }) 101 | debug_print(result) 102 | 103 | 104 | @staticmethod 105 | def normalize_options(raw_settings_list): 106 | settings_dict = {} 107 | for i, opts in enumerate(raw_settings_list, start=1): 108 | setting_id = opts['id'] 109 | setting_name = opts['title'] 110 | settings_dict[str(i)] = [setting_id, setting_name] 111 | return settings_dict 112 | 113 | 114 | def get_options(self, scenario_id): 115 | prompt = '' 116 | options = None 117 | 118 | debug_print("query options (variant #1)") 119 | result = self._execute_query(''' 120 | query ($id: String) { user { id username __typename } content(id: $id) { id userId contentType contentId prompt gameState options { id title __typename } playPublicId __typename }} 121 | ''', 122 | {"id": scenario_id}) 123 | debug_print(result) 124 | prompt = result['content']['prompt'] 125 | if result['content']['options']: 126 | options = self.normalize_options(result['content']['options']) 127 | 128 | # debug_print("query options (variant #2)") 129 | # result = self._execute_query(''' 130 | # query ($id: String) { content(id: $id) { id contentType contentId title description prompt memory tags nsfw published createdAt updatedAt deletedAt options { id title __typename } __typename }} 131 | # ''', 132 | # {"id": scenario_id}) 133 | # debug_print(result) 134 | # prompt = result['content']['prompt'] 135 | # options = self.normalize_options(result['content']['options']) 136 | 137 | return [prompt, options] 138 | 139 | 140 | def get_settings_single_player(self): 141 | return self.get_options(self.single_player_mode_id) 142 | 143 | 144 | def join_multi_adventure(self, public_adventure_id): 145 | debug_print("join multi-user adventure") 146 | result = self._execute_query(''' 147 | mutation ($adventurePlayPublicId: String) { addUserToAdventure(adventurePlayPublicId: $adventurePlayPublicId)} 148 | ''', 149 | {"adventurePlayPublicId": public_adventure_id}) 150 | debug_print(result) 151 | return result['addUserToAdventure'] 152 | 153 | 154 | def get_characters(self, scenario_id): 155 | prompt = '' 156 | characters = {} 157 | 158 | debug_print("query settings singleplayer (variant #1)") 159 | result = self._execute_query(''' 160 | query ($id: String) { user { id username __typename } content(id: $id) { id userId contentType contentId prompt gameState options { id title __typename } playPublicId __typename }} 161 | ''', 162 | {"id": scenario_id}) 163 | debug_print(result) 164 | prompt = result['content']['prompt'] 165 | characters = self.normalize_options(result['content']['options']) 166 | 167 | # debug_print("query settings singleplayer (variant #2)") 168 | # result = self._execute_query(''' 169 | # query ($id: String) { content(id: $id) { id contentType contentId title description prompt memory tags nsfw published createdAt updatedAt deletedAt options { id title __typename } __typename }} 170 | # ''', 171 | # {"id": scenario_id}) 172 | # debug_print(result) 173 | # prompt = result['content']['prompt'] 174 | # characters = self.normalize_options(result['content']['options']) 175 | 176 | return [prompt, characters] 177 | 178 | 179 | def get_story_template_for_scenario(self, scenario_id): 180 | 181 | debug_print("query get story for scenario") 182 | result = self._execute_query(''' 183 | query ($id: String) { user { id username __typename } content(id: $id) { id userId contentType contentId prompt gameState options { id title __typename } playPublicId __typename }} 184 | ''', 185 | {"id": scenario_id}) 186 | debug_print(result) 187 | return result['content']['prompt'] 188 | 189 | # debug_print("query get story for scenario #2") 190 | # result = self._execute_query(''' 191 | # query ($id: String) { content(id: $id) { id contentType contentId title description prompt memory tags nsfw published createdAt updatedAt deletedAt options { id title __typename } __typename }} 192 | # ''', 193 | # {"id": self.scenario_id}) 194 | # debug_print(result) 195 | 196 | 197 | 198 | @staticmethod 199 | def initial_story_from_history_list(history_list): 200 | pitch = '' 201 | for entry in history_list: 202 | if not entry['type'] in ['story', 'continue']: 203 | break 204 | pitch += entry['text'] 205 | return pitch 206 | 207 | 208 | def make_story_pitch(self, story_pitch_template, character_name): 209 | return story_pitch_template.replace('${character.name}', character_name) 210 | 211 | 212 | def init_custom_story_pitch(self, adventure_id, user_input): 213 | 214 | debug_print("send custom settings story pitch") 215 | result = self._execute_query(''' 216 | mutation ($input: ContentActionInput) { sendAction(input: $input) { id actionLoading memory died gameState newQuests { id text completed active __typename } actions { id text __typename } __typename }} 217 | ''', 218 | { 219 | "input": { 220 | "type": "story", 221 | "text": user_input, 222 | "id": adventure_id}}) 223 | debug_print(result) 224 | return ''.join([a['text'] for a in result['sendAction']['actions']]) 225 | 226 | 227 | def create_adventure(self, scenario_id, story_pitch): 228 | debug_print("create adventure") 229 | result = self._execute_query(''' 230 | mutation ($id: String, $prompt: String) { createAdventureFromScenarioId(id: $id, prompt: $prompt) { id contentType contentId title description musicTheme tags nsfw published createdAt updatedAt deletedAt publicId historyList __typename }} 231 | ''', 232 | { 233 | "id": scenario_id, 234 | "prompt": story_pitch 235 | }) 236 | debug_print(result) 237 | adventure_id = result['createAdventureFromScenarioId']['id'] 238 | story_pitch = None 239 | if 'historyList' in result['createAdventureFromScenarioId']: 240 | # NB: not present when story_pitch is None, as is the case for a custom scenario 241 | story_pitch = self.initial_story_from_history_list(result['createAdventureFromScenarioId']['historyList']) 242 | return [adventure_id, story_pitch] 243 | 244 | 245 | def init_story_multi_adventure(self, public_adventure_id): 246 | debug_print("get story multi-user adventure") 247 | result = self._execute_query(''' 248 | query ($id: String, $playPublicId: String) { content(id: $id, playPublicId: $playPublicId) { id actions { id text __typename } quests newQuests { id text completed active __typename } playPublicId userId __typename }} 249 | ''', 250 | {"playPublicId": public_adventure_id}) 251 | debug_print(result) 252 | entries = [] 253 | for entry in result['content']['actions']: 254 | if entry['__typename'] != 'Action': 255 | continue 256 | entry = entry['text'] 257 | if entry.startswith("\n>"): 258 | entry = "\n" + entry + "\n" # mo' spacing please 259 | entries.append(entry) 260 | return ''.join(entries) 261 | 262 | 263 | def init_story(self, scenario_id, story_pitch): 264 | adventure_id, story_pitch = self.create_adventure(scenario_id, story_pitch) 265 | 266 | debug_print("get created adventure ids") 267 | result = self._execute_query(''' 268 | query ($id: String, $playPublicId: String) { content(id: $id, playPublicId: $playPublicId) { id historyList quests playPublicId userId __typename }} 269 | ''', 270 | { 271 | "id": adventure_id, 272 | }) 273 | debug_print(result) 274 | quests = result['content']['quests'] 275 | public_id = result['content']['playPublicId'] 276 | # story_pitch = self.initial_story_from_history_list(result['content']['historyList']) 277 | 278 | return [adventure_id, public_id, story_pitch, quests] 279 | 280 | 281 | 282 | def perform_remember_action(self, user_input, adventure_id): 283 | debug_print("remember something") 284 | result = self._execute_query(''' 285 | mutation ($input: ContentActionInput) { updateMemory(input: $input) { id memory __typename }} 286 | ''', 287 | { 288 | "input": 289 | { 290 | "text": user_input, 291 | "type":"remember", 292 | "id": adventure_id 293 | } 294 | }) 295 | debug_print(result) 296 | 297 | 298 | def perform_regular_action(self, adventure_id, action, user_input, character_name = None): 299 | 300 | story_continuation = "" 301 | 302 | debug_print("send regular action") 303 | result = self._execute_query(''' 304 | mutation ($input: ContentActionInput) { sendAction(input: $input) { id actionLoading memory died gameState __typename }} 305 | ''', 306 | { 307 | "input": { 308 | "type": action, 309 | "text": user_input, 310 | "id": adventure_id, 311 | "characterName": character_name 312 | } 313 | }) 314 | debug_print(result) 315 | 316 | 317 | debug_print("get story continuation") 318 | result = self._execute_query(''' 319 | query ($id: String, $playPublicId: String) { 320 | content(id: $id, playPublicId: $playPublicId) { 321 | id 322 | actions { 323 | id 324 | text 325 | } 326 | } 327 | } 328 | ''', 329 | { 330 | "id": adventure_id 331 | }) 332 | debug_print(result) 333 | story_continuation = result['content']['actions'][-1]['text'] 334 | 335 | return story_continuation 336 | --------------------------------------------------------------------------------