├── .gitignore ├── requirements-dev.txt ├── requirements.txt ├── .mypy.ini ├── .pylintrc ├── feini ├── res │ ├── default.ini │ └── __init__.py ├── tests │ ├── __init__.py │ ├── ext_test_furniture.py │ ├── test_furniture.py │ ├── test_bot.py │ ├── test_util.py │ ├── test_actions.py │ ├── test_stories.py │ └── test_space.py ├── __init__.py ├── context.py ├── core.py ├── __main__.py ├── stories.py ├── updates.py ├── util.py ├── furniture.py ├── bot.py ├── space.py └── actions.py ├── scripts ├── release.sh └── material.py ├── feedparser.pyi ├── Makefile ├── .github └── workflows │ └── check.yaml ├── README.md ├── CONTRIBUTING.md └── LICENSE.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | /fe.ini 3 | /.mypy_cache/ 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy ~= 0.961.0 2 | pylint ~= 3.3 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp ~= 3.8 2 | redis ~= 5.0 3 | feedparser ~= 6.0 4 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = true 3 | disallow_any_expr = true 4 | disallow_any_decorated = true 5 | disallow_any_explicit = true 6 | show_error_codes = true 7 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [main] 2 | disable = 3 | # Good design is best figured out by humans yet 4 | design, too-many-lines, too-many-nested-blocks, 5 | # Handled by mypy 6 | classes, typecheck 7 | jobs = 0 8 | output-format = colorized 9 | -------------------------------------------------------------------------------- /feini/res/default.ini: -------------------------------------------------------------------------------- 1 | [feini] 2 | # URL of the Redis database 3 | redis_url = redis: 4 | # Indicates if debug mode is enabled 5 | debug = false 6 | 7 | [telegram] 8 | # Telegram messenger API key 9 | key = 10 | 11 | [tmdb] 12 | # TMDB API v4 key 13 | key = 14 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | FEATURE=${FEATURE:?} 6 | VERSION=${VERSION:?} 7 | 8 | # Merge feature (abort if there are no changes) 9 | git switch main 10 | git fetch 11 | git merge 12 | git merge --squash $FEATURE 13 | git diff --cached --quiet && false 14 | 15 | # Run code checks 16 | make check 17 | 18 | # Publish 19 | git commit --author="$(git log main..$FEATURE --format="%aN <%aE>" | tail --lines=1)" 20 | git tag $VERSION 21 | git push origin main $VERSION 22 | 23 | # Clean up 24 | git branch --delete $FEATURE 25 | git push --delete origin $FEATURE 26 | -------------------------------------------------------------------------------- /feedparser.pyi: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import struct_time 3 | from typing import BinaryIO, TextIO 4 | from urllib.request import BaseHandler 5 | 6 | def parse( 7 | url_file_stream_or_string: str | bytes | TextIO | BinaryIO, etag: str | None = ..., 8 | modified: str | datetime | struct_time | None = ..., agent: str | None = ..., 9 | referrer: str | None = ..., handlers: list[BaseHandler] | None = ..., 10 | request_headers: dict[str, str] | None = ..., response_headers: dict[str, str] | None = ..., 11 | resolve_relative_uris: bool | None = ..., 12 | sanitize_html: bool | None = ...) -> dict[str, object]: ... 13 | 14 | class ThingsNobodyCaresAboutButMe(Exception): ... 15 | -------------------------------------------------------------------------------- /feini/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | -------------------------------------------------------------------------------- /feini/res/__init__.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Resources.""" 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON=python3 2 | PIP=pip3 3 | 4 | PIPFLAGS=$$([ -z "$$VIRTUAL_ENV" ] && echo --user) --upgrade 5 | 6 | .PHONY: test 7 | test: 8 | $(PYTHON) -m unittest 9 | 10 | .PHONY: test-ext 11 | test-ext: 12 | $(PYTHON) -m unittest discover --pattern="ext_test*.py" 13 | 14 | .PHONY: type 15 | type: 16 | mypy feini scripts 17 | 18 | .PHONY: lint 19 | lint: 20 | pylint feini scripts 21 | 22 | .PHONY: check 23 | check: type test test-ext lint 24 | 25 | .PHONY: deps 26 | deps: 27 | $(PIP) install $(PIPFLAGS) --requirement requirements.txt 28 | 29 | .PHONY: deps-dev 30 | deps-dev: 31 | $(PIP) install $(PIPFLAGS) --requirement requirements-dev.txt 32 | 33 | .PHONY: release 34 | release: 35 | scripts/release.sh 36 | 37 | .PHONY: clean 38 | clean: 39 | rm --recursive --force $$(find . -name __pycache__) .mypy_cache 40 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Check code 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | check: 7 | strategy: 8 | matrix: 9 | python: 10 | - "3.9" 11 | - "3.10" 12 | - "3.11" 13 | - "3.12" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python }} 20 | - run: sudo apt-get install redis 21 | - run: make deps deps-dev 22 | - run: make type 23 | - run: make test 24 | - run: make test-ext 25 | - run: make lint 26 | continue-on-error: ${{ contains(github.event.head_commit.message, 'WIP') }} 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | 3 | Virtual pet chatbot and relaxing text game experiment. 4 | 5 | You can give it a try at [feini.chat](https://feini.chat/). 6 | 7 | ## System requirements 8 | 9 | The following software must be installed on your system: 10 | 11 | * Python >= 3.9 12 | * Redis >= 6.0 13 | 14 | Open Feini should work on any [POSIX](https://en.wikipedia.org/wiki/POSIX) system. 15 | 16 | ## Installing dependencies 17 | 18 | To install all dependencies, run: 19 | 20 | ```sh 21 | make deps 22 | ``` 23 | 24 | ## Running Open Feini 25 | 26 | To run Open Feini, use: 27 | 28 | ```sh 29 | python3 -m feini 30 | ``` 31 | 32 | The configuration file `fe.ini` is used, if present. See `feini/res/default.ini` for documentation. 33 | 34 | ## Messenger support 35 | 36 | Open Feini currently supports Telegram. Support for further messengers is planned. 37 | 38 | ## Contributors 39 | 40 | * Sven Pfaller <sven AT inrain.org> 41 | 42 | Copyright (C) 2022 Open Feini contributors 43 | -------------------------------------------------------------------------------- /feini/__init__.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Virtual pet chatbot and relaxing text game experiment. 16 | 17 | The bot is based on Redis and any operation may raise an :exc:`aioredis.exceptions.RedisError` if 18 | there is a problem communicating with the database. 19 | """ 20 | -------------------------------------------------------------------------------- /feini/context.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Context-Local state. 16 | 17 | .. data:: bot 18 | 19 | Active bot. 20 | """ 21 | 22 | from __future__ import annotations 23 | 24 | from contextvars import ContextVar 25 | from typing import TYPE_CHECKING 26 | 27 | if TYPE_CHECKING: 28 | from .bot import Bot 29 | 30 | bot: ContextVar[Bot] = ContextVar('bot') 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Open Feini 2 | 3 | ## How to contribute 4 | 5 | Would you like to contribute to Open Feini? Awesome! 💖 6 | 7 | 1. [Create an issue](https://github.com/noyainrain/feini/issues) describing the intended change. 8 | 2. Your draft is reviewed by a team member. Make the requested changes, if any. 9 | 3. Create a topic branch. 10 | 4. Code… 11 | 6. Run the code checks and fix any reported issues. 12 | 5. [Create a pull request](https://github.com/noyainrain/feini/pulls). 13 | 7. Your contribution is reviewed by a team member. Make the requested changes, if any. 14 | 8. Your contribution is merged by a team member. 🥳 15 | 16 | A good issue description contains: 17 | 18 | * If the API is modified, any class or function signature 19 | * If the UI is modified, an outline of the text 20 | * If a dependency is introduced, the reason why it is a better choice than alternatives 21 | 22 | ## Installing development dependencies 23 | 24 | To install all development dependencies, run: 25 | 26 | ```sh 27 | make deps-dev 28 | ``` 29 | 30 | ## Running code checks 31 | 32 | To run all unit tests, use: 33 | 34 | ```sh 35 | make 36 | ``` 37 | 38 | All available code checks (type, test and style) can be run with: 39 | 40 | ```sh 41 | make check 42 | ``` 43 | -------------------------------------------------------------------------------- /feini/core.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Core concepts.""" 16 | 17 | from typing import TypeVar 18 | 19 | from . import context 20 | 21 | _E = TypeVar('_E', bound='Entity') 22 | 23 | class Entity: 24 | """Game entity. 25 | 26 | .. attribute:: id 27 | 28 | Unique entity ID. 29 | """ 30 | 31 | def __init__(self, data: dict[str, str]) -> None: 32 | self.id = data['id'] 33 | 34 | async def get(self: _E) -> _E: 35 | """Get a fresh copy of the entity.""" 36 | data = await context.bot.get().redis.hgetall(self.id) 37 | if not data: 38 | raise ReferenceError(self.id) 39 | return type(self)(data) 40 | 41 | def __repr__(self) -> str: 42 | return f'<{self.id}>' 43 | 44 | def __eq__(self, other: object) -> bool: 45 | return isinstance(other, Entity) and self.id == other.id 46 | 47 | def __hash__(self) -> int: 48 | return hash(self.id) 49 | -------------------------------------------------------------------------------- /feini/tests/ext_test_furniture.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from asyncio import Task, all_tasks 18 | from collections.abc import AsyncIterator 19 | from configparser import ConfigParser 20 | from contextlib import asynccontextmanager 21 | from typing import cast 22 | 23 | from feini.furniture import TMDB 24 | from .test_bot import TestCase 25 | 26 | @asynccontextmanager 27 | async def wait_for_background_task() -> AsyncIterator[None]: 28 | tasks = cast(set[Task[None]], all_tasks()) 29 | yield 30 | try: 31 | await (cast(set[Task[None]], all_tasks()) - tasks).pop() 32 | except KeyError: 33 | pass 34 | 35 | class TMDBTest(TestCase): 36 | async def test_get_shows(self) -> None: 37 | config = ConfigParser() 38 | config.read('fe.ini') 39 | key = config.get('tmdb', 'key', fallback='') or None 40 | if not key: 41 | self.skipTest('Missing [tmdb] key') 42 | tmdb = TMDB(key=key) 43 | 44 | async with wait_for_background_task(): 45 | # pylint: disable=pointless-statement 46 | tmdb.shows 47 | self.assertGreater(len(tmdb.shows), 1) 48 | self.assertRegex(tmdb.shows[0].url, r'^https://www.themoviedb.org/tv/.+') 49 | 50 | class DWTest(TestCase): 51 | async def test_get_articles(self) -> None: 52 | async with wait_for_background_task(): 53 | # pylint: disable=pointless-statement 54 | self.bot.dw.articles 55 | self.assertGreater(len(self.bot.dw.articles), 1) 56 | self.assertRegex(self.bot.dw.articles[0].url, '^https://www.dw.com/en/') 57 | -------------------------------------------------------------------------------- /feini/__main__.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Open Feini script.""" 16 | 17 | import asyncio 18 | from asyncio import CancelledError, Task, current_task, get_running_loop 19 | from configparser import ConfigParser 20 | from importlib import resources 21 | import logging 22 | import signal 23 | import sys 24 | from typing import cast 25 | 26 | from .bot import Bot 27 | 28 | async def main() -> None: 29 | """Run Open Feini.""" 30 | loop = get_running_loop() 31 | task = cast(Task[None], current_task()) 32 | loop.add_signal_handler(signal.SIGINT, task.cancel) # type: ignore[misc] 33 | loop.add_signal_handler(signal.SIGTERM, task.cancel) # type: ignore[misc] 34 | 35 | logging.basicConfig(format='%(asctime)s %(levelname)s %(name)s: %(message)s', 36 | level=logging.INFO) 37 | 38 | config = ConfigParser() 39 | with (resources.files('feini.res') / 'default.ini').open() as f: 40 | config.read_file(f) 41 | config.read('fe.ini') 42 | redis_url = config.get('feini', 'redis_url') 43 | try: 44 | debug = config.getboolean('feini', 'debug') 45 | except ValueError: 46 | print('Configuration error: Bad [feini] debug type', file=sys.stderr) 47 | return 48 | telegram_key = config.get('telegram', 'key') or None 49 | tmdb_key = config.get('tmdb', 'key') or None 50 | try: 51 | bot = Bot(redis_url=redis_url, telegram_key=telegram_key, tmdb_key=tmdb_key, debug=debug) 52 | except ValueError: 53 | print(f'Configuration error: Bad [feini] redis_url {redis_url}', file=sys.stderr) 54 | return 55 | 56 | try: 57 | await bot.run() 58 | except CancelledError: 59 | await bot.close() 60 | raise 61 | 62 | try: 63 | asyncio.run(main()) 64 | except CancelledError: 65 | pass 66 | -------------------------------------------------------------------------------- /feini/tests/test_furniture.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from feini.furniture import Houseplant, Newspaper, Palette, Television, FURNITURE_MATERIAL 18 | from .test_bot import TestCase 19 | 20 | TRIALS = 1000 21 | 22 | class HouseplantTest(TestCase): 23 | async def test_tick(self) -> None: 24 | await self.space.obtain(*FURNITURE_MATERIAL['🪴']) 25 | plant = await self.space.craft('🪴') 26 | assert isinstance(plant, Houseplant) 27 | 28 | for time in range(TRIALS): 29 | await plant.tick(time) 30 | plant = await plant.get() 31 | if plant.state == '🌺': 32 | break 33 | else: 34 | self.fail() 35 | 36 | class TelevisionTest(TestCase): 37 | async def test_use(self) -> None: 38 | await self.space.obtain(*FURNITURE_MATERIAL['📺']) 39 | tv = await self.space.craft('📺') 40 | assert isinstance(tv, Television) 41 | await tv.use() 42 | tv = await tv.get() 43 | self.assertEqual(tv.show, self.bot.tmdb.shows[0]) 44 | 45 | class NewspaperTest(TestCase): 46 | async def test_use(self) -> None: 47 | await self.space.obtain(*FURNITURE_MATERIAL['🗞️']) 48 | newspaper = await self.space.craft('🗞️') 49 | assert isinstance(newspaper, Newspaper) 50 | await newspaper.use() 51 | newspaper = await newspaper.get() 52 | self.assertEqual(newspaper.article, self.bot.dw.articles[0]) 53 | 54 | class PaletteTest(TestCase): 55 | async def test_tick(self) -> None: 56 | await self.space.obtain(*FURNITURE_MATERIAL['🎨']) 57 | palette = await self.space.craft('🎨') 58 | assert isinstance(palette, Palette) 59 | 60 | for time in range(TRIALS): 61 | await palette.tick(time) 62 | palette = await palette.get() 63 | if palette.state == '🖼️': 64 | break 65 | else: 66 | self.fail() 67 | -------------------------------------------------------------------------------- /feini/tests/test_bot.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from asyncio import create_task 18 | from unittest import IsolatedAsyncioTestCase 19 | 20 | from feini import context 21 | from feini.actions import HikeMode 22 | from feini.bot import Bot 23 | from feini.space import Event, Hike 24 | from feini.util import cancel 25 | 26 | class TestCase(IsolatedAsyncioTestCase): 27 | """Open Feini test case. 28 | 29 | .. attribute:: bot 30 | 31 | Chatbot under test. 32 | 33 | .. attribute:: space 34 | 35 | Test space. 36 | 37 | .. attribute:: events 38 | 39 | Events that happened during the test. 40 | """ 41 | 42 | async def asyncSetUp(self) -> None: 43 | self.bot = Bot(redis_url='redis:15', debug=True) 44 | await self.bot.redis.flushdb() 45 | context.bot.set(self.bot) 46 | self.space = await self.bot.create_space('local') 47 | 48 | self.events: list[Event] = [] 49 | self._events_task = create_task(self._record_events()) 50 | 51 | async def asyncTearDown(self) -> None: 52 | await cancel(self._events_task) 53 | await self.bot.close() 54 | 55 | async def _record_events(self) -> None: 56 | async for event in self.bot.events(): 57 | self.events.append(event) 58 | 59 | class BotTest(TestCase): 60 | async def test_set_mode(self) -> None: 61 | mode = HikeMode(Hike(self.space)) 62 | self.bot.set_mode(self.space.chat, mode) 63 | self.assertIs(self.bot.get_mode(self.space.chat), mode) 64 | 65 | async def test_create_space(self) -> None: 66 | space = await self.bot.create_space('chat') 67 | pet = await space.get_pet() 68 | self.assertEqual(space.chat, 'chat') 69 | self.assertIn(space, await self.bot.get_spaces()) 70 | self.assertEqual(await self.bot.get_space(space.id), space) 71 | self.assertEqual(await self.bot.get_space_by_chat(space.chat), space) 72 | self.assertEqual(pet.space_id, space.id) 73 | self.assertTrue(await space.get_blueprints()) 74 | self.assertTrue(await space.get_stories()) 75 | -------------------------------------------------------------------------------- /scripts/material.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Show material distribution information.""" 16 | 17 | # pylint: disable=import-error,wrong-import-position 18 | 19 | from __future__ import annotations 20 | 21 | from pathlib import Path 22 | import sys 23 | sys.path.insert(0, str(Path(__file__).parent.parent)) 24 | 25 | from feini.space import Space 26 | from feini.furniture import FURNITURE_MATERIAL 27 | 28 | MATERIAL = ( 29 | {tool: material for tool, material in Space.TOOL_MATERIAL.items() if tool != '🪓'} | 30 | Space.CLOTHING_MATERIAL | 31 | {piece: material for piece, material in FURNITURE_MATERIAL.items() if piece != '🪃'}) 32 | INCOME = {'🪨': 1, '🪵': 1, '🧶': 1} 33 | TARGET_CRAFT_TIME = 2 34 | 35 | def print_metric(label: str, value: float, target: float | None = None) -> None: 36 | """Print a metric *value* with the given *label* and an optional *target* value.""" 37 | target_text = f'/ {target:.1f} ({value - target:+.1f})' if target else '' 38 | print(f'{label}: {value:.1f} {target_text}'.rstrip()) 39 | 40 | object_count = len(MATERIAL) 41 | total_income = sum(INCOME.values()) 42 | resources = [resource for resources in MATERIAL.values() for resource in resources] 43 | distribution = {resource: resources.count(resource) for resource in INCOME} 44 | total_resources = sum(distribution.values()) 45 | 46 | print_metric('Objects', object_count) 47 | print_metric('Income (1 / d)', total_income) 48 | for resource, income in INCOME.items(): 49 | print_metric(f' {resource}', income) 50 | print_metric('Total object resources', total_resources, 51 | total_income * TARGET_CRAFT_TIME * object_count) 52 | for resource, count in distribution.items(): 53 | print_metric(f' {resource}', count, INCOME[resource] * TARGET_CRAFT_TIME * object_count) 54 | print_metric('Average object resources', total_resources / object_count, 55 | total_income * TARGET_CRAFT_TIME) 56 | print_metric('Total object craft time (d)', total_resources / total_income, 57 | TARGET_CRAFT_TIME * object_count) 58 | print_metric('Average object craft time (d)', total_resources / total_income / object_count, 59 | TARGET_CRAFT_TIME) 60 | print_metric( 61 | 'Maximum object craft time (d)', 62 | max(count / INCOME[resource] / object_count for resource, count in distribution.items()), 63 | TARGET_CRAFT_TIME) 64 | -------------------------------------------------------------------------------- /feini/tests/test_util.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from asyncio import Task, create_task, sleep 18 | from string import ascii_lowercase 19 | from unittest import IsolatedAsyncioTestCase, TestCase 20 | 21 | from aiohttp import ClientResponseError, web 22 | from aiohttp.test_utils import AioHTTPTestCase 23 | from aiohttp.web import Application, HTTPNotImplemented, Request, Response 24 | 25 | from feini.util import JSONObject, cancel, collapse, isemoji, raise_for_status, randstr, truncate 26 | 27 | class RandstrTest(TestCase): 28 | def test(self) -> None: 29 | string = randstr() 30 | self.assertEqual(len(string), 16) 31 | self.assertLessEqual(set(string), set(ascii_lowercase)) # type: ignore[misc] 32 | 33 | class TruncateTest(TestCase): 34 | def test(self) -> None: 35 | self.assertEqual(truncate('Meow! Meow!', 5), 'Meow…') 36 | 37 | def test_short_text(self) -> None: 38 | self.assertEqual(truncate('Meow!', 5), 'Meow!') 39 | 40 | class IsEmojiTest(TestCase): 41 | def test(self) -> None: 42 | self.assertTrue(isemoji('⭐')) 43 | 44 | def test_presentation_selector(self) -> None: 45 | self.assertTrue(isemoji('⭐︎')) 46 | 47 | def test_letter(self) -> None: 48 | self.assertFalse(isemoji('A')) 49 | 50 | def test_string(self) -> None: 51 | self.assertFalse(isemoji('⭐A')) 52 | 53 | class CollapseTest(TestCase): 54 | def test(self) -> None: 55 | text = collapse('Meow, meow!\nMeow!␟') 56 | self.assertEqual(text, 'Meow, meow! Meow!') 57 | 58 | class CancelTest(IsolatedAsyncioTestCase): 59 | async def test(self) -> None: 60 | task: Task[None] = create_task(sleep(1)) 61 | await cancel(task) 62 | self.assertTrue(task.cancelled()) 63 | 64 | class RaiseForStatusTest(AioHTTPTestCase): 65 | @staticmethod 66 | async def index(request: Request) -> Response: 67 | raise HTTPNotImplemented(text='Not implemented') 68 | 69 | async def get_application(self) -> Application: 70 | app = Application() 71 | app.add_routes([web.get('/', self.index)]) 72 | return app 73 | 74 | async def test(self) -> None: 75 | response = await self.client.get('/') 76 | with self.assertRaisesRegex(ClientResponseError, 'Not implemented'): 77 | await raise_for_status(response) 78 | 79 | class JSONObjectTest(TestCase): 80 | def setUp(self) -> None: 81 | self.cat = JSONObject(name='Frank') 82 | 83 | def test_get(self) -> None: 84 | self.assertEqual(self.cat.get('name', cls=str), 'Frank') 85 | 86 | def test_get_bad_item_type(self) -> None: 87 | with self.assertRaisesRegex(TypeError, 'name'): 88 | self.cat.get('name', cls=int) 89 | -------------------------------------------------------------------------------- /feini/tests/test_actions.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from feini.furniture import FURNITURE_MATERIAL 18 | from feini.space import Space 19 | from .test_bot import TestCase 20 | 21 | class Test(TestCase): 22 | async def test(self) -> None: 23 | # Play with space 24 | reply = await self.bot.perform('local', '⛺') 25 | self.assertEqual(reply[0], '⛺') 26 | 27 | reply = await self.bot.perform('local', '🧺') 28 | self.assertEqual(reply[0], '🧺') 29 | 30 | reply = await self.bot.perform('local', '🪨') 31 | self.assertEqual(reply[0], '🪨') 32 | 33 | reply = await self.bot.perform('local', '🔨🪓') 34 | self.assertEqual(reply[0], '🔨') 35 | 36 | reply = await self.bot.perform('local', '🪓') 37 | self.assertEqual(reply[0], '🪓') 38 | 39 | await self.bot.perform('local', f"obtain 🪡{''.join(Space.CLOTHING_MATERIAL['🎀'])}") 40 | reply = await self.bot.perform('local', '🪡🎀') 41 | self.assertEqual(reply[0], '🪡') 42 | 43 | await self.bot.perform('local', 'obtain 🍳') 44 | reply = await self.bot.perform('local', '🍳') 45 | self.assertRegex(reply, '^🍳') 46 | 47 | # Play with pet 48 | reply = await self.bot.perform('local', '👋') 49 | self.assertEqual(reply[0], '🥚') 50 | 51 | await self.bot.perform('local', 'obtain 🥕') 52 | reply = await self.bot.perform('local', '🥕') 53 | self.assertEqual(reply[:2], '🥕🐕') 54 | 55 | reply = await self.bot.perform('local', '🧽') 56 | self.assertRegex(reply, '^🧽🐕') 57 | 58 | await self.bot.perform('local', 'obtain 🚿') 59 | reply = await self.bot.perform('local', '🚿') 60 | self.assertRegex(reply, '^🐕') 61 | 62 | reply = await self.bot.perform('local', '🎀') 63 | self.assertEqual(reply[:2], '🐕🎀') 64 | 65 | await self.bot.perform('local', 'obtain ✂️') 66 | reply = await self.bot.perform('local', '✂️') 67 | self.assertIn('later', reply) 68 | 69 | reply = await self.bot.perform('local', '✏️ Frank') 70 | self.assertEqual(reply[:3], '✏️🐕') 71 | 72 | # Play with furniture 73 | for piece, material in FURNITURE_MATERIAL.items(): 74 | await self.bot.perform('local', f"obtain {''.join(material)}") 75 | await self.bot.perform('local', f'🔨{piece}') 76 | 77 | reply = await self.bot.perform('local', '🪃') 78 | self.assertEqual(reply[0], '🪃') 79 | 80 | reply = await self.bot.perform('local', '⚾') 81 | self.assertEqual(reply[0], '⚾') 82 | 83 | reply = await self.bot.perform('local', '🧸') 84 | self.assertEqual(reply[0], '🧸') 85 | 86 | reply = await self.bot.perform('local', '🛋️') 87 | self.assertEqual(reply[:2], '🛋️') 88 | 89 | reply = await self.bot.perform('local', '🪴') 90 | self.assertEqual(reply[0], '🪴') 91 | 92 | reply = await self.bot.perform('local', '⛲') 93 | self.assertEqual(reply[0], '⛲') 94 | 95 | reply = await self.bot.perform('local', '📺') 96 | self.assertEqual(reply[0], '📺') 97 | 98 | reply = await self.bot.perform('local', '🗞️') 99 | self.assertEqual(reply[:2], '🗞️') 100 | 101 | reply = await self.bot.perform('local', '🎨') 102 | self.assertEqual(reply[0], '🎨') 103 | 104 | # Play with character 105 | reply = await self.bot.perform('local', '👻') 106 | self.assertIn('here', reply) 107 | -------------------------------------------------------------------------------- /feini/tests/test_stories.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from feini.stories import IntroStory, SewingStory 18 | from .test_bot import TestCase 19 | 20 | class IntroStoryTest(TestCase): 21 | async def asyncSetUp(self) -> None: 22 | await super().asyncSetUp() 23 | self.story = next(story for story in await self.space.get_stories() 24 | if isinstance(story, IntroStory)) 25 | 26 | async def test_tell(self) -> None: 27 | pet = await self.space.get_pet() 28 | 29 | self.bot.time += 1 30 | await self.story.tell() 31 | story = await self.story.get() 32 | self.assertEqual(story.chapter, 'touch') 33 | self.assertEqual(story.update_time, 1) 34 | self.assertTrue(self.events) 35 | self.assertEqual(self.events[-1].type, 'space-explain-touch') 36 | 37 | await pet.touch() 38 | await story.tell() 39 | story = await story.get() 40 | self.assertEqual(story.chapter, 'gather') 41 | self.assertEqual(self.events[-1].type, 'space-explain-gather') 42 | 43 | await self.space.gather() 44 | await story.tell() 45 | story = await story.get() 46 | self.assertEqual(story.chapter, 'feed') 47 | self.assertEqual(self.events[-1].type, 'space-explain-feed') 48 | 49 | await pet.feed('🥕') 50 | await story.tell() 51 | story = await story.get() 52 | self.assertEqual(story.chapter, 'craft') 53 | self.assertEqual(self.events[-1].type, 'space-explain-craft') 54 | 55 | await self.space.craft('🪓') 56 | await story.tell() 57 | self.assertNotIn(story, await self.space.get_stories()) 58 | self.assertEqual(self.events[-1].type, 'space-explain-basics') 59 | 60 | async def test_tell_unmet_condition(self) -> None: 61 | await self.story.tell() 62 | self.bot.time += 1 63 | await self.story.tell() 64 | story = await self.story.get() 65 | self.assertEqual(story.chapter, 'touch') 66 | self.assertEqual(story.update_time, 0) 67 | 68 | class SewingStoryTest(TestCase): 69 | async def test_tell(self) -> None: 70 | story = next(story for story in await self.space.get_stories() 71 | if isinstance(story, SewingStory)) 72 | 73 | await self.space.obtain('✂️') 74 | await story.tell() 75 | story = await story.get() 76 | self.assertEqual(story.chapter, 'visit') 77 | 78 | self.bot.time += 2 79 | await story.tell() 80 | story = await story.get() 81 | characters = await self.space.get_characters() 82 | self.assertEqual(len(characters), 1) 83 | ghost = characters[0] 84 | dialogue = await ghost.get_dialogue() 85 | self.assertEqual(story.chapter, 'quest') 86 | self.assertEqual(ghost.avatar, '👻') 87 | self.assertTrue(dialogue) 88 | self.assertEqual(dialogue[0].id, 'initial') 89 | self.assertTrue(self.events) 90 | self.assertEqual(self.events[0].type, 'space-visit-ghost') 91 | 92 | await ghost.talk() 93 | await ghost.talk() 94 | await ghost.talk() 95 | await self.space.obtain('🧶', '🧶', '🧶') 96 | await ghost.talk() 97 | await story.tell() 98 | story = await story.get() 99 | self.assertEqual(story.chapter, 'leave') 100 | self.assertIn('🪡', await self.space.get_blueprints()) 101 | 102 | await ghost.talk() 103 | await story.tell() 104 | self.assertNotIn(story, await self.space.get_stories()) 105 | self.assertFalse(await self.space.get_characters()) 106 | -------------------------------------------------------------------------------- /feini/stories.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Short stories.""" 16 | 17 | from . import context 18 | from .core import Entity 19 | from .space import Event, Message, Pet, Space 20 | from .util import randstr 21 | 22 | class Story(Entity): 23 | """Short story. 24 | 25 | .. attribute:: space_id 26 | 27 | Related :class:`Space` ID. 28 | 29 | .. attribute:: chapter 30 | 31 | Current point in the story. 32 | 33 | .. attribute:: update_time 34 | 35 | Tick the chapter was updated at. 36 | """ 37 | 38 | def __init__(self, data: dict[str, str]) -> None: 39 | super().__init__(data) 40 | self.space_id = data['space_id'] 41 | self.chapter = data['chapter'] 42 | self.update_time = int(data['update_time']) 43 | 44 | async def get_space(self) -> Space: 45 | """Get the related space.""" 46 | return await context.bot.get().get_space(self.space_id) 47 | 48 | async def tell(self) -> None: 49 | """Continue to the next point in the story if the relevant conditions are met.""" 50 | raise NotImplementedError() 51 | 52 | class IntroStory(Story): 53 | """Tutorial.""" 54 | 55 | async def tell(self) -> None: 56 | bot = context.bot.get() 57 | async with bot.redis.pipeline() as pipe: 58 | await pipe.watch(self.id) 59 | chapter = await pipe.hget(self.id, 'chapter') 60 | if not chapter: 61 | raise ReferenceError(self.id) 62 | values = await pipe.hmget(self.space_id, 'resources', 'tools', 'pet_id') 63 | items = (values[0] or '').split() 64 | tools = (values[1] or '').split() 65 | pet_id = values[2] 66 | assert pet_id 67 | values = await pipe.hmget(pet_id, 'hatched', 'nutrition') 68 | hatched = bool(values[0]) 69 | nutrition = int(values[1] or '') 70 | 71 | pipe.multi() 72 | if chapter == 'start': 73 | pipe.hset(self.id, mapping={'chapter': 'touch', 'update_time': bot.time}) 74 | pipe.rpush('events', str(Event('space-explain-touch', self.space_id))) 75 | elif chapter == 'touch' and hatched: 76 | pipe.hset(self.id, mapping={'chapter': 'gather', 'update_time': bot.time}) 77 | pipe.rpush('events', str(Event('space-explain-gather', self.space_id))) 78 | elif chapter == 'gather' and '🥕' in items: 79 | pipe.hset(self.id, mapping={'chapter': 'feed', 'update_time': bot.time}) 80 | pipe.rpush('events', str(Event('space-explain-feed', self.space_id))) 81 | elif chapter == 'feed' and nutrition >= Pet.NUTRITION_MAX: 82 | pipe.hset(self.id, mapping={'chapter': 'craft', 'update_time': bot.time}) 83 | pipe.rpush('events', str(Event('space-explain-craft', self.space_id))) 84 | elif chapter == 'craft' and '🪓' in tools: 85 | pipe.srem(f'{self.space_id}.stories', self.id) 86 | pipe.rpush('events', str(Event('space-explain-basics', self.space_id))) 87 | await pipe.execute() 88 | 89 | class SewingStory(Story): 90 | """Story about sewing.""" 91 | 92 | async def tell(self) -> None: 93 | bot = context.bot.get() 94 | async with bot.redis.pipeline() as pipe: 95 | await pipe.watch(self.id) 96 | values = await pipe.hmget(self.id, 'chapter', 'update_time') 97 | if not values: 98 | raise ReferenceError(self.id) 99 | chapter = values[0] 100 | update_time = int(values[1] or '') 101 | tools = (await pipe.hget(self.space_id, 'tools') or '').split() 102 | character_ids = await pipe.lrange(f'{self.space_id}.characters', 0, -1) 103 | character_ids = [character_id for character_id in character_ids 104 | if await pipe.hget(character_id, 'avatar') == '👻'] 105 | character_id = next(iter(character_ids), None) 106 | message = None 107 | if character_id: 108 | message = Message.parse((await pipe.lrange(f'{character_id}.dialogue', 0, 0))[0]) 109 | 110 | pipe.multi() 111 | if chapter == 'scissors' and '✂️' in tools: 112 | pipe.hset(self.id, mapping={'chapter': 'visit', 'update_time': bot.time}) 113 | elif chapter == 'visit' and bot.time >= update_time + 2: 114 | character_id = f'Character:{randstr()}' 115 | pipe.hset(character_id, 116 | mapping={'id': character_id, 'space_id': self.space_id, 'avatar': '👻'}) 117 | dialogue = [ 118 | Message('initial'), 119 | Message('ghost-sewing-hello'), 120 | Message('ghost-sewing-daughter'), 121 | Message('ghost-sewing-request', request=['🧶', '🧶', '🧶']), 122 | Message('ghost-sewing-blueprint'), 123 | Message('ghost-sewing-goodbye') 124 | ] 125 | pipe.rpush(f'{character_id}.dialogue', *(message.encode() for message in dialogue)) 126 | pipe.rpush(f'{self.space_id}.characters', character_id) 127 | pipe.hset(self.id, mapping={'chapter': 'quest', 'update_time': bot.time}) 128 | pipe.rpush('events', str(Event('space-visit-ghost', self.space_id))) 129 | elif (chapter == 'quest' and message and 130 | message.id in {'ghost-sewing-blueprint', 'ghost-sewing-goodbye'}): 131 | pipe.zadd(f'{self.space_id}.blueprints', {'🪡': Space.BLUEPRINT_WEIGHTS['🪡']}) 132 | pipe.hset(self.id, mapping={'chapter': 'leave', 'update_time': bot.time}) 133 | elif chapter == 'leave' and message and message.id == 'ghost-sewing-goodbye': 134 | assert character_id 135 | pipe.delete(character_id, f'{character_id}.dialogue') 136 | pipe.lrem(f'{self.space_id}.characters', 1, character_id) 137 | pipe.srem(f'{self.space_id}.stories', self.id) 138 | await pipe.execute() 139 | -------------------------------------------------------------------------------- /feini/updates.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Database updates, ordered from latest to earliest.""" 16 | 17 | # Note that updates are applied before the bot is started, thus there are no race conditions. 18 | 19 | # pylint: disable=missing-function-docstring 20 | 21 | from logging import getLogger 22 | import random 23 | 24 | from . import context 25 | from .furniture import Content 26 | from .space import Event, Space 27 | from .util import randstr 28 | 29 | async def update_event_format() -> None: 30 | updates = 0 31 | redis = context.bot.get().redis 32 | events = await redis.lrange('events', 0, -1) 33 | for i, data in enumerate(events): 34 | if '␟' not in data: 35 | typ, space_id = data.split() 36 | await redis.lset('events', i, str(Event(typ, space_id))) 37 | updates += 1 38 | if updates: 39 | getLogger(__name__).info('Updated Event format (%d)', updates) 40 | 41 | async def update_content_url() -> None: 42 | updates = 0 43 | bot = context.bot.get() 44 | redis = bot.redis 45 | for space_id in await redis.hvals('spaces_by_chat'): 46 | for furniture_id in await redis.lrange(f'{space_id}.items', 0, -1): 47 | show, article = await redis.hmget(furniture_id, 'show', 'article') 48 | if show: 49 | try: 50 | Content.parse(show) 51 | except ValueError: 52 | await redis.hset(furniture_id, 'show', str(random.choice(bot.tmdb.shows))) 53 | updates += 1 54 | elif article: 55 | try: 56 | Content.parse(article) 57 | except ValueError: 58 | await redis.hset(furniture_id, 'article', str(random.choice(bot.dw.articles))) 59 | updates += 1 60 | if updates: 61 | getLogger(__name__).info('Updated Content.url (%d)', updates) 62 | 63 | async def update_pet_name() -> None: 64 | updates = 0 65 | redis = context.bot.get().redis 66 | for space_id in await redis.hvals('spaces_by_chat'): 67 | if await redis.hexists(space_id, 'pet_name'): 68 | attrs = ('pet_name', 'pet_is_egg', 'pet_nutrition', 'pet_fur', 'pet_activity_id') 69 | values = await redis.hmget(space_id, 'tools', 'pet_id', *attrs) 70 | tools = (values[0] or '').split() 71 | pet_id = values[1] 72 | assert pet_id 73 | name, is_egg, nutrition, fur, activity_id = values[2:] 74 | assert name and is_egg is not None and nutrition and fur and activity_id is not None 75 | 76 | async with redis.pipeline() as pipe: 77 | pipe.multi() 78 | pipe.hset(pet_id, mapping={ 79 | 'name': name, 80 | 'hatched': 'true' if not bool(is_egg) else '', 81 | 'nutrition': nutrition, 82 | 'fur': fur, 83 | 'activity_id': activity_id 84 | }) 85 | pipe.hdel(space_id, *attrs) 86 | if '🍳' in tools: 87 | pipe.rpush('events', f'space-update-pan {space_id}') 88 | if '🚿' in tools: 89 | pipe.rpush('events', f'space-update-shower {space_id}') 90 | await pipe.execute() 91 | updates += 1 92 | if updates: 93 | getLogger(__name__).info('Updated Pet.name (%d)', updates) 94 | 95 | async def update_space_stories() -> None: 96 | updates = 0 97 | bot = context.bot.get() 98 | redis = bot.redis 99 | for space_id in await redis.hvals('spaces_by_chat'): 100 | chapter = await redis.hget(space_id, 'story') 101 | if chapter is not None: 102 | async with redis.pipeline() as pipe: 103 | pipe.multi() 104 | stories = [{ 105 | 'id': f'SewingStory:{randstr()}', 106 | 'space_id': space_id, 107 | 'chapter': 'scissors', 108 | 'update_time': str(bot.time) 109 | }] 110 | if chapter: 111 | stories.append({ 112 | 'id': f'IntroStory:{randstr()}', 113 | 'space_id': space_id, 114 | 'chapter': chapter, 115 | 'update_time': str(bot.time) 116 | }) 117 | for story in stories: 118 | pipe.hset(story['id'], mapping=story) 119 | pipe.sadd(f'{space_id}.stories', story['id']) 120 | pipe.hdel(space_id, 'story') 121 | await pipe.execute() 122 | updates += 1 123 | if updates: 124 | getLogger(__name__).info('Updated Space.stories (%d)', updates) 125 | 126 | async def update_space_blueprints() -> None: 127 | updates = 0 128 | redis = context.bot.get().redis 129 | blueprints = { 130 | blueprint: 131 | Space.BLUEPRINT_WEIGHTS[blueprint] 132 | for blueprint 133 | in ['🪓', '✂️', '🍳', '🚿', '🧭', '🪃', '⚾', '🧸', '🛋️', '🪴', '⛲', '📺', '🗞️', '🎨'] 134 | } 135 | for space_id in await redis.hvals('spaces_by_chat'): 136 | key = f'{space_id}.blueprints' 137 | if not await redis.exists(key): 138 | await redis.zadd(key, blueprints) 139 | updates += 1 140 | if updates: 141 | getLogger(__name__).info('Updated Space.blueprints (%d)', updates) 142 | 143 | async def update_pet_clothing() -> None: 144 | updates = 0 145 | redis = context.bot.get().redis 146 | for space_id in await redis.hvals('spaces_by_chat'): 147 | pet_id = await redis.hget(space_id, 'pet_id') or '' 148 | if not await redis.hexists(pet_id, 'clothing'): 149 | await redis.hset(pet_id, 'clothing', '') 150 | updates += 1 151 | if updates: 152 | getLogger(__name__).info('Updated Pet.clothing (%d)', updates) 153 | 154 | async def update_space_trail_supply() -> None: 155 | updates = 0 156 | bot = context.bot.get() 157 | for space_id in await bot.redis.hvals('spaces_by_chat'): 158 | if not await bot.redis.hexists(space_id, 'trail_supply'): 159 | async with bot.redis.pipeline() as pipe: 160 | pipe.hset(space_id, 'trail_supply', Space.TRAIL_SUPPLY_MAX) 161 | pipe.rpush('events', f'space-stroll-compass-blueprint {space_id}') 162 | await pipe.execute() 163 | updates += 1 164 | if updates: 165 | getLogger(__name__).info('Updated Space.trail_supply (%d)', updates) 166 | 167 | async def update_pet_dirt() -> None: 168 | bot = context.bot.get() 169 | updates = 0 170 | space_ids = await bot.redis.hvals('spaces_by_chat') 171 | for space_id in space_ids: 172 | tools = (await bot.redis.hget(space_id, 'tools') or '').split() 173 | if '🧽' not in tools: 174 | async with bot.redis.pipeline() as pipe: 175 | tools.insert(4, '🧽') 176 | pet_data = {'id': f'Pet:{randstr()}', 'space_id': space_id, 'dirt': '0'} 177 | pipe.hset(space_id, mapping={'tools': ' '.join(tools), 'pet_id': pet_data['id']}) 178 | pipe.hset(pet_data['id'], mapping=pet_data) 179 | pipe.rpush('events', f'space-stroll-sponge {space_id}') 180 | await pipe.execute() 181 | updates += 1 182 | if updates: 183 | getLogger(__name__).info('Updated Pet.dirt (%d)', updates) 184 | -------------------------------------------------------------------------------- /feini/util.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Various utilities.""" 16 | 17 | from __future__ import annotations 18 | 19 | from asyncio import CancelledError, Task 20 | from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence 21 | from contextlib import contextmanager 22 | import random 23 | import re 24 | from string import ascii_lowercase 25 | import sys 26 | from typing import Literal, Type, TypeVar, overload 27 | import unicodedata 28 | 29 | from aiohttp import ClientResponse, ClientResponseError 30 | import redis.asyncio.client 31 | from redis.typing import AnyFieldT, AnyKeyT, EncodableT, FieldT, KeyT, KeysT, TimeoutSecT 32 | 33 | _T = TypeVar('_T') 34 | 35 | def randstr(length: int = 16, *, charset: str = ascii_lowercase) -> str: 36 | """Generate a random string. 37 | 38 | The string will have the given *length* and consist of characters from *charset*. 39 | """ 40 | return ''.join(random.choice(charset) for _ in range(length)) 41 | 42 | def truncate(text: str, length: int = 16) -> str: 43 | """Truncate *text* at *length*. 44 | 45 | A truncated text ends with an ellipsis character. 46 | """ 47 | return f'{text[:length - 1]}…' if len(text) > length else text 48 | 49 | def collapse(text: str) -> str: 50 | """Collapse sequences of white space characters in *text*. 51 | 52 | ASCII delimiters are considered white space. 53 | """ 54 | return re.sub(r'[\s␜-␟]+', ' ', text).strip() 55 | 56 | def isemoji(char: str) -> bool: 57 | """Guess if *char* is an emoji. 58 | 59 | True if the character is categorized as other symbol, with an optional presentation selector. 60 | """ 61 | return ( 62 | 1 <= len(char) <= 2 and unicodedata.category(char[0]) == 'So' and 63 | (len(char) == 1 or char[1] in '\N{VARIATION SELECTOR-15}\N{VARIATION SELECTOR-16}')) 64 | 65 | async def cancel(task: Task[_T]) -> None: 66 | """Cancel the *task*.""" 67 | task.cancel() 68 | try: 69 | await task 70 | except CancelledError: 71 | pass 72 | 73 | async def raise_for_status(response: ClientResponse) -> None: 74 | """Raise a ClientResponseError if the *response* status is 400 or higher. 75 | 76 | The server error message is included. 77 | """ 78 | if not response.ok: 79 | message = truncate(re.sub(r'\s+', ' ', await response.text()), 1024) 80 | raise ClientResponseError(response.request_info, response.history, status=response.status, 81 | message=message, headers=response.headers) 82 | 83 | @contextmanager 84 | def recovery() -> Iterator[None]: 85 | """Context manager which recovers from unhandled exceptions in the block. 86 | 87 | Conceptionally, the block is executed on its own stack, without the overhead of creating a 88 | thread or task. 89 | """ 90 | # pylint: disable=broad-except 91 | try: 92 | yield 93 | except Exception: 94 | sys.excepthook(*sys.exc_info()) 95 | 96 | class JSONObject(dict[str, object]): 97 | """JSON object providing type safe member access.""" 98 | 99 | @overload 100 | def get(self, key: str, default: object = None, *, cls: None = None) -> object: 101 | pass 102 | @overload 103 | def get(self, key: str, *, cls: Type[_T]) -> _T: 104 | pass 105 | @overload 106 | def get(self, key: str, default: _T, *, cls: Type[_T]) -> _T: 107 | pass 108 | def get(self, key: str, default: object = None, *, # type: ignore[misc] 109 | cls: Type[_T] | None = None) -> object: 110 | """Return the value for *key*. 111 | 112 | *cls* is the value's expected type and a :exc:`TypeError` is raised if validation fails. 113 | """ 114 | value = super().get(key, default) 115 | if cls and not isinstance(value, cls): 116 | raise TypeError(f'Bad {key} type {type(value).__name__}') 117 | return value 118 | 119 | class Redis(redis.asyncio.client.Redis): 120 | """Supplemented Redis client type annotations.""" 121 | 122 | # pylint: disable=multiple-statements 123 | 124 | def pipeline(self, transaction: bool = ..., shard_hint: str | None = ...) -> Pipeline: ... 125 | 126 | @overload # type: ignore[override] 127 | def blpop(self, keys: KeysT) -> Awaitable[tuple[str, str]]: ... 128 | @overload 129 | def blpop(self, keys: KeysT, timeout: Literal[0]) -> Awaitable[tuple[str, str]]: ... 130 | @overload 131 | def blpop(self, keys: KeysT, timeout: TimeoutSecT) -> Awaitable[tuple[str, str] | None]: ... 132 | def blpop(self, keys: KeysT, 133 | timeout: TimeoutSecT = ...) -> Awaitable[tuple[str, str] | None]: ... 134 | 135 | def exists(self, *names: KeyT) -> Awaitable[int]: ... 136 | def flushdb(self, asynchronous: bool = ..., **kwargs: object) -> Awaitable[bool]: ... 137 | def hexists(self, name: KeyT, key: FieldT) -> Awaitable[bool]: ... 138 | def hget(self, name: KeyT, key: FieldT) -> Awaitable[str | None]: ... 139 | def hgetall(self, name: KeyT) -> Awaitable[dict[str, str]]: ... 140 | def hmget(self, name: KeyT, keys: Sequence[KeyT], # type: ignore[override] 141 | *args: FieldT) -> Awaitable[list[str | None]]: ... 142 | def hset( 143 | self, name: KeyT, key: FieldT | None = ..., value: EncodableT | None = ..., 144 | mapping: Mapping[AnyFieldT, EncodableT] | None = ..., 145 | items: Sequence[tuple[AnyFieldT, EncodableT]] | None = ...) -> Awaitable[int]: ... 146 | def hvals(self, name: KeyT) -> Awaitable[list[str]]: ... 147 | def lrange(self, name: KeyT, start: int, end: int) -> Awaitable[list[str]]: ... 148 | def lset(self, name: KeyT, index: int, value: EncodableT) -> Awaitable[str]: ... 149 | def smembers(self, name: KeyT) -> Awaitable[set[str]]: ... 150 | def zadd( 151 | self, name: KeyT, mapping: Mapping[AnyKeyT, EncodableT], nx: bool = ..., xx: bool = ..., 152 | ch: bool = ..., incr: bool = ..., gt: bool = ..., 153 | lt: bool = ...) -> Awaitable[int | float | None]: ... 154 | 155 | @overload # type: ignore[override] 156 | def zrange(self, name: KeyT, start: int, end: int, desc: bool, 157 | withscores: Literal[True]) -> Awaitable[list[tuple[str, float]]]: ... 158 | @overload 159 | def zrange(self, name: KeyT, start: int, end: int, desc: bool, withscores: Literal[True], 160 | score_cast_func: Callable[[str], _T]) -> Awaitable[list[tuple[str, _T]]]: ... 161 | @overload 162 | def zrange(self, name: KeyT, start: int, end: int, desc: bool = ..., *, 163 | withscores: Literal[True]) -> Awaitable[list[tuple[str, float]]]: ... 164 | @overload 165 | def zrange( 166 | self, name: KeyT, start: int, end: int, desc: bool = ..., *, withscores: Literal[True], 167 | score_cast_func: Callable[[str], _T]) -> Awaitable[list[tuple[str, _T]]]: ... 168 | @overload 169 | def zrange( 170 | self, name: KeyT, start: int, end: int, desc: bool = ..., withscores: Literal[False] = ..., 171 | score_cast_func: Callable[[str], _T] = ... 172 | ) -> Awaitable[list[str]]: ... 173 | def zrange( 174 | self, name: KeyT, start: int, end: int, desc: bool = ..., withscores: bool = ..., 175 | score_cast_func: Callable[[str], _T] = ... 176 | ) -> Awaitable[list[str] | list[tuple[str, _T]]]: ... 177 | 178 | def zscore(self, name: KeyT, value: EncodableT) -> Awaitable[float | None]: ... 179 | 180 | class Pipeline(redis.asyncio.client.Pipeline, Redis): # type: ignore[misc] 181 | """Supplemented Redis pipeline type annotations.""" 182 | 183 | # pylint: disable=multiple-statements 184 | 185 | def multi(self) -> None: ... 186 | async def execute(self, raise_on_error: bool = True) -> list[object]: ... 187 | async def watch(self, *names: KeyT) -> None: ... # type: ignore[override] 188 | -------------------------------------------------------------------------------- /feini/tests/test_space.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | # pylint: disable=missing-docstring 16 | 17 | from itertools import cycle, islice 18 | 19 | from feini.furniture import Houseplant, FURNITURE_MATERIAL 20 | from feini.space import Hike, Pet, Space 21 | from feini.stories import SewingStory 22 | from .test_bot import TestCase 23 | 24 | class SpaceTest(TestCase): 25 | async def test_tick(self) -> None: 26 | await self.space.tick(0) 27 | space = await self.space.get() 28 | pet = await space.get_pet() 29 | self.assertEqual(space.time, 1) 30 | self.assertEqual(space.meadow_vegetable_growth, Space.MEADOW_VEGETABLE_GROWTH_MAX + 1) 31 | self.assertEqual(space.woods_growth, Space.WOODS_GROWTH_MAX + 1) 32 | self.assertEqual(space.trail_supply, Space.TRAIL_SUPPLY_MAX + 1) 33 | self.assertEqual(pet.nutrition, (8 - 1) - 1) 34 | 35 | async def test_obtain(self) -> None: 36 | await self.space.obtain('🪵', '🧶', '🥕') 37 | await self.space.obtain('🪵') 38 | space = await self.space.get() 39 | self.assertEqual(space.items, ['🥕', '🪵', '🪵', '🧶']) # type: ignore[misc] 40 | 41 | async def test_gather(self) -> None: 42 | resources = await self.space.gather() 43 | space = await self.space.get() 44 | self.assertEqual(resources, ['🥕', '🪨']) # type: ignore[misc] 45 | self.assertEqual(space.items, resources) 46 | self.assertEqual(space.meadow_vegetable_growth, 0) 47 | 48 | async def test_gather_immature_vegetable(self) -> None: 49 | await self.space.gather() 50 | resources = await self.space.gather() 51 | self.assertFalse(resources) 52 | 53 | async def test_chop_wood(self) -> None: 54 | await self.space.obtain('🪓', '🥕') 55 | wood = await self.space.chop_wood() 56 | space = await self.space.get() 57 | self.assertEqual(wood, ['🪵']) # type: ignore[misc] 58 | self.assertEqual(space.items, ['🥕', '🪵']) # type: ignore[misc] 59 | self.assertEqual(space.woods_growth, 0) 60 | 61 | async def test_chop_wood_immature_wood(self) -> None: 62 | await self.space.obtain('🪓') 63 | await self.space.chop_wood() 64 | wood = await self.space.chop_wood() 65 | self.assertFalse(wood) 66 | 67 | async def test_craft(self) -> None: 68 | await self.space.obtain(*Space.TOOL_MATERIAL['🪓'], '🥕') 69 | axe = await self.space.craft('🪓') 70 | space = await self.space.get() 71 | self.assertEqual(axe, '🪓') 72 | self.assertEqual(space.tools, [*self.space.tools, '🪓']) # type: ignore[misc] 73 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 74 | 75 | async def test_craft_furniture_item(self) -> None: 76 | await self.space.obtain(*FURNITURE_MATERIAL['🪴'], '🥕') 77 | plant = await self.space.craft('🪴') 78 | space = await self.space.get() 79 | assert isinstance(plant, Houseplant) 80 | self.assertEqual(await space.get_furniture(), [plant]) # type: ignore[misc] 81 | self.assertEqual(await self.bot.get_furniture_item(plant.id), plant) 82 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 83 | 84 | async def test_craft_unknown_blueprint(self) -> None: 85 | with self.assertRaisesRegex(ValueError, 'blueprint'): 86 | await self.space.craft('🪡') 87 | 88 | async def test_craft_no_material(self) -> None: 89 | with self.assertRaisesRegex(ValueError, 'items'): 90 | await self.space.craft('🪴') 91 | 92 | async def test_sew(self) -> None: 93 | await self.space.obtain('🪡', *Space.CLOTHING_MATERIAL['🎀'], '🥕') 94 | ribbon = await self.space.sew('🎀') 95 | space = await self.space.get() 96 | self.assertEqual(ribbon, '🎀') 97 | self.assertEqual(space.items, ['🥕', '🎀']) # type: ignore[misc] 98 | 99 | async def test_sew_no_material(self) -> None: 100 | await self.space.obtain('🪡') 101 | with self.assertRaisesRegex(ValueError, 'items'): 102 | await self.space.sew('🎀') 103 | 104 | async def test_cook(self) -> None: 105 | await self.space.obtain('🥕', '🥕') 106 | dish = await self.space.cook() 107 | space = await self.space.get() 108 | self.assertEqual(dish, '🍲') 109 | self.assertEqual(space.items, ['🥕', '🍲']) # type: ignore[misc] 110 | 111 | async def test_cook_no_ingredients(self) -> None: 112 | with self.assertRaisesRegex(ValueError, 'items'): 113 | await self.space.cook() 114 | 115 | class PetTest(TestCase): 116 | TRIALS = 1000 117 | 118 | async def asyncSetUp(self) -> None: 119 | await super().asyncSetUp() 120 | self.pet = await self.space.get_pet() 121 | 122 | async def test_tick(self) -> None: 123 | await self.pet.tick() 124 | pet = await self.pet.get() 125 | self.assertEqual(pet.nutrition, (8 - 1) - 1) 126 | self.assertEqual(pet.dirt, Pet.DIRT_MAX - (8 - 1) + 1) 127 | self.assertEqual(pet.fur, 1) 128 | 129 | async def test_tick_later_time(self) -> None: 130 | await self.space.obtain(*FURNITURE_MATERIAL['🪴']) 131 | await self.space.craft('🪴') 132 | for _ in range(7): 133 | await self.pet.tick() 134 | self.assertEqual(len(self.events), 2) 135 | self.assertEqual(self.events[0].type, 'pet-hungry') 136 | self.assertEqual(self.events[1].type, 'pet-dirty') 137 | 138 | for _ in range(self.TRIALS): 139 | pet = await self.pet.get() 140 | if pet.activity_id != '': 141 | break 142 | await self.pet.tick() 143 | else: 144 | self.fail() 145 | 146 | async def test_touch(self) -> None: 147 | await self.pet.touch() 148 | pet = await self.pet.get() 149 | self.assertTrue(pet.hatched) 150 | 151 | async def test_feed(self) -> None: 152 | await self.space.obtain('🥕', '🥕') 153 | await self.pet.feed('🥕') 154 | pet = await self.pet.get() 155 | space = await self.space.get() 156 | self.assertEqual(pet.nutrition, Pet.NUTRITION_MAX) 157 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 158 | 159 | async def test_feed_full_pet(self) -> None: 160 | await self.space.obtain('🥕') 161 | await self.pet.feed('🥕') 162 | with self.assertRaisesRegex(ValueError, 'nutrition'): 163 | await self.pet.feed('🥕') 164 | 165 | async def test_feed_no_vegetable(self) -> None: 166 | with self.assertRaisesRegex(ValueError, 'items'): 167 | await self.pet.feed('🥕') 168 | 169 | async def test_wash(self) -> None: 170 | await self.pet.wash() 171 | pet = await self.pet.get() 172 | self.assertEqual(pet.dirt, 0) 173 | 174 | async def test_wash_clean_pet(self) -> None: 175 | await self.pet.wash() 176 | with self.assertRaisesRegex(ValueError, 'dirt'): 177 | await self.pet.wash() 178 | 179 | async def test_dress(self) -> None: 180 | await self.space.obtain('🎀', '🥕') 181 | await self.pet.dress('🎀') 182 | pet = await self.pet.get() 183 | space = await self.space.get() 184 | self.assertEqual(pet.clothing, '🎀') 185 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 186 | 187 | async def test_dress_no_clothing(self) -> None: 188 | await self.space.obtain('🎀', '🥕') 189 | await self.pet.dress('🎀') 190 | await self.pet.dress(None) 191 | pet = await self.pet.get() 192 | space = await self.space.get() 193 | self.assertIsNone(pet.clothing) 194 | self.assertEqual(space.items, ['🥕', '🎀']) # type: ignore[misc] 195 | 196 | async def test_shear(self) -> None: 197 | await self.space.obtain('✂️', '🥕') 198 | for tick in range(Pet.FUR_MAX): 199 | await self.space.tick(tick) 200 | wool = await self.pet.shear() 201 | pet = await self.pet.get() 202 | space = await self.space.get() 203 | self.assertEqual(wool, ['🧶']) # type: ignore[misc] 204 | self.assertEqual(pet.fur, 0) 205 | self.assertEqual(space.items, ['🥕', '🧶']) # type: ignore[misc] 206 | 207 | async def test_shear_immature_fur(self) -> None: 208 | await self.space.obtain('✂️') 209 | wool = await self.pet.shear() 210 | self.assertFalse(wool) 211 | 212 | async def test_change_name(self) -> None: 213 | await self.pet.change_name('Frank ') 214 | pet = await self.pet.get() 215 | self.assertEqual(pet.name, 'Frank') 216 | 217 | async def test_engage(self) -> None: 218 | await self.space.obtain(*FURNITURE_MATERIAL['🪴']) 219 | plant = await self.space.craft('🪴') 220 | await self.pet.engage(plant) 221 | pet = await self.pet.get() 222 | activity = await pet.get_activity() 223 | self.assertEqual(activity, plant) 224 | 225 | async def test_engage_standalone_activity(self) -> None: 226 | await self.pet.engage('🍃') 227 | pet = await self.pet.get() 228 | activity = await pet.get_activity() 229 | self.assertEqual(activity, '🍃') 230 | 231 | class CharacterTest(TestCase): 232 | async def test_talk(self) -> None: 233 | story = next(story for story in await self.space.get_stories() 234 | if isinstance(story, SewingStory)) 235 | await self.space.obtain('✂️', '🥕') 236 | await story.tell() 237 | self.bot.time += 2 238 | await story.tell() 239 | character = next(iter(await self.space.get_characters())) 240 | 241 | message = await character.talk() 242 | dialogue = await character.get_dialogue() 243 | self.assertEqual(message.id, 'ghost-sewing-hello') 244 | self.assertEqual(dialogue[0], message) 245 | 246 | await character.talk() 247 | await character.talk() 248 | message = await character.talk() 249 | space = await self.space.get() 250 | self.assertEqual(message.id, 'ghost-sewing-request') 251 | self.assertFalse(message.taken) 252 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 253 | 254 | await space.obtain('🧶', '🧶', '🧶') 255 | message = await character.talk() 256 | space = await self.space.get() 257 | self.assertEqual(message.id, 'ghost-sewing-blueprint') 258 | self.assertEqual(message.taken, ['🧶', '🧶', '🧶']) # type: ignore[misc] 259 | self.assertEqual(space.items, ['🥕']) # type: ignore[misc] 260 | 261 | await character.talk() 262 | message = await character.talk() 263 | self.assertEqual(message.id, 'ghost-sewing-goodbye') 264 | 265 | class HikeTest(TestCase): 266 | async def asyncSetUp(self) -> None: 267 | await super().asyncSetUp() 268 | await self.space.obtain('🧭') 269 | self.hike = await self.space.hike() 270 | 271 | @staticmethod 272 | def pad_directions(directions: list[str]) -> list[str]: 273 | direction = directions[-1] 274 | reverse = {'➡️': '⬅️', '⬇️': '⬆️', '⬅️': '➡️', '⬆️': '⬇️'}[direction] 275 | return directions + list(islice(cycle((reverse, direction)), Hike.RADIUS - len(directions))) 276 | 277 | async def test_move(self) -> None: 278 | directions = self.pad_directions(self.hike.find_path('🟩')) 279 | move = await self.hike.move(directions) 280 | self.assertEqual(move, list(zip(directions, ['🟩', '✴️', '🟩', '✴️']))) # type: ignore[misc] 281 | self.assertFalse(self.hike.finished) 282 | 283 | async def test_move_destination(self) -> None: 284 | assert self.hike.resource 285 | await self.hike.move(self.pad_directions(self.hike.find_path(self.hike.resource))) 286 | await self.hike.move(self.hike.find_path('📍')) 287 | space = await self.space.get() 288 | self.assertTrue(self.hike.finished) 289 | self.assertEqual(self.hike.gathered, [self.hike.resource]) # type: ignore[misc] 290 | self.assertEqual(space.items, [self.hike.resource]) # type: ignore[misc] 291 | self.assertEqual(space.trail_supply, 0) 292 | 293 | async def test_move_destination_empty_space_trail_supply(self) -> None: 294 | assert self.hike.resource 295 | await self.hike.move(self.pad_directions(self.hike.find_path(self.hike.resource))) 296 | await self.hike.move(self.hike.find_path('📍')) 297 | hike = await self.space.hike() 298 | 299 | await hike.move(hike.find_path('📍')) 300 | space = await self.space.get() 301 | self.assertFalse(hike.gathered) 302 | self.assertFalse(space.items[1:]) 303 | self.assertEqual(space.trail_supply, 0) 304 | 305 | async def test_move_bad_directions_length(self) -> None: 306 | with self.assertRaisesRegex(ValueError, 'directions'): 307 | await self.hike.move([]) 308 | -------------------------------------------------------------------------------- /feini/furniture.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Available furniture. 16 | 17 | .. data:: FURNITURE_MATERIAL 18 | 19 | Material needed for each furniture item. 20 | 21 | .. data:: FURNITURE_TYPES 22 | 23 | Furniture classes. 24 | """ 25 | 26 | from __future__ import annotations 27 | 28 | import asyncio 29 | from asyncio import Task, create_task 30 | from collections.abc import Awaitable, Callable 31 | from datetime import datetime, timedelta 32 | from dataclasses import dataclass 33 | from logging import getLogger 34 | from functools import partial 35 | import json 36 | from json import JSONDecodeError 37 | import random 38 | from typing import cast 39 | from xml.sax import SAXParseException 40 | from urllib.parse import urlsplit 41 | 42 | from aiohttp import ClientError 43 | import feedparser 44 | from feedparser import ThingsNobodyCaresAboutButMe 45 | 46 | from . import context 47 | from .core import Entity 48 | from .util import JSONObject, cancel, collapse, raise_for_status 49 | 50 | FURNITURE_MATERIAL = { 51 | # Toys 52 | '🪃': ['🪵', '🪵'], # S 53 | '⚾': ['🪵', '🪵', '🧶', '🧶', '🧶'], # S 54 | '🧸': ['🪨', '🧶', '🧶', '🧶', '🧶'], # S 55 | # Furniture 56 | '🛋️': ['🪨', '🪵', '🪵', '🪵', '🪵', '🧶', '🧶', '🧶', '🧶'], # L 57 | '🪴': ['🪨', '🪨', '🪵', '🪵', '🪵', '🪵', '🪵'], # M 58 | '⛲': ['🪨', '🪨', '🪨', '🪨', '🪨', '🪨', '🪨', '🪨'], # L 59 | # Devices 60 | '📺': ['🪨', '🪨', '🪵', '🪵', '🪵', '🪵'], # M 61 | # Miscellaneous 62 | '🗞️': ['🪵', '🪵', '🪵', '🧶'], # S 63 | '🎨': ['🪵', '🪵', '🪵', '🪵', '🪨', '🧶', '🧶'] # M 64 | } 65 | 66 | class Furniture(Entity): 67 | """Piece of furniture. 68 | 69 | .. attribute:: type 70 | 71 | Type of furniture as emoji. 72 | """ 73 | 74 | def __init__(self, data: dict[str, str]) -> None: 75 | super().__init__(data) 76 | self.type = data['type'] 77 | 78 | @staticmethod 79 | async def create(furniture_id: str, furniture_type: str) -> Furniture: 80 | """Create a furniture item of the given *furniture_type* with *furniture_id*.""" 81 | data = {'id': furniture_id, 'type': furniture_type} 82 | await context.bot.get().redis.hset(furniture_id, mapping=data) 83 | return Furniture(data) 84 | 85 | async def tick(self, time: int) -> None: 86 | """Simulate the furniture piece at *time* for one tick.""" 87 | 88 | async def use(self) -> None: 89 | """Use the furniture piece.""" 90 | 91 | def __str__(self) -> str: 92 | return self.type 93 | 94 | class Houseplant(Furniture): 95 | """Houseplant. 96 | 97 | .. attribute:: state 98 | 99 | Current state emoji. 100 | """ 101 | 102 | def __init__(self, data: dict[str, str]) -> None: 103 | super().__init__(data) 104 | self.state = data['state'] 105 | 106 | @staticmethod 107 | async def create(furniture_id: str, furniture_type: str) -> Houseplant: 108 | data = {'id': furniture_id, 'type': '🪴', 'state': '🪴'} 109 | await context.bot.get().redis.hset(furniture_id, mapping=data) 110 | return Houseplant(data) 111 | 112 | async def tick(self, time: int) -> None: 113 | if (time + 1) % 24 == 0: 114 | await context.bot.get().redis.hset(self.id, 'state', random.choice(['🪴', '🌺'])) 115 | 116 | def __str__(self) -> str: 117 | return self.state 118 | 119 | class Television(Furniture): 120 | """Television set. 121 | 122 | .. attribute:: show 123 | 124 | Current TV show. 125 | """ 126 | 127 | def __init__(self, data: dict[str, str]) -> None: 128 | super().__init__(data) 129 | self.show = Content.parse(data['show']) 130 | 131 | @staticmethod 132 | async def create(furniture_id: str, furniture_type: str) -> Television: 133 | bot = context.bot.get() 134 | data = {'id': furniture_id, 'type': '📺', 'show': str(random.choice(bot.tmdb.shows))} 135 | await bot.redis.hset(furniture_id, mapping=data) 136 | return Television(data) 137 | 138 | async def use(self) -> None: 139 | bot = context.bot.get() 140 | await bot.redis.hset(self.id, 'show', str(random.choice(bot.tmdb.shows))) 141 | 142 | class Newspaper(Furniture): 143 | """Newspaper. 144 | 145 | .. attribute:: article 146 | 147 | Opened news article. 148 | """ 149 | 150 | def __init__(self, data: dict[str, str]) -> None: 151 | super().__init__(data) 152 | self.article = Content.parse(data['article']) 153 | 154 | @staticmethod 155 | async def create(furniture_id: str, furniture_type: str) -> Newspaper: 156 | bot = context.bot.get() 157 | data = {'id': furniture_id, 'type': '🗞️', 'article': str(random.choice(bot.dw.articles))} 158 | await bot.redis.hset(furniture_id, mapping=data) 159 | return Newspaper(data) 160 | 161 | async def use(self) -> None: 162 | bot = context.bot.get() 163 | await bot.redis.hset(self.id, 'article', str(random.choice(bot.dw.articles))) 164 | 165 | class Palette(Furniture): 166 | """Canvas and palette. 167 | 168 | .. attribute:: state 169 | 170 | Current state emoji. 171 | """ 172 | 173 | def __init__(self, data: dict[str, str]) -> None: 174 | super().__init__(data) 175 | self.state = data['state'] 176 | 177 | @staticmethod 178 | async def create(furniture_id: str, furniture_type: str) -> Palette: 179 | data = {'id': furniture_id, 'type': '🎨', 'state': '🎨'} 180 | await context.bot.get().redis.hset(furniture_id, mapping=data) 181 | return Palette(data) 182 | 183 | async def tick(self, time: int) -> None: 184 | if (time + 1) % 24 == 0: 185 | await context.bot.get().redis.hset(self.id, 'state', random.choice(['🎨', '🖼️'])) 186 | 187 | def __str__(self) -> str: 188 | return self.state 189 | 190 | @dataclass 191 | class Content: 192 | """Media content. 193 | 194 | .. attribute:: title 195 | 196 | Title of the content. 197 | 198 | .. attribute:: url 199 | 200 | URL of the content or information about it. 201 | 202 | .. attribute:: summary 203 | 204 | Short summary. 205 | """ 206 | 207 | title: str 208 | url: str 209 | summary: str | None = None 210 | 211 | def __post_init__(self) -> None: 212 | self.title = collapse(self.title) 213 | if not self.title: 214 | raise ValueError('Blank title') 215 | if not urlsplit(self.url).scheme: 216 | raise ValueError(f'Relative URL {self.url}') 217 | self.summary = collapse(self.summary) or None if self.summary else None 218 | 219 | @staticmethod 220 | def parse(data: str) -> Content: 221 | """Parse the string representation *data* into media content.""" 222 | try: 223 | title, url, summary = data.split('␟') 224 | except ValueError: 225 | raise ValueError('Bad data format') from None 226 | return Content(title, url, summary or None) 227 | 228 | def __str__(self) -> str: 229 | return '␟'.join([self.title, self.url, self.summary or '']) 230 | 231 | class TMDB: 232 | """The Movie Database source. 233 | 234 | .. attribute:: CACHE_TTL 235 | 236 | Time to live for cached content. 237 | 238 | .. attribute:: key 239 | 240 | TMDB API v4 key to fetch the current popular TV shows. 241 | """ 242 | 243 | CACHE_TTL = timedelta(days=1) 244 | 245 | def __init__(self, *, key: str | None = None) -> None: 246 | self.key = key 247 | self._shows = [ 248 | Content('Buffy the Vampire Slayer', 249 | 'https://www.themoviedb.org/tv/95-buffy-the-vampire-slayer')] 250 | self._cache_expires = datetime.now() 251 | self._fetch_task: Task[None] | None = None 252 | 253 | @property 254 | def shows(self) -> list[Content]: 255 | """Current TV shows, ordered by popularity, highest first.""" 256 | if ( 257 | datetime.now() >= self._cache_expires and 258 | (not self._fetch_task or self._fetch_task.done()) 259 | ): 260 | self._fetch_task = create_task(self._fetch()) 261 | return self._shows 262 | 263 | async def _fetch(self) -> None: 264 | if not self.key: 265 | return 266 | 267 | logger = getLogger(__name__) 268 | try: 269 | headers = {'Authorization': f'Bearer {self.key}'} 270 | response = await context.bot.get().http.get('https://api.themoviedb.org/3/tv/popular', 271 | headers=headers) 272 | await raise_for_status(response) 273 | loads = partial(cast(Callable[[], object], json.loads), object_hook=JSONObject) 274 | result = await cast(Awaitable[object], response.json(loads=loads)) 275 | 276 | if not isinstance(result, JSONObject): 277 | raise TypeError(f'Bad result type {type(result).__name__}') 278 | shows = result.get('results', cls=list) 279 | if not shows: 280 | raise ValueError('No results') 281 | def parse_show(data: object) -> Content: 282 | if not isinstance(data, JSONObject): 283 | raise TypeError(f'Bad show type {type(data).__name__}') 284 | show_id = data.get('id', cls=int) 285 | return Content( 286 | title=data.get('name', cls=str), url=f'https://www.themoviedb.org/tv/{show_id}', 287 | summary=data.get('overview', cls=str)) 288 | self._shows = [parse_show(data) for data in shows[:10]] 289 | self._cache_expires = datetime.now() + self.CACHE_TTL 290 | logger.info('Fetched %d show(s) from TMDB', len(self._shows)) 291 | 292 | # Work around spurious Any for as target (see https://github.com/python/mypy/issues/13167) 293 | except (ClientError, asyncio.TimeoutError, JSONDecodeError, TypeError, # type: ignore[misc] 294 | ValueError) as e: 295 | if isinstance(e, asyncio.TimeoutError): 296 | e = asyncio.TimeoutError('Stalled request') 297 | logger.error('Failed to fetch shows from TMDB (%s)', e) 298 | 299 | async def close(self) -> None: 300 | """Close the source.""" 301 | if self._fetch_task: 302 | await cancel(self._fetch_task) 303 | 304 | class DW: 305 | """Deutsche Welle source. 306 | 307 | .. attribute:: CACHE_TTL 308 | 309 | Time to live for cached content. 310 | """ 311 | 312 | CACHE_TTL = timedelta(days=1) 313 | 314 | def __init__(self) -> None: 315 | self._articles = [ 316 | Content('Digital pet Tamagotchi turns 25', 317 | 'https://www.dw.com/en/digital-pet-tamagotchi-turns-25/a-61709227')] 318 | self._cache_expires = datetime.now() 319 | self._fetch_task: Task[None] | None = None 320 | 321 | @property 322 | def articles(self) -> list[Content]: 323 | """Current news articles, ordered by time, latest first.""" 324 | if ( 325 | datetime.now() >= self._cache_expires and 326 | (not self._fetch_task or self._fetch_task.done()) 327 | ): 328 | self._fetch_task = create_task(self._fetch()) 329 | return self._articles 330 | 331 | async def _fetch(self) -> None: 332 | logger = getLogger(__name__) 333 | try: 334 | response = await context.bot.get().http.get('https://rss.dw.com/atom/rss-en-top') 335 | await raise_for_status(response) 336 | data = await response.read() 337 | 338 | feed = feedparser.parse(data, sanitize_html=False) 339 | if feed['bozo']: 340 | raise cast(Exception, feed['bozo_exception']) 341 | entries = cast(list[dict[str, str]], feed['entries']) 342 | if not entries: 343 | raise ValueError('No entries') 344 | self._articles = [ 345 | Content(title=entry.get('title', ''), url=entry.get('link', ''), 346 | summary=entry.get('summary')) 347 | for entry in entries 348 | ] 349 | self._cache_expires = datetime.now() + self.CACHE_TTL 350 | logger.info('Fetched %d article(s) from DW', len(self._articles)) 351 | 352 | # Work around spurious Any for as target (see https://github.com/python/mypy/issues/13167) 353 | except (ClientError, asyncio.TimeoutError, ThingsNobodyCaresAboutButMe, # type: ignore[misc] 354 | SAXParseException, ValueError) as e: 355 | if isinstance(e, asyncio.TimeoutError): 356 | e = asyncio.TimeoutError('Stalled request') 357 | logger.error('Failed to fetch articles from DW (%s)', e) 358 | 359 | async def close(self) -> None: 360 | """Close the source.""" 361 | if self._fetch_task: 362 | await cancel(self._fetch_task) 363 | 364 | FURNITURE_TYPES = { 365 | # Toys 366 | '🪃': Furniture, 367 | '⚾': Furniture, 368 | '🧸': Furniture, 369 | # Furniture 370 | '🛋️': Furniture, 371 | '🪴': Houseplant, 372 | '⛲': Furniture, 373 | # Devices 374 | '📺': Television, 375 | # Miscellaneous 376 | '🗞️': Newspaper, 377 | '🎨': Palette 378 | } 379 | -------------------------------------------------------------------------------- /feini/bot.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Open Feini chatbot.""" 16 | 17 | from __future__ import annotations 18 | 19 | import asyncio 20 | from asyncio import CancelledError, Queue, Task, create_task, gather, shield, sleep 21 | from dataclasses import dataclass 22 | from datetime import datetime 23 | from functools import partial 24 | from inspect import getmembers, iscoroutinefunction, signature 25 | from itertools import takewhile 26 | import json 27 | from json import JSONDecodeError 28 | from logging import getLogger 29 | from typing import Awaitable, AsyncIterator, Callable, cast 30 | import unicodedata 31 | from urllib.parse import urljoin 32 | from weakref import WeakSet 33 | 34 | from aiohttp import ClientError, ClientPayloadError, ClientSession, ClientTimeout 35 | import redis.asyncio.client 36 | 37 | import feini.space 38 | from . import actions, context, updates 39 | from .actions import EventMessageFunc, MainMode, Mode 40 | from .furniture import DW, Furniture, TMDB, FURNITURE_TYPES 41 | from .space import Event, Pet, Space 42 | from .util import JSONObject, Redis, cancel, raise_for_status, randstr, recovery 43 | 44 | class Bot: 45 | """Open Feini chatbot. 46 | 47 | .. attribute:: time 48 | 49 | Current time in ticks. 50 | 51 | .. attribute:: redis 52 | 53 | Redis database client. 54 | 55 | .. attribute:: http 56 | 57 | HTTP client. 58 | 59 | .. attribute:: telegram 60 | 61 | Telegram messenger client, if configured. 62 | 63 | .. attribute:: tmdb 64 | 65 | The Movie Database source. 66 | 67 | .. attribute:: dw 68 | 69 | Deutsche Welle source. 70 | 71 | .. attribute:: debug 72 | 73 | Indicates if debug mode is enabled. 74 | 75 | .. attribute:: TICK 76 | 77 | Duration of a tick in seconds. 78 | """ 79 | 80 | TICK = 60 * 60 81 | 82 | def __init__(self, *, redis_url: str = 'redis:', telegram_key: str | None = None, 83 | tmdb_key: str | None = None, debug: bool = False) -> None: 84 | self.time = 0 85 | try: 86 | self.redis: Redis = redis.asyncio.client.Redis.from_url(redis_url, # type: ignore[misc] 87 | decode_responses=True) 88 | except ValueError as e: 89 | raise ValueError(f'Bad redis_url {redis_url}') from e 90 | self.http = ClientSession(timeout=ClientTimeout(total=20)) 91 | self.telegram = Telegram(telegram_key) if telegram_key else None 92 | self.tmdb = TMDB(key=tmdb_key) 93 | self.dw = DW() 94 | self.debug = debug 95 | 96 | self._chat_modes: dict[str, Mode] = {} 97 | self._story_tasks: WeakSet[Task[None]] = WeakSet() 98 | self._outbox: Queue[Message] = Queue() 99 | 100 | async def update(self) -> None: 101 | """Update the database.""" 102 | def isupdate(obj: object) -> bool: 103 | return (iscoroutinefunction(obj) and 104 | not signature(obj).parameters) # type: ignore[arg-type] 105 | # Use vars() instead of getmembers() to preserve order 106 | functions = [ 107 | cast(Callable[[], Awaitable[None]], member) 108 | for member in cast(dict[str, object], vars(updates)).values() if isupdate(member)] 109 | for update in reversed(functions): 110 | await update() 111 | 112 | async def run(self) -> None: 113 | """Run the bot continuously.""" 114 | context.bot.set(self) 115 | self.time = int(datetime.now().timestamp() / self.TICK) 116 | await self.update() 117 | 118 | events_task = create_task(self._handle_events()) 119 | inbox_task = None 120 | outbox_task = None 121 | if self.telegram: 122 | inbox_task = create_task(self._handle_inbox(self.telegram)) 123 | outbox_task = create_task(self._handle_outbox(self.telegram)) 124 | 125 | logger = getLogger(__name__) 126 | logger.info('Started bot') 127 | 128 | try: 129 | while True: 130 | with recovery(): 131 | for space in await self.get_spaces(): 132 | for time in range(space.time, self.time): 133 | await space.tick(time) 134 | self._story_tasks.add(create_task(space.tell_stories())) 135 | logger.info('Simulated world at tick %d', self.time) 136 | await sleep((self.time + 1) * self.TICK - datetime.now().timestamp()) 137 | self.time = int(datetime.now().timestamp() / self.TICK) 138 | 139 | except CancelledError: 140 | await cancel(events_task) 141 | if inbox_task: 142 | await cancel(inbox_task) 143 | if outbox_task: 144 | await cancel(outbox_task) 145 | logger.info('Stopped bot') 146 | raise 147 | 148 | async def close(self) -> None: 149 | """Close the database connection.""" 150 | await gather(*self._story_tasks) # type: ignore[misc] 151 | await self.redis.aclose() 152 | # Work around Redis not closing its connection pool (see 153 | # https://github.com/aio-libs/aioredis-py/issues/1103) 154 | try: 155 | await self.redis.connection_pool.disconnect() # type: ignore[misc] 156 | except CancelledError: 157 | pass 158 | await self.http.close() 159 | await self.tmdb.close() 160 | await self.dw.close() 161 | 162 | async def perform(self, chat: str, action: str) -> str: 163 | """Perform an action for the given *chat*. 164 | 165 | The *action* string consists of arguments, where an argument is either an emoji or a word. A 166 | reaction message is returned. 167 | """ 168 | logger = getLogger(__name__) 169 | try: 170 | space = await self.get_space_by_chat(chat) 171 | except KeyError: 172 | space = await self.create_space(chat) 173 | pet = await space.get_pet() 174 | self._story_tasks.add(create_task(space.tell_stories())) 175 | logger.info('Created space for %s (%s)', chat, pet.name) 176 | return '🥚 You found an egg. 😮' 177 | 178 | pet = await space.get_pet() 179 | args = self._parse_action(action) 180 | reply = await self.get_mode(chat).perform(space, *args) 181 | self._story_tasks.add(create_task(space.tell_stories())) 182 | logger.info('%s (%s): %s', chat, pet.name, ' '.join(args)) 183 | return reply 184 | 185 | def get_mode(self, chat: str) -> Mode: 186 | """Get the current mode of *chat*.""" 187 | return self._chat_modes.get(chat) or MainMode() 188 | 189 | def set_mode(self, chat: str, mode: Mode) -> None: 190 | """Set the current *mode* of *chat*.""" 191 | if isinstance(mode, MainMode): 192 | self._chat_modes.pop(chat, None) 193 | else: 194 | self._chat_modes[chat] = mode 195 | 196 | async def events(self) -> AsyncIterator[Event]: 197 | """Stream of game events.""" 198 | while True: 199 | _, data = await self.redis.blpop('events') 200 | name = data.split('␟', maxsplit=1)[0] 201 | cls = cast(object, getattr(feini.space, name)) 202 | assert isinstance(cls, type) and issubclass(cls, Event) # type: ignore[misc] 203 | yield cast(type[Event], cls).parse(data) 204 | 205 | async def get_spaces(self) -> set[Space]: 206 | """Get all spaces.""" 207 | ids = await self.redis.hvals('spaces_by_chat') 208 | return {Space(data) for space_id in ids if (data := await self.redis.hgetall(space_id))} 209 | 210 | async def get_space(self, space_id: str) -> Space: 211 | """Get the space given by *space_id*.""" 212 | if not space_id.startswith('Space:'): 213 | raise ValueError(f'Bad space_id {space_id}') 214 | data = await self.redis.hgetall(space_id) 215 | if not data: 216 | raise KeyError(space_id) 217 | return Space(data) 218 | 219 | async def get_space_by_chat(self, chat: str) -> Space: 220 | """Get the space given by *chat*.""" 221 | space_id = await self.redis.hget('spaces_by_chat', chat) 222 | if not space_id: 223 | raise KeyError(chat) 224 | return await self.get_space(space_id) 225 | 226 | async def get_pet(self, pet_id: str) -> Pet: 227 | """Get the pet given by *pet_id*.""" 228 | if not pet_id.startswith('Pet:'): 229 | raise ValueError(f'Bad pet_id {pet_id}') 230 | data = await self.redis.hgetall(pet_id) 231 | if not data: 232 | raise KeyError(pet_id) 233 | return Pet(data) 234 | 235 | async def get_furniture_item(self, furniture_id: str) -> Furniture: 236 | """Get the furniture item given by *furniture_id*.""" 237 | if not furniture_id.startswith('Object:'): 238 | raise ValueError(f'Bad furniture_id {furniture_id}') 239 | data = await self.redis.hgetall(furniture_id) 240 | if not data: 241 | raise KeyError(furniture_id) 242 | return FURNITURE_TYPES[data['type']](data) 243 | 244 | async def create_space(self, chat: str) -> Space: 245 | """Create a new space for the given *chat*.""" 246 | async with self.redis.pipeline() as pipe: 247 | await pipe.watch('spaces_by_chat') 248 | if await pipe.hexists('spaces_by_chat', chat): 249 | raise ValueError(f'Duplicate chat {chat}') 250 | 251 | pipe.multi() 252 | space_id = f'Space:{randstr()}' 253 | pet_id = f'Pet:{randstr()}' 254 | 255 | space = { 256 | 'id': space_id, 257 | 'chat': chat, 258 | 'time': str(self.time), 259 | 'resources': '', 260 | 'tools': ' '.join(['👋', '✏️', '🔨', '🧺', '🧽']), 261 | 'meadow_vegetable_growth': str(Space.MEADOW_VEGETABLE_GROWTH_MAX), 262 | 'woods_growth': str(Space.WOODS_GROWTH_MAX), 263 | 'trail_supply': str(Space.TRAIL_SUPPLY_MAX), 264 | 'pet_id': pet_id 265 | } 266 | pipe.hset(space_id, mapping=space) 267 | pipe.hset('spaces_by_chat', chat, space_id) 268 | 269 | pet = { 270 | 'id': pet_id, 271 | 'space_id': space_id, 272 | 'name': 'Feini', 273 | 'hatched': '', 274 | 'nutrition': str(8 - 1), 275 | 'dirt': str(Pet.DIRT_MAX - (8 - 1)), 276 | 'fur': '0', 277 | 'clothing': '', 278 | 'activity_id': '' 279 | } 280 | pipe.hset(pet_id, mapping=pet) 281 | 282 | blueprints = ['🪓', '✂️', '🍳', '🚿', '🧭', '🪃', '⚾', '🧸', '🛋️', '🪴', '⛲', '📺', '🗞️', 283 | '🎨'] 284 | pipe.zadd(f'{space_id}.blueprints', 285 | {blueprint: Space.BLUEPRINT_WEIGHTS[blueprint] for blueprint in blueprints}) 286 | 287 | stories = [ 288 | { 289 | 'id': f'IntroStory:{randstr()}', 290 | 'space_id': space_id, 291 | 'chapter': 'start', 292 | 'update_time': str(self.time) 293 | }, { 294 | 'id': f'SewingStory:{randstr()}', 295 | 'space_id': space_id, 296 | 'chapter': 'scissors', 297 | 'update_time': str(self.time) 298 | } 299 | ] 300 | for story in stories: 301 | pipe.hset(story['id'], mapping=story) 302 | pipe.sadd(f'{space_id}.stories', story['id']) 303 | 304 | await pipe.execute() 305 | return Space(space) 306 | 307 | async def _handle_events(self) -> None: 308 | def iseventmessagefunc(obj: object) -> bool: 309 | return isinstance(obj, EventMessageFunc) 310 | members = cast(list[tuple[str, EventMessageFunc]], 311 | getmembers(actions, iseventmessagefunc)) 312 | event_messages = {f.event_type: f for _, f in members} 313 | 314 | logger = getLogger(__name__) 315 | logger.info('Started event queue') 316 | try: 317 | async for event in self.events(): 318 | with recovery(): 319 | space = await shield(event.get_space()) 320 | pet = await shield(space.get_pet()) 321 | reply = await shield(event_messages[event.type](event)) 322 | self._send(Message(space.chat, reply)) 323 | logger.info('%s (%s): %s', space.chat, pet.name, event.type) 324 | except CancelledError: 325 | logger.info('Stopped event queue') 326 | raise 327 | 328 | async def _handle_inbox(self, telegram: Telegram) -> None: 329 | logger = getLogger(__name__) 330 | logger.info('Started Telegram inbox') 331 | try: 332 | while True: 333 | try: 334 | async for message in telegram.inbox(): 335 | reply = ('⚠️ Oops, something went very wrong! We will fix the problem as ' 336 | 'soon as possible. Meanwhile, you may try again.') 337 | with recovery(): 338 | reply = await shield(self.perform(message.chat, message.text)) 339 | self._send(Message(message.chat, reply)) 340 | except ClientError as e: 341 | logger.warning('Failed to receive Telegram messages (%s)', e) 342 | await sleep(1) 343 | except CancelledError: 344 | logger.info('Stopped Telegram inbox') 345 | raise 346 | 347 | async def _handle_outbox(self, telegram: Telegram) -> None: 348 | logger = getLogger(__name__) 349 | logger.info('Started Telegram outbox') 350 | try: 351 | while True: 352 | message = await self._outbox.get() 353 | try: 354 | with recovery(): 355 | while True: 356 | try: 357 | await telegram.send(message) 358 | break 359 | except ClientError as e: 360 | logger.warning('Failed to send Telegram message (%s)', e) 361 | await sleep(1) 362 | finally: 363 | self._outbox.task_done() 364 | except CancelledError: 365 | logger.info('Stopped Telegram outbox') 366 | raise 367 | 368 | def _send(self, message: Message) -> None: 369 | self._outbox.put_nowait(message) 370 | 371 | def _parse_action(self, action: str) -> list[str]: 372 | if not action: 373 | return [] 374 | category = unicodedata.category(action[0]) 375 | 376 | # Parse space 377 | if category.startswith('Z'): 378 | return self._parse_action(action[1:]) 379 | 380 | # Parse emoji 381 | if category == 'So': 382 | variation_selectors = '\N{VARIATION SELECTOR-15}\N{VARIATION SELECTOR-16}' 383 | length = 2 if len(action) >= 2 and action[1] in variation_selectors else 1 384 | return [action[:length], *self._parse_action(action[length:])] 385 | 386 | # Parse word 387 | def isletter(character: str) -> bool: 388 | category = unicodedata.category(character) 389 | return not (category.startswith('Z') or category == 'So') 390 | word = ''.join(takewhile(isletter, action)) 391 | return [word, *self._parse_action(action[len(word):])] 392 | 393 | @dataclass 394 | class Message: 395 | """Chat message. 396 | 397 | .. attribute:: chat 398 | 399 | Related chat ID. 400 | 401 | .. attribute:: text 402 | 403 | Message content. 404 | """ 405 | 406 | chat: str 407 | text: str 408 | 409 | class Telegram: 410 | """Telegram messenger client. 411 | 412 | .. attribute:: key 413 | 414 | API key. 415 | """ 416 | 417 | def __init__(self, key: str) -> None: 418 | self.key = key 419 | self._base = f'https://api.telegram.org/bot{self.key}/' 420 | 421 | async def inbox(self) -> AsyncIterator[Message]: 422 | """Message inbox. 423 | 424 | If there is a problem communicating with Telegram, a :exc:`aiohttp.ClientError` is raised. 425 | """ 426 | # Note that if there is a crash while a batch of messages is yielded, the messages will be 427 | # repeated 428 | offset = 0 429 | while True: 430 | try: 431 | response = await context.bot.get().http.get( 432 | urljoin(self._base, 'getUpdates'), 433 | params={'offset': str(offset), 'timeout': '300'}, # type: ignore[misc] 434 | timeout=320) 435 | await raise_for_status(response) 436 | loads = partial(cast(Callable[[], object], json.loads), object_hook=JSONObject) 437 | result = await cast(Awaitable[object], response.json(loads=loads)) 438 | except JSONDecodeError as e: 439 | raise ClientPayloadError('Bad response format') from e 440 | except asyncio.TimeoutError as e: 441 | raise ClientError('Stalled request') from e 442 | 443 | try: 444 | if not isinstance(result, JSONObject): 445 | raise ClientPayloadError(f'Bad result type {type(result).__name__}') 446 | for update in result.get('result', cls=list): 447 | if not isinstance(update, JSONObject): 448 | raise ClientPayloadError(f'Bad update type {type(update).__name__}') 449 | update_id = update.get('update_id', cls=int) 450 | if 'message' in update: 451 | message = update.get('message', cls=JSONObject) 452 | yield Message(str(message.get('chat', cls=JSONObject).get('id', cls=int)), 453 | message.get('text', cls=str)) 454 | offset = update_id + 1 455 | except TypeError as e: 456 | raise ClientPayloadError(str(e)) from e 457 | 458 | async def send(self, message: Message) -> None: 459 | """Send a *message*. 460 | 461 | If the indicated *chat* is not available, a :exc:`KeyError` is raised. If there is a problem 462 | communicating with the service, a :exc:`aiohttp.ClientError` is raised. 463 | """ 464 | try: 465 | chat = int(message.chat) 466 | except ValueError: 467 | raise KeyError(message.chat) from None 468 | if not message.text.strip(): 469 | return 470 | 471 | try: 472 | response = await context.bot.get().http.post( 473 | urljoin(self._base, 'sendMessage'), 474 | json={'chat_id': chat, 'text': message.text}) # type: ignore[misc] 475 | if response.status >= 500: 476 | await raise_for_status(response) 477 | loads = partial(cast(Callable[[], object], json.loads), object_hook=JSONObject) 478 | result = await cast(Awaitable[object], response.json(loads=loads)) 479 | except JSONDecodeError as e: 480 | raise ClientPayloadError('Bad response format') from e 481 | except asyncio.TimeoutError as e: 482 | raise ClientError('Stalled request') from e 483 | 484 | if not response.ok: 485 | try: 486 | if not isinstance(result, JSONObject): 487 | raise ClientPayloadError(f'Bad result type {type(result).__name__}') 488 | code = result.get('error_code', cls=int) 489 | if code in {400, 403}: 490 | raise KeyError(message.chat) 491 | raise ClientPayloadError(f'Bad error_code {code}') 492 | except TypeError as e: 493 | raise ClientPayloadError(str(e)) from e 494 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /feini/space.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Logic of pets and their space. 16 | 17 | .. data:: CHARACTER_NAMES 18 | 19 | Name of each character. 20 | """ 21 | 22 | from __future__ import annotations 23 | 24 | from collections import deque 25 | from collections.abc import Collection 26 | import dataclasses 27 | from dataclasses import dataclass, field 28 | from itertools import chain 29 | import random 30 | from random import randint, shuffle 31 | import sys 32 | from typing import cast, TYPE_CHECKING 33 | 34 | from . import context 35 | from .core import Entity 36 | from .furniture import Furniture, FURNITURE_TYPES, FURNITURE_MATERIAL 37 | from .util import randstr 38 | 39 | if TYPE_CHECKING: 40 | from .stories import Story 41 | 42 | CHARACTER_NAMES = { 43 | '👻': 'Ghost' 44 | } 45 | 46 | class Space(Entity): 47 | """Space inhabited by a pet. 48 | 49 | .. attribute:: chat 50 | 51 | Chat the space belongs to. 52 | 53 | .. attribute:: time 54 | 55 | Current simulation time. 56 | 57 | .. attribute:: items 58 | 59 | Item inventory. 60 | 61 | .. attribute:: tools 62 | 63 | Tool inventory. 64 | 65 | .. attribute:: meadow_vegetable_growth 66 | 67 | Current vegetable growth level. 68 | 69 | .. attribute:: woods_growth 70 | 71 | Current wood growth level. 72 | 73 | .. attribute:: trail_supply 74 | 75 | Current hiking trail resource supply level. 76 | 77 | .. attribute:: pet_id 78 | 79 | ID of the residing :class:`Pet`. 80 | 81 | .. attribute:: MEADOW_VEGETABLE_GROWTH_MAX 82 | 83 | Level at which a vegetable is fully grown. 84 | 85 | .. attribute:: WOODS_GROWTH_MAX 86 | 87 | Level at which wood is fully grown. 88 | 89 | .. attribute:: TRAIL_SUPPLY_MAX 90 | 91 | Level at which a resource is in supply on the trail. 92 | 93 | .. attribute:: ITEM_CATEGORIES 94 | 95 | Available types of items by category. 96 | 97 | .. attribute:: ITEM_WEIGHTS 98 | 99 | Weights by which items are ordered. 100 | 101 | .. attribute:: TOOL_MATERIAL 102 | 103 | Material needed for each tool. 104 | 105 | .. attribute:: CLOTHING_MATERIAL 106 | 107 | Material needed for each clothing item. 108 | 109 | .. attribute:: BLUEPRINT_WEIGHTS 110 | 111 | Weights by which blueprints are ordered. 112 | """ 113 | 114 | MEADOW_VEGETABLE_GROWTH_MAX = 8 - 1 115 | WOODS_GROWTH_MAX = 8 - 1 116 | TRAIL_SUPPLY_MAX = 24 - 1 117 | 118 | ITEM_CATEGORIES = { 119 | 'food': ['🥕', '🍲'], 120 | 'resource': ['🪨', '🪵', '🧶'], 121 | 'clothing': ['🧢', '👒', '🎧', '👓', '🕶️', '🥽', '🧣', '🎀', '💍'], 122 | 'tool': ['👋', '✏️', '🧺', '🪓', '✂️', '🔨', '🪡', '🍳', '🧽', '🚿', '🧭'] 123 | } 124 | 125 | ITEM_WEIGHTS = { 126 | item: 127 | weight for weight, item 128 | in enumerate(item for items in ITEM_CATEGORIES.values() for item in items) 129 | } 130 | 131 | # Material distribution guidelines: 4 - 5 resources for small (S) objects, 6 - 7 for M and 8 - 9 132 | # for L (for details see ``scripts/material.py``) 133 | 134 | TOOL_MATERIAL = { 135 | '🪓': ['🪨'], # S 136 | '✂️': ['🪨', '🪨', '🪨', '🪵'], # S 137 | '🪡': ['🪵', '🪵', '🪵', '🪵', '🪵'], # S 138 | '🍳': ['🪨', '🪨', '🪨', '🪨', '🪵'], # S 139 | '🚿': ['🪨', '🪨', '🪵', '🪵', '🪵', '🪵'], # M 140 | '🧭': ['🪨', '🪨', '🪨', '🪨'], # S 141 | } 142 | 143 | CLOTHING_MATERIAL = { 144 | # Head 145 | '🧢': ['🪵', '🧶', '🧶', '🧶'], # S 146 | '👒': ['🪵', '🪵', '🪵', '🪵', '🧶'], # S 147 | '🎧': ['🪨', '🪨', '🧶', '🧶', '🧶'], # S 148 | # Face 149 | '👓': ['🪨', '🪨', '🪵', '🪵', '🧶'], # S 150 | '🕶️': ['🪨', '🪨', '🪵', '🪵', '🧶'], # S 151 | '🥽': ['🪨', '🪨', '🧶', '🧶', '🧶'], # S 152 | # Body 153 | '🧣': ['🧶', '🧶', '🧶', '🧶', '🧶', '🧶'], # M 154 | '🎀': ['🧶', '🧶', '🧶', '🧶'], # S 155 | '💍': ['🪨', '🪨', '🪨', '🪨', '🧶'] # S 156 | } 157 | 158 | BLUEPRINT_WEIGHTS = { 159 | blueprint: weight 160 | for weight, blueprint in enumerate(chain(TOOL_MATERIAL, FURNITURE_MATERIAL)) 161 | } 162 | 163 | def __init__(self, data: dict[str, str]) -> None: 164 | super().__init__(data) 165 | self.chat = data['chat'] 166 | self.time = int(data['time']) 167 | self.items = data['resources'].split() 168 | self.tools = data['tools'].split() 169 | self.meadow_vegetable_growth = int(data['meadow_vegetable_growth']) 170 | self.woods_growth = int(data['woods_growth']) 171 | self.trail_supply = int(data['trail_supply']) 172 | self.pet_id = data['pet_id'] 173 | 174 | async def get_pet(self) -> Pet: 175 | """Get the residing pet.""" 176 | return await context.bot.get().get_pet(self.pet_id) 177 | 178 | async def get_blueprints(self) -> list[str]: 179 | """Get known blueprints.""" 180 | return await context.bot.get().redis.zrange(f'{self.id}.blueprints', 0, -1) 181 | 182 | async def get_furniture(self) -> list[Furniture]: 183 | """Get owned furniture.""" 184 | redis = context.bot.get().redis 185 | ids = await redis.lrange(f'{self.id}.items', 0, -1) 186 | return [FURNITURE_TYPES[data['type']](data) 187 | for item_id in ids if (data := await redis.hgetall(item_id))] 188 | 189 | async def get_characters(self) -> list[Character]: 190 | """Get the present characters.""" 191 | redis = context.bot.get().redis 192 | ids = await redis.lrange(f'{self.id}.characters', 0, -1) 193 | characters = (await redis.hgetall(character_id) for character_id in ids) 194 | return [Character(data) # type: ignore[misc] 195 | async for data in characters if data] # type: ignore[attr-defined,misc] 196 | 197 | async def get_stories(self) -> set[Story]: 198 | """Get all ongoing stories.""" 199 | def parse_story(data: dict[str, str]) -> Story: 200 | # pylint: disable=import-outside-toplevel 201 | from . import stories 202 | cls = cast('type[Story]', getattr(stories, data['id'].split(':')[0])) 203 | return cls(data) 204 | redis = context.bot.get().redis 205 | ids = await redis.smembers(f'{self.id}.stories') 206 | stories = (await redis.hgetall(story_id) for story_id in ids) 207 | return {parse_story(data) # type: ignore[misc] 208 | async for data in stories if data} # type: ignore[attr-defined,misc] 209 | 210 | async def tick(self, time: int) -> None: 211 | """Simulate the space at *time* for one tick. 212 | 213 | If *time* does not match the current simulation :attr:`time`, the operation is skipped. 214 | """ 215 | pet = await self.get_pet() 216 | await pet.tick() 217 | for item in await self.get_furniture(): 218 | await item.tick(time) 219 | 220 | async with context.bot.get().redis.pipeline() as pipe: 221 | await pipe.watch(self.id) 222 | try: 223 | sim_time = int(await pipe.hget(self.id, 'time') or '') 224 | except ValueError: 225 | raise ReferenceError(self.id) from None 226 | if time != sim_time: 227 | return 228 | 229 | pipe.multi() 230 | pipe.hset(self.id, 'time', sim_time + 1) 231 | pipe.hincrby(self.id, 'meadow_vegetable_growth', 1) 232 | pipe.hincrby(self.id, 'woods_growth', 1) 233 | pipe.hincrby(self.id, 'trail_supply', 1) 234 | await pipe.execute() 235 | 236 | async def obtain(self, *items: str) -> None: 237 | """Obtain the given *items*. 238 | 239 | Only available in debug mode. 240 | """ 241 | bot = context.bot.get() 242 | if not bot.debug: 243 | raise ValueError('Disabled bot debug mode') 244 | for item in items: 245 | if not any(item in items for items in Space.ITEM_CATEGORIES.values()): 246 | raise ValueError(f'Unknown items item {item}') 247 | 248 | tools = tuple(item for item in items if item in self.ITEM_CATEGORIES['tool']) 249 | items = tuple(item for item in items if item not in self.ITEM_CATEGORIES['tool']) 250 | async with bot.redis.pipeline() as pipe: 251 | await pipe.watch(self.id) 252 | values = await pipe.hmget(self.id, 'resources', 'tools') 253 | stock = (values[0] or '').split() 254 | tools_stock = (values[1] or '').split() 255 | pipe.multi() 256 | stock = sorted(chain(stock, items), key=Space.ITEM_WEIGHTS.__getitem__) 257 | tools_stock += tools 258 | pipe.hset(self.id, 259 | mapping={'resources': ' '.join(stock), 'tools': ' '.join(tools_stock)}) 260 | await pipe.execute() 261 | 262 | async def gather(self) -> list[str]: 263 | """Gather available resources from the meadow and return a receipt.""" 264 | async with context.bot.get().redis.pipeline() as pipe: 265 | await pipe.watch(self.id) 266 | values = await pipe.hmget(self.id, 'resources', 'meadow_vegetable_growth') 267 | if not values: 268 | raise ReferenceError(self.id) 269 | items = (values[0] or '').split() 270 | growth = int(values[1] or '') 271 | 272 | pipe.multi() 273 | resources = [] 274 | if growth >= self.MEADOW_VEGETABLE_GROWTH_MAX: 275 | resources = ['🥕', '🪨'] 276 | items = sorted(items + resources, key=self.ITEM_WEIGHTS.__getitem__) 277 | pipe.hset(self.id, 278 | mapping={'resources': ' '.join(items), 'meadow_vegetable_growth': 0}) 279 | await pipe.execute() 280 | return resources 281 | 282 | async def chop_wood(self) -> list[str]: 283 | """Chop available wood from the woods and return a receipt.""" 284 | async with context.bot.get().redis.pipeline() as pipe: 285 | await pipe.watch(self.id) 286 | values = await pipe.hmget(self.id, 'resources', 'tools', 'woods_growth') 287 | if not values: 288 | raise ReferenceError(self.id) 289 | items = (values[0] or '').split() 290 | tools = (values[1] or '').split() 291 | growth = int(values[2] or '') 292 | if '🪓' not in tools: 293 | raise ValueError('No tools item 🪓') 294 | 295 | pipe.multi() 296 | wood = [] 297 | if growth >= self.WOODS_GROWTH_MAX: 298 | wood = ['🪵'] 299 | items = sorted(items + wood, key=self.ITEM_WEIGHTS.__getitem__) 300 | pipe.hset(self.id, mapping={'resources': ' '.join(items), 'woods_growth': 0}) 301 | await pipe.execute() 302 | return wood 303 | 304 | async def craft(self, blueprint: str) -> str | Furniture: 305 | """Craft a new object given by *blueprint*.""" 306 | if blueprint in FURNITURE_TYPES: 307 | return await self._craft_furniture_item(blueprint) 308 | return await self._craft_tool(blueprint) 309 | 310 | async def _craft_tool(self, blueprint: str) -> str: 311 | async with context.bot.get().redis.pipeline() as pipe: 312 | await pipe.watch(self.id) 313 | values = await pipe.hmget(self.id, 'resources', 'tools') 314 | items = (values[0] or '').split(' ') 315 | tools = (values[1] or '').split(' ') 316 | if await pipe.zscore(f'{self.id}.blueprints', blueprint) is None: 317 | raise ValueError(f'Unknown blueprint {blueprint}') 318 | pipe.multi() 319 | try: 320 | for resource in self.TOOL_MATERIAL[blueprint]: 321 | items.remove(resource) 322 | except ValueError: 323 | raise ValueError('Missing items') from None 324 | tools.append(blueprint) 325 | pipe.hset(self.id, mapping={'resources': ' '.join(items), 'tools': ' '.join(tools)}) 326 | await pipe.execute() 327 | return blueprint 328 | 329 | async def _craft_furniture_item(self, blueprint: str) -> Furniture: 330 | bot = context.bot.get() 331 | object_id = f'Object:{randstr()}' 332 | 333 | async with bot.redis.pipeline() as pipe: 334 | await pipe.watch(self.id) 335 | items = (await pipe.hget(self.id, 'resources') or '').split(' ') 336 | if await pipe.zscore(f'{self.id}.blueprints', blueprint) is None: 337 | raise ValueError(f'Unknown blueprint {blueprint}') 338 | pipe.multi() 339 | try: 340 | for resource in FURNITURE_MATERIAL[blueprint]: 341 | items.remove(resource) 342 | except ValueError: 343 | raise ValueError('Missing items') from None 344 | pipe.hset(self.id, 'resources', ' '.join(items)) 345 | pipe.rpush(f'{self.id}.items', object_id) 346 | await pipe.execute() 347 | 348 | # Note that if there is a crash creating the furniture item, we could create it later from 349 | # the reserved ID 350 | return await FURNITURE_TYPES[blueprint].create(object_id, blueprint) 351 | 352 | async def sew(self, pattern: str) -> str: 353 | """Sew a new clothing item given by *pattern*.""" 354 | try: 355 | material = self.CLOTHING_MATERIAL[pattern] 356 | except KeyError: 357 | raise ValueError(f'Unknown pattern {pattern}') from None 358 | 359 | async with context.bot.get().redis.pipeline() as pipe: 360 | await pipe.watch(self.id) 361 | values = await pipe.hmget(self.id, 'resources', 'tools') 362 | items = (values[0] or '').split() 363 | tools = (values[1] or '').split() 364 | pipe.multi() 365 | if '🪡' not in tools: 366 | raise ValueError('No tools item 🪡') 367 | try: 368 | for item in material: 369 | items.remove(item) 370 | except ValueError: 371 | raise ValueError('Missing items') from None 372 | items.append(pattern) 373 | items.sort(key=Space.ITEM_WEIGHTS.__getitem__) 374 | pipe.hset(self.id, 'resources', ' '.join(items)) 375 | await pipe.execute() 376 | return pattern 377 | 378 | async def cook(self) -> str: 379 | """Prepare a dish from a vegetable.""" 380 | async with context.bot.get().redis.pipeline() as pipe: 381 | await pipe.watch(self.id) 382 | value = await pipe.hget(self.id, 'resources') 383 | if value is None: 384 | raise ReferenceError(self.id) 385 | items = value.split() 386 | 387 | pipe.multi() 388 | try: 389 | items.remove('🥕') 390 | except ValueError: 391 | raise ValueError('No items item 🥕') from None 392 | dish = '🍲' 393 | items.append(dish) 394 | items.sort(key=self.ITEM_WEIGHTS.__getitem__) 395 | pipe.hset(self.id, 'resources', ' '.join(items)) 396 | await pipe.execute() 397 | return dish 398 | 399 | async def hike(self) -> Hike: 400 | """Start a hike. 401 | 402 | A compass 🧭 is required. 403 | """ 404 | space = Space(await context.bot.get().redis.hgetall(self.id)) 405 | if '🧭' not in space.tools: 406 | raise ValueError('No tools item 🧭') 407 | resource = (random.choice(['🥕', '🪨']) if space.trail_supply >= self.TRAIL_SUPPLY_MAX 408 | else None) 409 | return Hike(self, resource=resource) 410 | 411 | async def record_hike(self, hike: Hike) -> None: 412 | """Record a finished *hike*. 413 | 414 | Any :attr:`Hike.gathered` resources are stored. 415 | """ 416 | if not hike.finished: 417 | raise ValueError('Unfinished hike') 418 | 419 | async with context.bot.get().redis.pipeline() as pipe: 420 | await pipe.watch(self.id) 421 | space = Space(await pipe.hgetall(self.id)) 422 | pipe.multi() 423 | if '🧭' not in space.tools: 424 | raise ValueError('No tools item 🧭') 425 | if hike.gathered: 426 | if space.trail_supply < self.TRAIL_SUPPLY_MAX: 427 | raise ValueError('Empty trail_supply') 428 | items = sorted(space.items + hike.gathered, key=Space.ITEM_WEIGHTS.__getitem__) 429 | pipe.hset(self.id, mapping={'resources': ' '.join(items), 'trail_supply': 0}) 430 | await pipe.execute() 431 | 432 | async def tell_stories(self) -> None: 433 | """Continue all ongoing stories.""" 434 | for story in await self.get_stories(): 435 | try: 436 | await story.tell() 437 | except ReferenceError: 438 | pass 439 | 440 | class Pet(Entity): 441 | """Pet. 442 | 443 | .. attribute:: space_id 444 | 445 | ID of the :class:`Space` the pet inhabits. 446 | 447 | .. attribute:: name 448 | 449 | Name of the pet. 450 | 451 | .. attribute:: hatched 452 | 453 | Indicates if the pet has hatched or is still an egg. 454 | 455 | .. attribute:: nutrition 456 | 457 | Current nutrition level. 458 | 459 | .. attribute:: dirt 460 | 461 | Current dirtiness level. 462 | 463 | .. attribute:: fur 464 | 465 | Current fur growth level. 466 | 467 | .. attribute:: clothing 468 | 469 | Clothing the pet is wearing. 470 | 471 | .. attribute:: activity_id 472 | 473 | Current activity emoji or ID of the furniture item the pet is engaged with. 474 | 475 | .. attribute:: NUTRITION_MAX 476 | 477 | Level at which the pet is full. 478 | 479 | .. attribute:: DIRT_MAX 480 | 481 | Level at which the pet is completely dirty. 482 | 483 | .. attribute:: FUR_MAX 484 | 485 | Level at which the fur is fully grown. 486 | 487 | .. attribute:: ACTIVITIES 488 | 489 | Available stand-alone activities. 490 | """ 491 | 492 | NUTRITION_MAX = 24 + 1 493 | DIRT_MAX = 48 + 1 494 | FUR_MAX = 8 - 1 495 | ACTIVITIES = {'💤', '🍃'} 496 | 497 | def __init__(self, data: dict[str, str]) -> None: 498 | super().__init__(data) 499 | self.space_id = data['space_id'] 500 | self.name = data['name'] 501 | self.hatched = bool(data['hatched']) 502 | self.nutrition = int(data['nutrition']) 503 | self.dirt = int(data['dirt']) 504 | self.fur = int(data['fur']) 505 | self.clothing = data['clothing'] or None 506 | self.activity_id = data['activity_id'] 507 | 508 | async def get_space(self) -> Space: 509 | """Get the space the pet inhabits.""" 510 | return await context.bot.get().get_space(self.space_id) 511 | 512 | async def get_activity(self) -> Furniture | str: 513 | """Get the current activity emoji or furniture item the pet is engaged with.""" 514 | try: 515 | return await context.bot.get().get_furniture_item(self.activity_id) 516 | except ValueError: 517 | return self.activity_id 518 | 519 | async def tick(self) -> None: 520 | """Simulate the pet for one tick.""" 521 | bot = context.bot.get() 522 | async with bot.redis.pipeline() as pipe: 523 | await pipe.watch(self.id) 524 | values = await pipe.hmget(self.id, 'nutrition', 'dirt') 525 | if not values: 526 | raise ReferenceError(self.id) 527 | nutrition = int(values[0] or '') 528 | dirt = int(values[1] or '') 529 | 530 | pipe.multi() 531 | nutrition -= 1 532 | dirt += 1 533 | pipe.hset(self.id, mapping={'nutrition': nutrition, 'dirt': dirt}) 534 | pipe.hincrby(self.id, 'fur', 1) 535 | if nutrition == 0: 536 | pipe.rpush('events', str(Event('pet-hungry', self.space_id))) 537 | if dirt == self.DIRT_MAX: 538 | pipe.rpush('events', str(Event('pet-dirty', self.space_id))) 539 | await pipe.execute() 540 | 541 | space = await self.get_space() 542 | furniture = await space.get_furniture() 543 | activities: list[Furniture | str] = ['', *self.ACTIVITIES, *furniture] 544 | await self.engage(random.choice(activities)) 545 | 546 | async def touch(self) -> None: 547 | """Touch the pet. 548 | 549 | If the pet is still an egg, it hatches. 550 | """ 551 | await context.bot.get().redis.hset(self.id, 'hatched', 'true') 552 | 553 | async def feed(self, food: str) -> None: 554 | """Feed the pet with *food*.""" 555 | if food not in Space.ITEM_CATEGORIES['food']: 556 | raise ValueError(f'Unknown food {food}') 557 | 558 | async with context.bot.get().redis.pipeline() as pipe: 559 | await pipe.watch(self.id, self.space_id) 560 | try: 561 | nutrition = int(await pipe.hget(self.id, 'nutrition') or '') 562 | except ValueError: 563 | raise ReferenceError(self.id) from None 564 | items = (await pipe.hget(self.space_id, 'resources') or '').split() 565 | if nutrition >= self.NUTRITION_MAX: 566 | raise ValueError('Maximal nutrition') 567 | 568 | pipe.multi() 569 | try: 570 | items.remove(food) 571 | except ValueError: 572 | raise ValueError(f'No space items item {food}') from None 573 | pipe.hset(self.id, 'nutrition', self.NUTRITION_MAX) 574 | pipe.hset(self.space_id, 'resources', ' '.join(items)) 575 | await pipe.execute() 576 | 577 | async def wash(self) -> None: 578 | """Wash the pet.""" 579 | async with context.bot.get().redis.pipeline() as pipe: 580 | await pipe.watch(self.id) 581 | try: 582 | dirt = int(await pipe.hget(self.id, 'dirt') or '') 583 | except ValueError: 584 | raise ReferenceError(self.id) from None 585 | if not dirt: 586 | raise ValueError('Minimal dirt') 587 | pipe.multi() 588 | pipe.hset(self.id, 'dirt', 0) 589 | await pipe.execute() 590 | 591 | async def dress(self, clothing: str | None) -> None: 592 | """Dress the pet in the given *clothing*.""" 593 | if clothing and clothing not in Space.ITEM_CATEGORIES['clothing']: 594 | raise ValueError(f'Unknown clothing {clothing}') 595 | 596 | async with context.bot.get().redis.pipeline() as pipe: 597 | await pipe.watch(self.id, self.space_id) 598 | old_clothing = await pipe.hget(self.id, 'clothing') 599 | if old_clothing is None: 600 | raise ReferenceError(self.id) 601 | old_clothing = old_clothing or None 602 | items = (await pipe.hget(self.space_id, 'resources') or '').split() 603 | 604 | pipe.multi() 605 | if old_clothing: 606 | items.append(old_clothing) 607 | items.sort(key=Space.ITEM_WEIGHTS.__getitem__) 608 | if clothing: 609 | try: 610 | items.remove(clothing) 611 | except ValueError: 612 | raise ValueError(f'No space items item {clothing}') from None 613 | pipe.hset(self.id, 'clothing', clothing or '') 614 | pipe.hset(self.space_id, 'resources', ' '.join(items)) 615 | await pipe.execute() 616 | 617 | async def shear(self) -> list[str]: 618 | """Shear available wool from the pet and return a receipt.""" 619 | async with context.bot.get().redis.pipeline() as pipe: 620 | await pipe.watch(self.id, self.space_id) 621 | try: 622 | fur = int(await pipe.hget(self.id, 'fur') or '') 623 | except ValueError: 624 | raise ReferenceError(self.id) from None 625 | values = await pipe.hmget(self.space_id, 'resources', 'tools') 626 | items = (values[0] or '').split() 627 | tools = (values[1] or '').split() 628 | if '✂️' not in tools: 629 | raise ValueError('No space tools item ✂️') 630 | 631 | pipe.multi() 632 | wool = [] 633 | if fur >= self.FUR_MAX: 634 | wool = ['🧶'] 635 | items = sorted(items + wool, key=Space.ITEM_WEIGHTS.__getitem__) 636 | pipe.hset(self.id, 'fur', 0) 637 | pipe.hset(self.space_id, 'resources', ' '.join(items)) 638 | await pipe.execute() 639 | return wool 640 | 641 | async def change_name(self, name: str) -> None: 642 | """Rename the pet to the given *name*.""" 643 | name = name.strip() 644 | if not name: 645 | raise ValueError(f'Blank name {name}') 646 | await context.bot.get().redis.hset(self.id, 'name', name) 647 | 648 | async def engage(self, activity: Furniture | str) -> None: 649 | """Engage the pet in the given *activity*.""" 650 | async with context.bot.get().redis.pipeline() as pipe: 651 | if isinstance(activity, Furniture): 652 | await pipe.watch(activity.id) 653 | if not await pipe.exists(activity.id): 654 | raise ReferenceError(activity.id) 655 | activity_id = activity.id 656 | else: 657 | if not (activity in self.ACTIVITIES or not activity): 658 | raise ValueError(f'Unknown activity {activity}') 659 | activity_id = activity 660 | pipe.multi() 661 | pipe.hset(self.id, 'activity_id', activity_id) 662 | await pipe.execute() 663 | 664 | if isinstance(activity, Furniture): 665 | await activity.use() 666 | 667 | def __str__(self) -> str: 668 | return f"🐕{self.clothing or ''}" 669 | 670 | @dataclass 671 | class Event: 672 | """Game event. 673 | 674 | .. attribute:: type 675 | 676 | Type of the event. 677 | 678 | .. attribute:: space_id 679 | 680 | ID of the :class:`Space` where the event happened. 681 | """ 682 | 683 | type: str 684 | space_id: str 685 | 686 | @staticmethod 687 | def parse(data: str) -> Event: 688 | """Parse the string representation *data* into an event.""" 689 | try: 690 | _, typ, space_id = data.split('␟') 691 | except ValueError: 692 | raise ValueError('Bad data format') from None 693 | return Event(typ, space_id) 694 | 695 | async def get_space(self) -> Space: 696 | """Get the space where the event happened.""" 697 | return await context.bot.get().get_space(self.space_id) 698 | 699 | def __str__(self) -> str: 700 | return '␟'.join([type(self).__name__, self.type, self.space_id]) 701 | 702 | @dataclass 703 | class Message: 704 | """Dialogue message. 705 | 706 | .. attribute:: id 707 | 708 | Message ID. 709 | 710 | .. attribute:: request 711 | 712 | Items the character currently wants, if any. 713 | 714 | .. attribute:: taken 715 | 716 | Items the player has just given to the character, if any. 717 | """ 718 | 719 | id: str 720 | request: list[str] = field(default_factory=list) 721 | taken: list[str] = field(default_factory=list, compare=False) 722 | 723 | def __post_init__(self) -> None: 724 | for item in self.request: 725 | if not any(item in items 726 | for category, items in Space.ITEM_CATEGORIES.items() if category != 'tools'): 727 | raise ValueError(f'Unknown request item {item}') 728 | for item in self.taken: 729 | if not any(item in items 730 | for category, items in Space.ITEM_CATEGORIES.items() if category != 'tools'): 731 | raise ValueError(f'Unknown taken item {item}') 732 | 733 | @staticmethod 734 | def parse(data: str) -> Message: 735 | """Parse the string representation *data* into a message.""" 736 | tokens = data.split() 737 | return Message(tokens[0], tokens[1:]) 738 | 739 | def encode(self) -> str: 740 | """Return a string representation of the message.""" 741 | return ' '.join([self.id, *self.request]) 742 | 743 | class Character: 744 | """Non-Player character. 745 | 746 | .. attribute:: id 747 | 748 | Character ID. 749 | 750 | .. attribute:: space_id 751 | 752 | Related :class:`Space` ID. 753 | 754 | .. attribute:: avatar 755 | 756 | Avatar emoji. 757 | """ 758 | 759 | def __init__(self, data: dict[str, str]) -> None: 760 | self.id = data['id'] 761 | self.space_id = data['space_id'] 762 | self.avatar = data['avatar'] 763 | 764 | async def get_dialogue(self) -> list[Message]: 765 | """Get the ongoing dialogue, starting from the most recent message.""" 766 | return [Message.parse(message) 767 | for message in await context.bot.get().redis.lrange(f'{self.id}.dialogue', 0, -1)] 768 | 769 | async def talk(self) -> Message: 770 | """Talk to the character and return their reply. 771 | 772 | If the character has requested some items, give the items to them or repeat the request 773 | message. The final dialogue message is always repeated. 774 | """ 775 | async with context.bot.get().redis.pipeline() as pipe: 776 | dialogue_key = f'{self.id}.dialogue' 777 | await pipe.watch(dialogue_key, self.space_id) 778 | messages = [Message.parse(message) 779 | for message in await pipe.lrange(dialogue_key, 0, 1)] 780 | message = messages[0] 781 | next_message = messages[1] if len(messages) > 1 else None 782 | items = (await pipe.hget(self.space_id, 'resources') or '').split() 783 | 784 | pipe.multi() 785 | if next_message is None: 786 | return message 787 | if message.request: 788 | try: 789 | for item in message.request: 790 | items.remove(item) 791 | next_message = dataclasses.replace(next_message, taken=message.request) 792 | except ValueError: 793 | return message 794 | pipe.ltrim(dialogue_key, 1, -1) 795 | pipe.hset(self.space_id, 'resources', ' '.join(items)) 796 | await pipe.execute() 797 | return next_message 798 | 799 | class Hike: 800 | """Hike minigame. 801 | 802 | .. attribute:: space 803 | 804 | Related space. 805 | 806 | .. attribute:: map 807 | 808 | Tile map. 809 | 810 | .. attribute:: moves 811 | 812 | Moves the player made so far. A move is a list of steps, where each step is a direction 813 | (➡️⬇️⬅️⬆️.) along with the encountered tile. 814 | 815 | .. attribute:: resource 816 | 817 | Resource available on the hike. May be ``None``. 818 | 819 | .. attribute:: gathered 820 | 821 | Resources the player has gathered so far. 822 | 823 | .. data:: RADIUS 824 | 825 | Radius of the map. 826 | 827 | .. data:: GROUND 828 | 829 | Ground tiles. 830 | 831 | .. data:: TREES 832 | 833 | Tree tiles. 834 | """ 835 | 836 | RADIUS = 4 837 | GROUND = {'🟩', '✴️'} 838 | TREES = {'🌲', '🌳'} 839 | 840 | _DISPLACEMENTS = {'➡️': (1, 0), '⬇️': (0, 1), '⬅️': (-1, 0), '⬆️': (0, -1)} 841 | _DIRECTIONS = {displacement: direction for direction, displacement in _DISPLACEMENTS.items()} 842 | 843 | def __init__(self, space: Space, *, resource: str | None = None) -> None: 844 | if ( 845 | not (resource is None or resource in Space.ITEM_CATEGORIES['resource'] or 846 | resource == '🥕') 847 | ): 848 | raise ValueError(f'Bad resource {resource}') 849 | 850 | self.space = space 851 | size = self.RADIUS * 2 + 1 852 | self.map = [[''] * size for _ in range(size)] 853 | self.resource = resource 854 | self.gathered: list[str] = [] 855 | self.moves: list[list[tuple[str, str]]] = [] 856 | self._revealed: set[tuple[int, int]] = set() 857 | self._generate_map() 858 | 859 | @property 860 | def finished(self) -> bool: 861 | """Indicates if the player found the destination.""" 862 | return bool(self.moves and self.moves[-1][-1][1] == '📍') 863 | 864 | def __str__(self) -> str: 865 | return self.text() 866 | 867 | async def move(self, directions: Collection[str]) -> list[tuple[str, str]]: 868 | """Move :data:`RADIUS` steps in the given *directions*. 869 | 870 | A description of the move is returned. If the destination is reached, the hike is recorded. 871 | """ 872 | if len(directions) != self.RADIUS: 873 | raise ValueError(f"Bad directions length [{', '.join(directions)}]") 874 | for direction in directions: 875 | if direction not in self._DISPLACEMENTS: 876 | raise ValueError(f'Bad directions item {direction}') 877 | if self.finished: 878 | raise ValueError('Finished hike') 879 | 880 | move = [] 881 | x, y = self.RADIUS, self.RADIUS 882 | for direction in directions: 883 | dx, dy = self._DISPLACEMENTS[direction] 884 | x, y = x + dx, y + dy 885 | tile = self.map[y][x] 886 | move.append((direction, tile)) 887 | self._revealed.add((x, y)) 888 | 889 | if tile in self.TREES or tile == '📍': 890 | break 891 | if tile == self.resource: 892 | self.gathered.append(tile) 893 | self.map[y][x] = '🟩' 894 | self.moves.append(move) 895 | 896 | if self.finished: 897 | await self.space.record_hike(self) 898 | return move 899 | 900 | def find_path(self, tile: str) -> list[str]: 901 | """Find directions to *tile*. 902 | 903 | If *tile* is not reachable, a :exc:`ValueError` is raised. 904 | """ 905 | queue = deque([[(self.RADIUS, self.RADIUS)]]) 906 | while queue: 907 | path = queue.pop() 908 | x, y = path[-1] 909 | distance = len(path) - 1 910 | 911 | if distance > self.RADIUS: 912 | continue 913 | if path.count((x, y)) > 1: 914 | continue 915 | if self.map[y][x] == tile: 916 | return [self._DIRECTIONS[(b[0] - a[0], b[1] - a[1])] 917 | for a, b in zip(path, path[1:])] 918 | 919 | for coords in self._get_adjacents(x, y): 920 | queue.appendleft(path + [coords]) 921 | raise ValueError(f'Unreachable tile {tile}') 922 | 923 | def text(self, *, revealed: bool = False) -> str: 924 | """Return a text representation of the map. 925 | 926 | Tiles not visited by the player so far are hidden, unless *revealed* is set. 927 | """ 928 | return '\n'.join( 929 | ''.join( 930 | tile if tile and ((x, y) in self._revealed or revealed) else '⬜' 931 | for x, tile in enumerate(row)) 932 | for y, row in enumerate(self.map)) 933 | 934 | def _get_adjacents(self, x: int, y: int) -> list[tuple[int, int]]: 935 | return ([] if self.map[y][x] in self.TREES 936 | else [(x + dx, y + dy) for dx, dy in self._DISPLACEMENTS.values()]) 937 | 938 | def _generate_map(self) -> None: 939 | # In taxicab geometry (https://en.wikipedia.org/wiki/Taxicab_geometry), a circle is a 940 | # rotated square with half the area of its circumscribed square 941 | area = int(len(self.map) ** 2 / 2) 942 | distances = self._generate_passable(round(area * 2 / 3)) 943 | passable = list(distances.items()) 944 | shuffle(passable) 945 | def get_distance(tile: tuple[tuple[int, int], int]) -> int: 946 | return tile[1] 947 | passable.sort(key=get_distance) 948 | 949 | # Place trees 950 | for y, row in enumerate(self.map): 951 | for x, _ in enumerate(row): 952 | if abs(self.RADIUS - x) + abs(self.RADIUS - y) <= self.RADIUS: 953 | self.map[y][x] = '🌳' if random.random() < 0.25 else '🌲' 954 | 955 | # Place ground 956 | for coords, _ in passable: 957 | x, y = coords 958 | self.map[y][x] = '🟩' 959 | 960 | # Place origin 961 | x, y = passable.pop(0)[0] 962 | self.map[y][x] = '✴️' 963 | self._revealed.add((x, y)) 964 | 965 | # Place destination 966 | x, y = passable.pop()[0] 967 | self.map[y][x] = '📍' 968 | self._revealed.add((x, y)) 969 | 970 | # Place resource 971 | if self.resource: 972 | x, y = random.choice(passable)[0] 973 | self.map[y][x] = self.resource 974 | 975 | def _generate_passable(self, count: int) -> dict[tuple[int, int], int]: 976 | distances: dict[tuple[int, int], int] = {} 977 | bucket = deque([[(self.RADIUS, self.RADIUS)]]) 978 | while bucket: 979 | path = bucket.pop() 980 | x, y = path[-1] 981 | distance = len(path) - 1 982 | 983 | if distance > self.RADIUS: 984 | continue 985 | if path.count((x, y)) > 1: 986 | continue 987 | if sum(coords in path for coords in self._get_adjacents(x, y)) > 1: 988 | continue 989 | if len(distances) >= count and (x, y) not in distances: 990 | continue 991 | if distance < distances.get((x, y), sys.maxsize): 992 | distances[(x, y)] = distance 993 | 994 | for coords in self._get_adjacents(x, y): 995 | # Note that a flat random bucket is slightly biased towards already visited paths. 996 | # If needed, this could be improved with a recursive random bucket. 997 | bucket.insert(randint(0, len(bucket)), path + [coords]) 998 | return distances 999 | -------------------------------------------------------------------------------- /feini/actions.py: -------------------------------------------------------------------------------- 1 | # Open Feini 2 | # Copyright (C) 2022 Open Feini contributors 3 | # 4 | # This program is free software: you can redistribute it and/or modify it under the terms of the GNU 5 | # Affero General Public License as published by the Free Software Foundation, either version 3 of 6 | # the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without 9 | # even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 10 | # Affero General Public License for more details. 11 | # 12 | # You should have received a copy of the GNU Affero General Public License along with this program. 13 | # If not, see . 14 | 15 | """Player actions.""" 16 | 17 | # Message style guide: The pet is described from an outside perspective, e.g. "Feini looks happy" or 18 | # "Feini is playing happily" instead of "Feini is excited". 19 | 20 | # pylint: disable=missing-function-docstring,unused-argument 21 | 22 | from __future__ import annotations 23 | 24 | from collections.abc import Awaitable, Callable 25 | from functools import partial 26 | from gettext import NullTranslations 27 | from inspect import getmembers 28 | import random 29 | from random import randint 30 | from textwrap import dedent 31 | from typing import ClassVar, Generic, Protocol, TypeVar, cast, overload 32 | import unicodedata 33 | 34 | from . import context 35 | from .furniture import Furniture, Houseplant, Newspaper, Palette, Television, FURNITURE_MATERIAL 36 | from .space import Event, Hike, Pet, Space, CHARACTER_NAMES 37 | from .util import isemoji 38 | 39 | ngettext = NullTranslations().ngettext 40 | 41 | _M_contra = TypeVar('_M_contra', bound='Mode', contravariant=True) 42 | 43 | class _ActionCallable(Protocol[_M_contra]): 44 | async def __call__(_, self: _M_contra, space: Space, *args: str) -> str: 45 | pass 46 | 47 | class _ActionMethod(Protocol): 48 | async def __call__(self, space: Space, *args: str) -> str: 49 | pass 50 | 51 | class Action(Generic[_M_contra]): 52 | """Perform a player action with the arguments *args* in *space*. 53 | 54 | A reaction message is returned. 55 | 56 | Actions are performed sequentially in a space, thus there are no race conditions. 57 | 58 | .. attribute:: func 59 | 60 | Annotated function. 61 | 62 | .. attribute:: name 63 | 64 | Name of the action. 65 | """ 66 | 67 | def __init__(self, func: _ActionCallable[_M_contra], name: str) -> None: 68 | self.func = func 69 | self.name = name 70 | 71 | @overload 72 | def __get__(self, instance: None, owner: type[_M_contra] | None = None) -> Action[_M_contra]: 73 | pass 74 | @overload 75 | def __get__(self, instance: _M_contra, owner: type[_M_contra] | None = None) -> _ActionMethod: 76 | pass 77 | def __get__(self, instance: _M_contra | None, 78 | owner: type[_M_contra] | None = None) -> Action[_M_contra] | _ActionMethod: 79 | return self if instance is None else partial(self.func, instance) 80 | 81 | def action(name: str) -> Callable[[_ActionCallable[_M_contra]], Action[_M_contra]]: 82 | """Decorator to define a player action called *name*.""" 83 | return cast(Callable[[_ActionCallable[_M_contra]], Action[_M_contra]], 84 | partial(Action, name=name)) 85 | 86 | class Mode: 87 | """Chat mode comprising a set of player actions.""" 88 | 89 | _actions: ClassVar[dict[str, Action[Mode]]] 90 | 91 | def __init__(self) -> None: 92 | if not hasattr(self, '_actions'): 93 | cls = type(self) 94 | def isaction(obj: object) -> bool: 95 | return isinstance(obj, Action) 96 | members = cast(list[tuple[str, Action[Mode]]], getmembers(cls, isaction)) 97 | cls._actions = {action.name: action for _, action in members} 98 | 99 | async def perform(self, space: Space, *args: str) -> str: 100 | """Perform the action given by the arguments *args* in *space*. 101 | 102 | A reaction message is returned. 103 | """ 104 | try: 105 | # pylint: disable=unnecessary-dunder-call 106 | f = self._actions[normalize_emoji(args[0])].__get__(self) 107 | except (KeyError, IndexError): 108 | f = self.default 109 | return await f(space, *args) 110 | 111 | async def default(self, space: Space, *args: str) -> str: 112 | """Perform the default action if no other available action matches.""" 113 | raise NotImplementedError() 114 | 115 | class _FurnitureActionCallable(Protocol): 116 | async def __call__(_, self: MainMode, space: Space, piece: Furniture, *args: str) -> str: 117 | pass 118 | 119 | def item_action(item: str) -> Callable[[_ActionCallable[MainMode]], Action[MainMode]]: 120 | """Decorator to define a player action with an *item*. 121 | 122 | If the player does not have such an item, an appropriate message is returned. 123 | """ 124 | def decorator(func: _ActionCallable[MainMode]) -> Action[MainMode]: 125 | async def wrapper(self: MainMode, space: Space, *args: str) -> str: 126 | if not (item in space.items or item in space.tools): 127 | return await self.default(space, *args) 128 | return await func(self, space, *args) 129 | return Action(wrapper, name=item) 130 | return decorator 131 | 132 | def furniture_action(furniture_type: str) -> Callable[[_FurnitureActionCallable], Action[MainMode]]: 133 | """Decorator to define a player action with a *furniture_type* piece of furniture. 134 | 135 | *piece* is the relevant piece of furniture. If there is no such piece of furniture, an 136 | appropriate message is returned. 137 | """ 138 | def decorator(func: _FurnitureActionCallable) -> Action[MainMode]: 139 | async def wrapper(self: MainMode, space: Space, *args: str) -> str: 140 | piece = next( 141 | (piece for piece in await space.get_furniture() if piece.type == furniture_type), 142 | None) 143 | if not piece: 144 | return await self.default(space, *args) 145 | return await func(self, space, piece, *args) 146 | return Action(wrapper, name=furniture_type) 147 | return decorator 148 | 149 | class EventMessageFunc: 150 | """Write a message about an *event*. 151 | 152 | .. attribute:: func 153 | 154 | Annotated function. 155 | 156 | .. attribute:: event_type 157 | 158 | Type of event handled by the function. 159 | """ 160 | 161 | def __init__(self, func: Callable[[Event], Awaitable[str]], event_type: str) -> None: 162 | self.func = func 163 | self.event_type = event_type 164 | 165 | async def __call__(self, event: Event) -> str: 166 | return await self.func(event) 167 | 168 | def event_message( 169 | event_type: str 170 | ) -> Callable[[Callable[[Event], Awaitable[str]]], EventMessageFunc]: 171 | """Decorator to define an event message function about *event_type* events.""" 172 | return partial(EventMessageFunc, event_type=event_type) 173 | 174 | _EMOJI_VARIANTS = { 175 | '🪨': ['🧱'], 176 | '🧶': ['🧵', '🪢'], 177 | '🎧': ['🎧\N{VARIATION SELECTOR-15}', '🎧\N{VARIATION SELECTOR-16}'], 178 | '👓': ['👓\N{VARIATION SELECTOR-15}', '👓\N{VARIATION SELECTOR-16}'], 179 | '🕶️': ['🕶', '🕶\N{VARIATION SELECTOR-15}'], 180 | '👋': ['🤚', '🖐️', '🖐', '🖐\N{VARIATION SELECTOR-15}', '✋', '🖖'], 181 | '✏️': ['✏', '✏\N{VARIATION SELECTOR-15}', '✒️', '✒', '✒\N{VARIATION SELECTOR-15}', '🖋️', '🖋', 182 | '🖋\N{VARIATION SELECTOR-15}', '🖊️', '🖊', '🖊\N{VARIATION SELECTOR-15}'], 183 | '🧺': ['🪣'], 184 | '✂️': ['✂', '✂\N{VARIATION SELECTOR-15}'], 185 | '🔨': ['⚒️', '⚒', '⚒\N{VARIATION SELECTOR-15}', '🛠️', '🛠', '🛠\N{VARIATION SELECTOR-15}'], 186 | '🍳': ['🔪'], 187 | '🧽': ['🧴', '🧼'], 188 | '⚾': ['⚾\N{VARIATION SELECTOR-15}', '⚾\N{VARIATION SELECTOR-16}', '🥎'], 189 | '🛋️': ['🛋', '🛋\N{VARIATION SELECTOR-15}'], 190 | '⛲': ['⛲\N{VARIATION SELECTOR-15}', '⛲\N{VARIATION SELECTOR-16}'], 191 | '📺': ['📺\N{VARIATION SELECTOR-15}', '📺\N{VARIATION SELECTOR-16}'], 192 | '🗞️': ['🗞', '🗞\N{VARIATION SELECTOR-15}', '📰'], 193 | '🎨': ['🖌️', '🖌', '🖌\N{VARIATION SELECTOR-15}'], 194 | '⛺': ['⛺\N{VARIATION SELECTOR-15}', '⛺\N{VARIATION SELECTOR-16}', '🏕️', '🏕', 195 | '🏕\N{VARIATION SELECTOR-15}'], 196 | '➡️': ['➡', '➡\N{VARIATION SELECTOR-15}'], 197 | '⬇️': ['⬇', '⬇\N{VARIATION SELECTOR-15}'], 198 | '⬅️': ['⬅', '⬅\N{VARIATION SELECTOR-15}'], 199 | '⬆️': ['⬆', '⬆\N{VARIATION SELECTOR-15}'], 200 | '🔙': ['🔚'], 201 | '✴️': ['✴', '✴\N{VARIATION SELECTOR-15}'], 202 | '📍': ['📌'] 203 | } 204 | _EMOJI_NORMAL_FORMS = { 205 | variant: emoji for emoji, variants in _EMOJI_VARIANTS.items() for variant in variants 206 | } 207 | 208 | def normalize_emoji(emoji: str) -> str: 209 | """Normalize the given *emoji*. 210 | 211 | A definite emoji is used for variants expressing the same concept. The most compact emoji 212 | presentation is used for variation sequences. 213 | """ 214 | return _EMOJI_NORMAL_FORMS.get(emoji) or emoji 215 | 216 | def speak() -> str: 217 | """Generate pet speech.""" 218 | return ' '.join(random.choice(['Woof!', 'Arf!']) for _ in range(randint(1, 2))) 219 | 220 | def pet_message(pet: Pet, text: str, *, focus: str = '', mood: str = '') -> str: 221 | """Write a message about *pet* containing *text*. 222 | 223 | *focus* is an optional emoji for something the pet is focused on. *mood* is an optional emoji 224 | conveying the mood of the message. 225 | """ 226 | if focus and not isemoji(focus): 227 | raise ValueError(f'Bad focus {focus}') 228 | if mood and not isemoji(mood): 229 | raise ValueError(f'Bad mood {mood}') 230 | return f'{focus}{pet} {text} {mood}'.strip() 231 | 232 | class MainMode(Mode): 233 | """Main chat mode.""" 234 | 235 | _DIALOGUE = { 236 | 'ghost-sewing-hello': ['Where am I?'], 237 | 'ghost-sewing-daughter': [ 238 | '(Ghost looks at a piece of cloth in their hands) The last thing I remember is sitting ' 239 | 'in my chair, making a scarf for my daughter. She always used to like those… I think…' 240 | ], 241 | 'ghost-sewing-request': [ 242 | 'Dear, do you know where I could find {items} to finish this scarf?', 243 | 'If I only had {items}, I could finish this scarf.' 244 | ], 245 | 'ghost-sewing-blueprint': [ 246 | '(You give {items} to Ghost) Thank you so much, dear! Please, let me return the favor ' 247 | 'and tell you a few things about sewing! (You get a sewing needle blueprint 📋)' 248 | ], 249 | 'ghost-sewing-goodbye': [ 250 | 'Do you think she will forgive me? (Ghost slowly vanishes into thin air)' 251 | ] 252 | } 253 | 254 | @action('⛺') 255 | async def view_home(self, space: Space, *args: str) -> str: 256 | furniture = ''.join(str(piece) for piece in await space.get_furniture()) 257 | characters = ''.join(character.avatar for character in await space.get_characters()) 258 | return dedent(f"""\ 259 | ⛺{furniture} {characters} 260 | 261 | Items: 262 | {''.join(space.items) or '-'} 263 | Tools: 264 | {''.join(space.tools)} 265 | """) 266 | 267 | async def _view_resource(self, space: Space, *args: str) -> str: 268 | resource = normalize_emoji(args[0]) 269 | return random.choice([f'{resource} Good quality!', f'{resource} Beautiful!']) 270 | 271 | view_resource = item_action('🪨')(_view_resource) 272 | _view_wood = item_action('🪵')(_view_resource) 273 | _view_wool = item_action('🧶')(_view_resource) 274 | 275 | @action('obtain') 276 | async def obtain(self, space: Space, *args: str) -> str: 277 | items = [normalize_emoji(item) for item in args[1:]] or ['_'] 278 | try: 279 | await space.obtain(*items) 280 | except ValueError as e: 281 | if 'debug' in str(e): 282 | return await self.default(space, *args) 283 | if 'items' in str(e): 284 | items = [item for items in Space.ITEM_CATEGORIES.values() for item in items] 285 | return dedent(f"""\ 286 | obtain ⬜Item ⬜… 287 | Obtain some items ({''.join(items)}). 288 | """) 289 | raise 290 | return 'You stock up. 😅' 291 | 292 | @item_action('🧺') 293 | async def gather(self, space: Space, *args: str) -> str: 294 | resources = await space.gather() 295 | if not resources: 296 | return 'The meadow is empty. Maybe try again later?' 297 | return f"🧺 You gather {''.join(resources)} from the meadow. 😊" 298 | 299 | @item_action('🪓') 300 | async def chop_wood(self, space: Space, *args: str) -> str: 301 | wood = await space.chop_wood() 302 | if not wood: 303 | return 'There are no more logs in the woods. Maybe try again later?' 304 | return f"🪓 You chop {''.join(wood)} from the woods. 😊" 305 | 306 | @item_action('🔨') 307 | async def craft(self, space: Space, *args: str) -> str: 308 | try: 309 | blueprint = normalize_emoji(args[1]) 310 | except IndexError: 311 | blueprint = '' 312 | material = ''.join((Space.TOOL_MATERIAL | FURNITURE_MATERIAL).get(blueprint) or '') 313 | 314 | try: 315 | await space.craft(blueprint) 316 | return f'🔨 You spend {material} to craft a new {blueprint}. 🥳' 317 | 318 | except ValueError as e: 319 | if 'blueprint' in str(e): 320 | blueprints = await space.get_blueprints() 321 | catalog = { 322 | 'Tools': {blueprint: cost for blueprint in blueprints 323 | if (cost := Space.TOOL_MATERIAL.get(blueprint))}, 324 | 'Furniture': {blueprint: cost for blueprint in blueprints 325 | if (cost := FURNITURE_MATERIAL.get(blueprint))} 326 | } 327 | line_break = '\n ' 328 | catalog_blocks = { 329 | category: 330 | line_break.join(f"{blueprint}: {''.join(cost)}" 331 | for blueprint, cost in blueprints.items()) 332 | for category, blueprints in catalog.items() 333 | } 334 | catalog_text = line_break.join(f'{category}:{line_break}{block}' 335 | for category, block in catalog_blocks.items()) 336 | return dedent(f"""\ 337 | 🔨 ⬜Item 338 | Craft a new item. 339 | 340 | {catalog_text} 341 | """) 342 | 343 | if 'items' in str(e): 344 | return f'You need {material} to craft a {blueprint}.' 345 | raise 346 | 347 | @item_action('🪡') 348 | async def sew(self, space: Space, *args: str) -> str: 349 | try: 350 | pattern = normalize_emoji(args[1]) 351 | except IndexError: 352 | pattern = '' 353 | 354 | try: 355 | material = ''.join(space.CLOTHING_MATERIAL[pattern]) 356 | except KeyError: 357 | clothes = '\n '.join( 358 | f"{pattern}: {''.join(material)}" 359 | for pattern, material in Space.CLOTHING_MATERIAL.items()) 360 | return dedent(f"""\ 361 | 🪡 ⬜Item 362 | Sew a new clothing item. 363 | 364 | Clothes: 365 | {clothes} 366 | """) 367 | 368 | try: 369 | await space.sew(pattern) 370 | return f'🪡 You spend {material} to sew a new {pattern}. 🥳' 371 | except ValueError as e: 372 | if 'items' in str(e): 373 | return f'You need {material} to sew a {pattern}.' 374 | raise 375 | 376 | @item_action('🍳') 377 | async def cook(self, space: Space, *args: str) -> str: 378 | try: 379 | dish = await space.cook() 380 | except ValueError: 381 | return 'You need some veggies 🥕 to cook.' 382 | return f'🍳 You use 🥕 to prepare a {dish} dish. 😊' 383 | 384 | @item_action('🧭') 385 | async def hike(self, space: Space, *args: str) -> str: 386 | mode = HikeMode(await space.hike()) 387 | context.bot.get().set_mode(space.chat, mode) 388 | return await mode.default(space) 389 | 390 | async def _try_hike(self, space: Space, *args: str) -> str: 391 | return 'You could use a compass 🧭 to hike.' 392 | 393 | try_hike = action('➡️')(_try_hike) 394 | _try_hike_move_south = action('⬇️')(_try_hike) 395 | _try_hike_move_west = action('⬅️')(_try_hike) 396 | _try_hike_move_north = action('⬆️')(_try_hike) 397 | _try_hike_stop = action('🔙')(_try_hike) 398 | _try_hike_green = action('🟩')(_try_hike) 399 | _try_hike_origin = action('✴️')(_try_hike) 400 | _try_hike_tree_a = action('🌲')(_try_hike) 401 | _try_hike_tree_b = action('🌳')(_try_hike) 402 | _try_hike_destination = action('📍')(_try_hike) 403 | 404 | @item_action('👋') 405 | async def touch_pet(self, space: Space, *args: str) -> str: 406 | pet = await space.get_pet() 407 | await pet.touch() 408 | 409 | if not pet.hatched: 410 | return f'🥚 Crack! {pet.name} 🐕 hatched from the egg. It looks around curiously. 😊' 411 | if pet.nutrition <= 0: 412 | return pet_message(pet, f'{pet.name} looks hungry.', focus='🍽️') 413 | if pet.dirt >= pet.DIRT_MAX: 414 | return pet_message(pet, f'{pet.name} is pretty dirty.', focus='💩') 415 | 416 | activity = await pet.get_activity() 417 | activity_type = activity.type if isinstance(activity, Furniture) else activity 418 | try: 419 | func = self._ACTIVITY_MESSAGES[activity_type] 420 | except KeyError: 421 | return random.choice([ 422 | pet_message(pet, f'{pet.name} wags its tail.'), pet_message(pet, speak())]) 423 | return await func(self, space, activity) 424 | 425 | async def _feed_pet(self, space: Space, *args: str) -> str: 426 | food = normalize_emoji(args[0]) 427 | pet = await space.get_pet() 428 | 429 | try: 430 | await pet.feed(food) 431 | except ValueError as e: 432 | if 'nutrition' in str(e): 433 | return pet_message(pet, f'{pet.name} seems full and ignores the {food} food.') 434 | raise 435 | 436 | if food == '🍲': 437 | return random.choice([ 438 | pet_message(pet, f'{pet.name} relishes the dish.', focus=food, mood='😍'), 439 | pet_message(pet, f'{pet.name} digs in.', focus=food, mood='😍') 440 | ]) 441 | return random.choice([ 442 | pet_message(pet, f'{pet.name} enjoys its food.', focus=food, mood='😊'), 443 | pet_message(pet, f'{pet.name} digs in.', focus=food, mood='😊') 444 | ]) 445 | 446 | feed_pet = item_action('🥕')(_feed_pet) 447 | _feed_pet_stew = item_action('🍲')(_feed_pet) 448 | 449 | async def _wash_pet(self, space: Space, *args: str) -> str: 450 | tool = normalize_emoji(args[0]) 451 | pet = await space.get_pet() 452 | try: 453 | await pet.wash() 454 | except ValueError: 455 | return pet_message(pet, f'{pet.name} is clean and politely refuses.') 456 | 457 | if tool == '🚿': 458 | return random.choice([ 459 | pet_message(pet, f'{pet.name} relaxes in the spray of warm water.', focus=tool, 460 | mood='😍'), 461 | pet_message(pet, f'You wash {pet.name} thoroughly.', focus=tool, mood='😍') 462 | ]) 463 | return random.choice([ 464 | pet_message(pet, f'{pet.name} waits patiently while you scrub it clean.', focus=tool, 465 | mood='😊'), 466 | pet_message(pet, f'You wash {pet.name} thoroughly.', focus=tool, mood='😊') 467 | ]) 468 | 469 | wash_pet = item_action('🧽')(_wash_pet) 470 | _wash_pet_shower = item_action('🚿')(_wash_pet) 471 | 472 | async def _dress_pet(self, space: Space, *args: str) -> str: 473 | clothing = normalize_emoji(args[0]) 474 | pet = await space.get_pet() 475 | 476 | if pet.clothing == clothing: 477 | await pet.dress(None) 478 | pet = await space.get_pet() 479 | return pet_message(pet, f"{pet.name} lets you take off the {clothing}.", mood='😊') 480 | 481 | await pet.dress(clothing) 482 | pet = await space.get_pet() 483 | return random.choice([ 484 | pet_message(pet, f'{pet.name} looks very pretty.', mood='😊'), 485 | pet_message(pet, f'{pet.name} looks happy with its {clothing}.', mood='😊') 486 | ]) 487 | 488 | dress_pet = item_action('🧢')(_dress_pet) 489 | _dress_pet_sun_hat = item_action('👒')(_dress_pet) 490 | _dress_pet_headphones = item_action('🎧')(_dress_pet) 491 | _dress_pet_glasses = item_action('👓')(_dress_pet) 492 | _dress_pet_sunglasses = item_action('🕶️')(_dress_pet) 493 | _dress_pet_goggles = item_action('🥽')(_dress_pet) 494 | _dress_pet_scarf = item_action('🧣')(_dress_pet) 495 | _dress_pet_ribbon = item_action('🎀')(_dress_pet) 496 | _dress_pet_ring = item_action('💍')(_dress_pet) 497 | 498 | @item_action('✂️') 499 | async def shear_pet(self, space: Space, *args: str) -> str: 500 | pet = await space.get_pet() 501 | wool = await pet.shear() 502 | if not wool: 503 | return pet_message(pet, f'{pet.name} is reluctant. Maybe try again later?') 504 | return pet_message(pet, f"You gently cut {''.join(wool)} from {pet.name}.", focus='✂️', 505 | mood='😊') 506 | 507 | @item_action('✏️') 508 | async def change_name_of_pet(self, space: Space, *args: str) -> str: 509 | pet = await space.get_pet() 510 | try: 511 | name = args[1] 512 | except IndexError: 513 | name = None 514 | if not name or isemoji(name): 515 | return dedent(f"""\ 516 | ✏️ ⬜Name 517 | Change the name of {pet.name}. 518 | """) 519 | 520 | await pet.change_name(name) 521 | pet = await pet.get() 522 | return random.choice([ 523 | pet_message(pet, f'{pet.name} looks happy with its new name.', focus='✏️', mood='😊'), 524 | pet_message(pet, f'{pet.name} approves its new name.', focus='✏️', mood='😊') 525 | ]) 526 | 527 | @furniture_action('🪃') 528 | async def engange_pet_boomerang(self, space: Space, piece: Furniture, *args: str) -> str: 529 | pet = await space.get_pet() 530 | await pet.engage(piece) 531 | return random.choice([ 532 | pet_message(pet, f'{pet.name} starts after the boomerang. {speak()}', focus=str(piece), 533 | mood='😊'), 534 | pet_message(pet, f'{pet.name} snatches the boomerong as it returns.', focus=str(piece), 535 | mood='😊') 536 | ]) 537 | 538 | @furniture_action('⚾') 539 | async def engage_pet_ball(self, space: Space, piece: Furniture, *args: str) -> str: 540 | pet = await space.get_pet() 541 | await pet.engage(piece) 542 | return random.choice([ 543 | pet_message(pet, f'You throw the ball for {pet.name}. {speak()}', focus=str(piece), 544 | mood='😊'), 545 | pet_message(pet, f'{pet.name} goes to fetch the ball.', focus=str(piece), mood='😊') 546 | ]) 547 | 548 | @furniture_action('🧸') 549 | async def engage_pet_teddy(self, space: Space, piece: Furniture, *args: str) -> str: 550 | pet = await space.get_pet() 551 | await pet.engage(piece) 552 | return random.choice([ 553 | pet_message(pet, f'{pet.name} guards its teddy.', focus=str(piece)), 554 | pet_message(pet, 'Grrr!', focus=str(piece)) 555 | ]) 556 | 557 | @furniture_action('🛋️') 558 | async def engage_pet_couch(self, space: Space, piece: Furniture, *args: str) -> str: 559 | pet = await space.get_pet() 560 | await pet.engage(piece) 561 | return random.choice([ 562 | pet_message(pet, f'{pet.name} comes over as you pat the couch.', focus=str(piece), 563 | mood='😊'), 564 | pet_message(pet, f'{pet.name} jumps on the couch.', focus=str(piece), mood='😊') 565 | ]) 566 | 567 | @furniture_action('🪴') 568 | async def view_houseplant(self, space: Space, piece: Furniture, *args: str) -> str: 569 | assert isinstance(piece, Houseplant) 570 | if piece.state == '🌺': 571 | return f'{piece} The plant is in full bloom.' 572 | return f'{piece} The plant looks well-cared-for. Is that a new leaf?' 573 | 574 | @furniture_action('⛲') 575 | async def engage_pet_fountain(self, space: Space, piece: Furniture, *args: str) -> str: 576 | pet = await space.get_pet() 577 | await pet.engage(piece) 578 | return random.choice([ 579 | pet_message(pet, f'You splash some water on {pet.name}. {speak()}', focus=str(piece), 580 | mood='😊'), 581 | pet_message(pet, f'{pet.name} dodges as you splash water around.', focus=str(piece), 582 | mood='😊') 583 | ]) 584 | 585 | @furniture_action('📺') 586 | async def view_television(self, space: Space, piece: Furniture, *args: str) -> str: 587 | assert isinstance(piece, Television) 588 | parts = [f'📺 “{piece.show.title}” is on.', f'({piece.show.url})'] 589 | if piece.show.summary: 590 | parts.insert(1, piece.show.summary) 591 | return ' '.join(parts) 592 | 593 | @furniture_action('🗞️') 594 | async def view_newspaper(self, space: Space, piece: Furniture, *args: str) -> str: 595 | assert isinstance(piece, Newspaper) 596 | period = '' if unicodedata.category(piece.article.title[-1]).startswith('P') else '.' 597 | parts = [f'🗞️ {piece.article.title}{period}', f'({piece.article.url})'] 598 | if piece.article.summary: 599 | parts.insert(1, f'{piece.article.summary}') 600 | return ' '.join(parts) 601 | 602 | @furniture_action('🎨') 603 | async def view_palette(self, space: Space, piece: Furniture, *args: str) -> str: 604 | assert isinstance(piece, Palette) 605 | if piece.state == '🖼️': 606 | return (f'{piece} The painting is composed of abstract patterns in vibrant colors, ' 607 | 'which still evoke a delicate impression of reality.') 608 | return f'{piece} There are some thick brushstrokes along the canvas.' 609 | 610 | @action('👻') 611 | async def talk_to_character(self, space: Space, *args: str) -> str: 612 | avatar = normalize_emoji(args[0]) 613 | character = next( 614 | (character for character in await space.get_characters() if character.avatar == avatar), 615 | None) 616 | if not character: 617 | return f'{CHARACTER_NAMES[avatar]} {avatar} is not here at the moment.' 618 | 619 | message = await character.talk() 620 | text = random.choice(self._DIALOGUE[message.id]) 621 | if message.taken: 622 | text = text.replace('{items}', ''.join(message.taken)) 623 | elif message.request: 624 | text = text.replace('{items}', ''.join(message.request)) 625 | return f'{avatar} {text}' 626 | 627 | async def default(self, space: Space, *args: str) -> str: 628 | word = normalize_emoji(args[0]) 629 | if not isemoji(word): 630 | word = f'“{word}”' 631 | return f'You have no {word} at the moment. You can see your inventory in the tent ⛺.' 632 | 633 | async def _sleep_message(self, space: Space, activity: Furniture | str) -> str: 634 | assert isinstance(activity, str) 635 | pet = await space.get_pet() 636 | return random.choice([ 637 | pet_message(pet, f'{pet.name} is taking a nap.', focus=activity), 638 | pet_message(pet, f'{pet.name} is snoring to itself.', focus=activity) 639 | ]) 640 | 641 | async def _leaves_message(self, space: Space, activity: Furniture | str) -> str: 642 | assert isinstance(activity, str) 643 | pet = await space.get_pet() 644 | return random.choice([ 645 | pet_message(pet, f'{pet.name} is chasing after some leaves. {speak()}', focus=activity), 646 | pet_message(pet, f'{pet.name} is playing outdoors.', focus=activity) 647 | ]) 648 | 649 | async def _boomerang_message(self, space: Space, activity: Furniture | str) -> str: 650 | pet = await space.get_pet() 651 | return random.choice([ 652 | pet_message(pet, f'{pet.name} is carrying the boomerang around.', focus=str(activity)), 653 | pet_message(pet, f'{pet.name} is gnawing on the boomerang.', focus=str(activity)) 654 | ]) 655 | 656 | async def _ball_message(self, space: Space, activity: Furniture | str) -> str: 657 | pet = await space.get_pet() 658 | return random.choice([ 659 | pet_message(pet, f'{pet.name} is playing with the ball. {speak()}', 660 | focus=str(activity)), 661 | pet_message(pet, f'{pet.name} is occupied with the ball.', focus=str(activity)) 662 | ]) 663 | 664 | async def _teddy_message(self, space: Space, activity: Furniture | str) -> str: 665 | pet = await space.get_pet() 666 | return random.choice([ 667 | pet_message(pet, f'{pet.name} is cuddling with its teddy.', focus=str(activity)), 668 | pet_message(pet, f'{pet.name} looks very fond of its teddy.', focus=str(activity)) 669 | ]) 670 | 671 | async def _couch_message(self, space: Space, activity: Furniture | str) -> str: 672 | pet = await space.get_pet() 673 | return random.choice([ 674 | pet_message(pet, f'{pet.name} is relaxing on the couch.', focus=str(activity)), 675 | pet_message(pet, f'{pet.name} is briefly resting its eyes.', focus=str(activity)) 676 | ]) 677 | 678 | async def _houseplant_message(self, space: Space, activity: Furniture | str) -> str: 679 | assert isinstance(activity, Houseplant) 680 | pet = await space.get_pet() 681 | if activity.state == '🌺': 682 | text = f'{pet.name} is smelling the fresh blossoms.' 683 | else: 684 | text = f'{pet.name} is carefully watering the houseplant.' 685 | return pet_message(pet, text, focus=str(activity)) 686 | 687 | async def _fountain_message(self, space: Space, activity: Furniture | str) -> str: 688 | pet = await space.get_pet() 689 | return random.choice([ 690 | pet_message(pet, f'{pet.name} is splashing around in the fountain. {speak()}', 691 | focus=str(activity)), 692 | pet_message(pet, f'{pet.name} is dipping its paws in the water.', focus=str(activity)) 693 | ]) 694 | 695 | async def _television_message(self, space: Space, activity: Furniture | str) -> str: 696 | assert isinstance(activity, Television) 697 | pet = await space.get_pet() 698 | return pet_message(pet, f'{pet.name} is hooked by {activity.show.title}.', 699 | focus=str(activity)) 700 | 701 | async def _newspaper_message(self, space: Space, activity: Furniture | str) -> str: 702 | assert isinstance(activity, Newspaper) 703 | pet = await space.get_pet() 704 | period = '' if unicodedata.category(activity.article.title[-1]).startswith('P') else '.' 705 | return pet_message( 706 | pet, f'{pet.name} is reading an article. {activity.article.title}{period}', 707 | focus=str(activity)) 708 | 709 | async def _palette_message(self, space: Space, activity: Furniture | str) -> str: 710 | assert isinstance(activity, Palette) 711 | pet = await space.get_pet() 712 | if activity.state == '🖼️': 713 | text = f'{pet.name} looks very content with its painting.' 714 | else: 715 | text = f'{pet.name} is painting something with passion.' 716 | return pet_message(pet, text, focus=str(activity)) 717 | 718 | _ACTIVITY_MESSAGES: dict[str, Callable[[MainMode, Space, Furniture | str], Awaitable[str]]] = { 719 | '💤': _sleep_message, 720 | '🍃': _leaves_message, 721 | '🪃': _boomerang_message, 722 | '⚾': _ball_message, 723 | '🧸': _teddy_message, 724 | '🛋️': _couch_message, 725 | '🪴': _houseplant_message, 726 | '⛲': _fountain_message, 727 | '📺': _television_message, 728 | '🗞️': _newspaper_message, 729 | '🎨': _palette_message 730 | } 731 | 732 | class HikeMode(Mode): 733 | """Hike minigame chat mode. 734 | 735 | .. attribute:: hike 736 | 737 | Active hike. 738 | """ 739 | 740 | # pylint: disable=unused-argument 741 | 742 | def __init__(self, hike: Hike) -> None: 743 | super().__init__() 744 | self.hike = hike 745 | 746 | async def _move(self, space: Space, *args: str) -> str: 747 | try: 748 | move = await self.hike.move([normalize_emoji(direction) for direction in args]) 749 | except ValueError as e: 750 | if 'directions' in str(e): 751 | return await self.default(space) 752 | raise 753 | 754 | pet = await space.get_pet() 755 | emoji = ''.join(item for step in move for item in step) 756 | 757 | end = move[-1][1] 758 | parts = [] 759 | if end in Hike.GROUND: 760 | parts.append( 761 | random.choice([ 762 | "Apparently that wasn't the right way. 😵‍💫", 763 | "You missed a turn somewhere. 😵‍💫" 764 | ])) 765 | elif end in Hike.TREES: 766 | parts.append( 767 | random.choice([ 768 | f'{pet.name} was blocked by a tree.', 769 | f'{pet.name} got stuck in the thicket.' 770 | ])) 771 | elif end == '📍': 772 | moves = len(self.hike.moves) 773 | parts.append( 774 | ngettext( 775 | 'You finished the hike in 1 move. 🥳', 776 | 'You finished the hike in {moves} moves. 🥳', moves 777 | ).format(moves=moves)) 778 | else: 779 | assert False 780 | if any(tile == self.hike.resource for _, tile in move): 781 | parts.append( 782 | random.choice([ 783 | f'{pet.name} found a {self.hike.resource}. 😊', 784 | f'{pet.name} fetched a {self.hike.resource} en route. 😊' 785 | ])) 786 | 787 | trail = '' 788 | if self.hike.finished: 789 | context.bot.get().set_mode(space.chat, MainMode()) 790 | trail = f'\n\n{self.hike}' 791 | 792 | return f"{pet}{emoji} {' '.join(parts)}{trail}" 793 | 794 | move = action('➡️')(_move) 795 | _move_south = action('⬇️')(_move) 796 | _move_west = action('⬅️')(_move) 797 | _move_north = action('⬆️')(_move) 798 | 799 | @action('🔙') 800 | async def stop(self, space: Space, *args: str) -> str: 801 | context.bot.get().set_mode(space.chat, MainMode()) 802 | pet = await space.get_pet() 803 | moves = len(self.hike.moves) 804 | text = ngettext( 805 | 'You return home after 1 move.', 'You return home after {moves} moves.', moves 806 | ).format(moves=moves) 807 | return f'{pet} {text}\n\n{self.hike}' 808 | 809 | async def default(self, space: Space, *args: str) -> str: 810 | return dedent("""\ 811 | 🧭 Hike: Navigate the trail and find your destination 📍. 812 | 813 | ⬆️➡️⬇️⬅️: Move four steps in the given direction, e.g. ⬅️⬆️⬆️⬅️. Every move starts from the same spot. 814 | 🔙: Return home. 815 | """) 816 | 817 | @event_message('pet-hungry') 818 | async def pet_hungry_message(event: Event) -> str: 819 | space = await event.get_space() 820 | pet = await space.get_pet() 821 | return pet_message(pet, f'{pet.name} looks hungry. {speak()}', focus='🍽️') 822 | 823 | @event_message('pet-dirty') 824 | async def pet_dirty_message(event: Event) -> str: 825 | space = await event.get_space() 826 | pet = await space.get_pet() 827 | return pet_message(pet, f'{pet.name} is pretty dirty.', focus='💩') 828 | 829 | @event_message('space-explain-touch') 830 | async def space_explain_touch_message(event: Event) -> str: 831 | return 'ℹ️ You can touch the egg by sending a 👋 emoji. What will happen?' 832 | 833 | @event_message('space-explain-gather') 834 | async def space_explain_gather_message(event: Event) -> str: 835 | space = await event.get_space() 836 | pet = await space.get_pet() 837 | return f'ℹ️ {pet.name} looks hungry. You can gather some veggies with 🧺.' 838 | 839 | @event_message('space-explain-feed') 840 | async def space_explain_feed_message(event: Event) -> str: 841 | space = await event.get_space() 842 | pet = await space.get_pet() 843 | return f'ℹ️ You can now feed {pet.name} with 🥕.' 844 | 845 | @event_message('space-explain-craft') 846 | async def space_explain_craft_message(event: Event) -> str: 847 | space = await event.get_space() 848 | pet = await space.get_pet() 849 | return (f'ℹ️ You can craft tools and furniture for {pet.name} with 🔨. You can currently ' 850 | 'afford to craft an axe with 🔨🪓.') 851 | 852 | @event_message('space-explain-basics') 853 | async def space_explain_basics_message(event: Event) -> str: 854 | space = await event.get_space() 855 | pet = await space.get_pet() 856 | return ('ℹ️ All items are placed in the tent. You can view it with ⛺. You can watch and pet ' 857 | f'{pet.name} any time with 👋.') 858 | 859 | @event_message('space-visit-ghost') 860 | async def space_visit_ghost_message(event: Event) -> str: 861 | space = await event.get_space() 862 | pet = await space.get_pet() 863 | return pet_message(pet, f'{pet.name} has seen a ghost. {speak()}', focus='👻', mood='😮') 864 | 865 | @event_message('space-stroll-compass-blueprint') 866 | async def space_stroll_compass_blueprint_message(event: Event) -> str: 867 | space = await event.get_space() 868 | pet = await space.get_pet() 869 | return pet_message(pet, f'{pet.name} was digging and found a compass blueprint.', focus='📋', 870 | mood='😊') 871 | 872 | @event_message('space-stroll-sponge') 873 | async def space_stroll_sponge_message(event: Event) -> str: 874 | space = await event.get_space() 875 | pet = await space.get_pet() 876 | return pet_message(pet, f'{pet.name} found a sponge at the stream.', focus='🧽', mood='😊') 877 | 878 | @event_message('space-update-pan') 879 | async def space_update_pan_message(event: Event) -> str: 880 | space = await event.get_space() 881 | pet = await space.get_pet() 882 | return pet_message(pet, f'{pet.name} somehow managed to repair the pan.', focus='🍳', mood='😊') 883 | 884 | @event_message('space-update-shower') 885 | async def space_update_shower_message(event: Event) -> str: 886 | space = await event.get_space() 887 | pet = await space.get_pet() 888 | return pet_message(pet, f'{pet.name} patched up the shower with great effort.', focus='🚿', 889 | mood='😊') 890 | --------------------------------------------------------------------------------