diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d7d1540 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,18 @@ +[run] +source = terminara/ +omit = + */tests/* + */.venv/* + +[report] +omit = + */tests/* + */.venv/* +skip_empty = True +exclude_lines = + pragma: no cover + def __repr__ + def __str__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore index 60ecb65..064f084 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ .env /.coverage /coverage.xml + +# Virtual environment +/.venv/ + +# Python cache +__pycache__/ +*.py[cod] diff --git a/AGENTS.md b/AGENTS.md index ba50279..ffa1aaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,57 +63,3 @@ To create a terminal-based AI simulation game using Python and the `textual` lib - **Context Caching System:** A temporary storage system to provide the AI with consistent memory and context, independent of the AI's own memory limitations. - **Save & Load System:** Allows players to save their game progress and load it later. - **World Setting Management:** World settings can be exported to a file for sharing and imported to start new games in different worlds. - -### GUI Screens - -1. **Main Menu:** The initial screen with options to "Load Game" and "Start New Game". -2. **New Game Screen:** Allows the player to import a world setting file and configure initial game parameters. -3. **Game Screen:** The main gameplay interface, displaying the text-based scenario and a list of choices. It includes buttons to open the "Details" and "Options" screens. -4. **Details Screen:** A screen that shows player-specific information, such as status, inventory, etc. The content of this screen is dynamic and depends on the loaded world setting. -5. **Options Screen:** An in-game menu with options to "Save Game," "Load Game," and "Exit." - -## Implementation Steps - -1. **Project Setup:** Initialize the project structure and dependencies. -2. **Core System Development:** - - Implement the `world_handler.py` for loading, parsing, and exporting world setting files. - - Develop the `state_manager.py` to handle game state serialization (saving) and deserialization (loading). - - Create the `ai_narrator.py` to manage communication with the AI model, including the context caching system. -3. **GUI Implementation (Screens):** - - Build the static screens: `main_menu_screen.py` and `new_game_screen.py`. - - Develop the dynamic game interface: `game_view_screen.py`, `details_view_screen.py`, and `options_menu_screen.py`. -4. **Game Logic Integration:** - - Implement the main `game_engine.py` to tie the core systems and GUI screens together. - - Establish the flow of the game from starting a new game to playing and saving. -5. **Testing & Refinement:** - - Test each component individually. - - Conduct end-to-end testing of the game loop. - - Refine the user experience based on testing. - -## Dependencies - -- Python 3.13+ -- textual 6.0.0+ -- openai 1.107.0+ - -
-Note: textual 6 (release at 2025/08/30) has breaking changes list below. - -- Added bar_renderable to ProgressBar widget -- Added OptionList.set_options -- Added TextArea.suggestion -- Added TextArea.placeholder -- Added Header.format_title and App.format_title for easier customization of title in the Header -- Added Widget.get_line_filters and App.get_line_filters -- Added Binding.Group -- Added DOMNode.displayed_children -- Added TextArea.hide_suggestion_on_blur boolean -- Added OptionList.highlighted_option property -- Added TextArea.update_suggestion method -- Added textual.getters.app - -- Breaking change: The renderable property on the Static widget has been changed to content. -- Breaking change: HeaderTitle widget is now a static, with no text and sub_text reactives -- Breaking change: Renamed Label constructor argument renderable to content for consistency -- Breaking change: Optimization to line API to avoid applying background styles to widget content. In practice this means that you can no longer rely on blank Segments automatically getting the background color. -
diff --git a/pyproject.toml b/pyproject.toml index 4f4eb87..8f33e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,18 +9,16 @@ description = "Terminal-based adventure game" authors = [ { name = "Lu Yiou Rwong", email = "35396353+luyiourwong@users.noreply.github.com" } ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ "textual >= 6.0.0, <7", "openai >= 1.107.0, <2" ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: End Users/Desktop", "Topic :: Games/Entertainment", "Topic :: Games/Entertainment :: Role-Playing", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -53,4 +51,4 @@ version = { attr = "terminara.__version__" } # Get packages [tool.setuptools.packages.find] -# exclude = ["tests*", "docs*"] \ No newline at end of file +exclude = ["tests*", "docs*", ".github*", ".gemini*"] diff --git a/terminara/objects/world_settings.py b/terminara/objects/world_settings.py index 4a881e4..30298cc 100644 --- a/terminara/objects/world_settings.py +++ b/terminara/objects/world_settings.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, Any, Union, Optional +from typing import Dict, Any, Optional from abc import ABC from terminara.objects.scenario import Scenario @@ -15,9 +15,9 @@ class GameVariable(ABC): @dataclass class NumericVariable(GameVariable): """Represents a numeric variable in the game.""" - value: Union[int, float] - min_value: Union[int, float, None] = None - max_value: Union[int, float, None] = None + value: int | float + min_value: int | float | None = None + max_value: int | float | None = None @dataclass diff --git a/tests/test_core/test_config_manager.py b/tests/test_core/test_config_manager.py new file mode 100644 index 0000000..b2f3ade --- /dev/null +++ b/tests/test_core/test_config_manager.py @@ -0,0 +1,115 @@ +from pathlib import Path +import pytest +from terminara.core.config_manager import ConfigManager +import platform + +@pytest.fixture +def config_manager(tmp_path: Path, monkeypatch) -> ConfigManager: + """ + Fixture to create a ConfigManager that uses a temporary directory for its config file. + """ + # We monkeypatch the __init__ to use a temporary directory + # This is to isolate tests from the user's actual configuration + # and from each other. + + def mock_init(self): + self.config_dir = tmp_path / "Terminara" + self.config_dir.mkdir(parents=True, exist_ok=True) + self.config_file = self.config_dir / "config.json" + + monkeypatch.setattr(ConfigManager, "__init__", mock_init) + + return ConfigManager() + + +class TestConfigManager: + """Test suite for the ConfigManager class.""" + + # We test the real __init__ separately without the fixture + def test_init_creates_directory(self, monkeypatch, tmp_path: Path): + """Test that ConfigManager's constructor creates the config directory.""" + + # We patch platform.system to control which config path is used + monkeypatch.setattr(platform, "system", lambda: "Linux") + # We patch Path.home to point to our temp directory for predictability + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + expected_config_dir = tmp_path / '.local' / 'share' / "Terminara" + assert not expected_config_dir.exists() + + # This should create the directory + ConfigManager() + + assert expected_config_dir.exists() + + def test_get_config_no_file(self, config_manager: ConfigManager): + """Test get_config() when the config file doesn't exist.""" + assert config_manager.get_config() == {} + + def test_get_config_empty_file(self, config_manager: ConfigManager): + """Test get_config() when the config file is empty and raises JSONDecodeError.""" + config_manager.config_file.touch() + assert config_manager.get_config() == {} + + def test_get_config_invalid_json(self, config_manager: ConfigManager): + """Test get_config() with an invalid JSON file.""" + config_manager.config_file.write_text("this is not json") + assert config_manager.get_config() == {} + + def test_save_and_get_config(self, config_manager: ConfigManager): + """Test saving a configuration and then retrieving it.""" + config_data = {"hello": "world", "number": 123} + config_manager.save_config(config_data) + + retrieved_config = config_manager.get_config() + assert retrieved_config == config_data + + def test_get_value(self, config_manager: ConfigManager): + """Test retrieving a single value from the configuration.""" + config_data = {"key1": "value1", "key2": "value2"} + config_manager.save_config(config_data) + + assert config_manager.get_value("key1") == "value1" + + def test_get_nonexistent_value(self, config_manager: ConfigManager): + """Test that get_value() returns None for a key that does not exist.""" + assert config_manager.get_value("nonexistent_key") is None + + def test_set_value(self, config_manager: ConfigManager): + """Test setting a single value in the configuration.""" + config_manager.set_value("new_key", "new_value") + + # Verify by reading the whole config + config = config_manager.get_config() + assert config.get("new_key") == "new_value" + + # Verify using get_value + assert config_manager.get_value("new_key") == "new_value" + + def test_set_value_overwrites_existing(self, config_manager: ConfigManager): + """Test that set_value() overwrites an existing value.""" + config_manager.set_value("key_to_overwrite", "initial_value") + config_manager.set_value("key_to_overwrite", "updated_value") + + assert config_manager.get_value("key_to_overwrite") == "updated_value" + + def test_delete_value(self, config_manager: ConfigManager): + """Test removing a single value from the configuration.""" + config_data = {"key_to_keep": "A", "key_to_delete": "B"} + config_manager.save_config(config_data) + + config_manager.delete_value("key_to_delete") + + config = config_manager.get_config() + assert "key_to_keep" in config + assert "key_to_delete" not in config + + def test_delete_nonexistent_value(self, config_manager: ConfigManager): + """Test that deleting a nonexistent key does not raise an error.""" + config_data = {"existing_key": "value"} + config_manager.save_config(config_data) + + config_manager.delete_value("nonexistent_key") + + # Check that the original data is untouched + assert config_manager.get_config() == config_data \ No newline at end of file diff --git a/tests/test_core/test_world_handler.py b/tests/test_core/test_world_handler.py index a9dba14..2018a3c 100644 --- a/tests/test_core/test_world_handler.py +++ b/tests/test_core/test_world_handler.py @@ -1,16 +1,16 @@ +import json import unittest from pathlib import Path -import json from terminara.core.world_handler import load_world -from terminara.objects.scenario import Scenario, Choice, VariableAction +from terminara.objects.scenario import Scenario, VariableAction from terminara.objects.world_settings import ( WorldSettings, WorldInfo, AiPrompt, Item, NumericVariable, - ScenarioSettings, + ScenarioSettings, TextVariable, ) @@ -32,6 +32,11 @@ def setUp(self): "value": 100, "min_value": 0, "max_value": 100, + }, + "rank": { + "type": "text", + "description": "Player rank", + "value": "Novice", } }, "items": { @@ -56,6 +61,15 @@ def setUp(self): "value": "-10" } ] + }, + { + "text": "3. Flee into the misty woods", + "actions": [ + { + "item_name": "potion", + "quantity": 1 + } + ] } ] } @@ -81,6 +95,8 @@ def test_load_world(self): self.assertIn("health", world_settings.variables) self.assertIsInstance(world_settings.variables["health"], NumericVariable) self.assertEqual(world_settings.variables["health"].value, 100) + self.assertIsInstance(world_settings.variables["rank"], TextVariable) + self.assertEqual(world_settings.variables["rank"].value, "Novice") self.assertIn("potion", world_settings.items) self.assertIsInstance(world_settings.items["potion"], Item) @@ -91,7 +107,7 @@ def test_load_world(self): self.assertIsNotNone(world_settings.scenario.init) self.assertIsInstance(world_settings.scenario.init, Scenario) self.assertEqual(world_settings.scenario.init.text, "You stand at the entrance to a mysterious cave in the test world.") - self.assertEqual(len(world_settings.scenario.init.choices), 2) + self.assertEqual(len(world_settings.scenario.init.choices), 3) self.assertEqual(world_settings.scenario.init.choices[0].text, "1. Enter the cave boldly") self.assertEqual(len(world_settings.scenario.init.choices[0].actions), 0) self.assertEqual(world_settings.scenario.init.choices[1].text, "2. Peek inside cautiously")