Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -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__.:
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@
.env
/.coverage
/coverage.xml

# Virtual environment
/.venv/

# Python cache
__pycache__/
*.py[cod]
54 changes: 0 additions & 54 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

<details>
<summary><strong>Note: textual 6 (release at 2025/08/30) has breaking changes list below.</strong></summary>

- 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.
</details>
8 changes: 3 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -53,4 +51,4 @@ version = { attr = "terminara.__version__" }

# Get packages
[tool.setuptools.packages.find]
# exclude = ["tests*", "docs*"]
exclude = ["tests*", "docs*", ".github*", ".gemini*"]
8 changes: 4 additions & 4 deletions terminara/objects/world_settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/test_core/test_config_manager.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 20 additions & 4 deletions tests/test_core/test_world_handler.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To properly test the new ItemAction functionality, you should import ItemAction here. This will allow you to use assertIsInstance to verify the action type in test_load_world, as suggested in another comment.

Suggested change
from terminara.objects.scenario import Scenario, VariableAction
from terminara.objects.scenario import Scenario, VariableAction, ItemAction

from terminara.objects.world_settings import (
WorldSettings,
WorldInfo,
AiPrompt,
Item,
NumericVariable,
ScenarioSettings,
ScenarioSettings, TextVariable,
)


Expand All @@ -32,6 +32,11 @@ def setUp(self):
"value": 100,
"min_value": 0,
"max_value": 100,
},
"rank": {
"type": "text",
"description": "Player rank",
"value": "Novice",
}
},
"items": {
Expand All @@ -56,6 +61,15 @@ def setUp(self):
"value": "-10"
}
]
},
{
"text": "3. Flee into the misty woods",
"actions": [
{
"item_name": "potion",
"quantity": 1
}
]
}
]
}
Expand All @@ -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)
Expand All @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While the test correctly asserts that there are 3 choices, it doesn't verify the content of the newly added third choice. Please add assertions to check its text and actions to ensure the parsing logic is correct for ItemAction.

You can add the following assertions at the end of the test method:

self.assertEqual(world_settings.scenario.init.choices[2].text, "3. Flee into the misty woods")
self.assertEqual(len(world_settings.scenario.init.choices[2].actions), 1)
self.assertIsInstance(world_settings.scenario.init.choices[2].actions[0], ItemAction)
self.assertEqual(world_settings.scenario.init.choices[2].actions[0].item_name, "potion")
self.assertEqual(world_settings.scenario.init.choices[2].actions[0].quantity, 1)

Note that you'll need to import ItemAction for this to work (as mentioned in another comment).

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")
Expand Down