├── .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 |
--------------------------------------------------------------------------------