diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5b703a1..14a3a91 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,4 +26,4 @@ updates: - "github-actions" commit-message: prefix: "github-actions" - include: "scope" \ No newline at end of file + include: "scope" diff --git a/README.md b/README.md index 3ce6310..b446269 100644 --- a/README.md +++ b/README.md @@ -53,32 +53,61 @@ A sophisticated text-based RPG that leverages AI to generate unique content, fea - Inventory system with consumables and equipment - Gold-based economy with configurable sell prices +### Multiplayer Support +- Turn-based combat with player queue +- Multiple players and enemies in combat +- Real-time communication using WebSockets +- Player authentication and session management +- Game state saving and loading + ## Installation ### Prerequisites - Python 3.12+ - OpenAI API key (for AI-generated content) +- PostgreSQL (for game state and session management) ### Setup 1. Clone the repository: -```bash -git clone https://github.com/yourusername/terminal-quest.git -cd terminal-quest -``` + ```bash + git clone https://github.com/yourusername/terminal-quest.git + cd terminal-quest + ``` + 2. Install dependencies: -```bash -pip3 install -r requirements.txt -``` + ```bash + pip3 install -r requirements.txt + ``` 3. Create a `.env` file: -```env -OPENAI_API_KEY=your_key_here -``` + ```env + OPENAI_API_KEY=your_key_here + DATABASE_URL=your_postgresql_database_url + ``` -4. Run the game: -```bash -python3 main.py -``` +4. Set up the PostgreSQL database: + - Ensure PostgreSQL is running. + - Create the necessary database and tables using the provided SQL scripts or migrations. + +5. Run the server: + ```bash + python src/services/server.py + ``` + +6. Run the client: + ```bash + python client/client.py + ``` + +## Running the Game on a Server + +To run the game on a server and allow players to connect from their machines, follow these steps: + +1. Set up an AWS EC2 instance or any other cloud server. +2. Install the necessary dependencies on the server. +3. Configure the server to run the game as a service. +4. Use a WebSocket server to handle real-time communication between the server and clients. +5. Players can connect to the server using a client application that communicates with the server via WebSockets. ## Project Structure @@ -162,4 +191,4 @@ terminal_quest/ - Multiplayer support ## License -This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0abe3b3 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,34 @@ +# alembic.ini +[alembic] +# path to migration scripts +script_location = migrations + +# database connection URL +sqlalchemy.url = postgresql://rpg_user:secure_password@localhost:5432/rpg_game + +[loggers] +keys = root, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s diff --git a/client/client.py b/client/client.py new file mode 100644 index 0000000..8d19074 --- /dev/null +++ b/client/client.py @@ -0,0 +1,44 @@ +import asyncio +import websockets +import json + + +async def connect_to_server(): + uri = "ws://localhost:8765" + async with websockets.connect(uri) as websocket: + # Authenticate player + await websocket.send( + json.dumps( + {"action": "authenticate", "username": "player", "password": "password"} + ) + ) + response = await websocket.recv() + print(response) + + # If class selection is required + response_data = json.loads(response) + if "classes" in response_data: + print("Available classes:") + for i, class_name in enumerate(response_data["classes"], 1): + print(f"{i}. {class_name}") + + class_choice = int(input("Select your class: ")) - 1 + selected_class = response_data["classes"][class_choice] + + # Send class selection to server + await websocket.send( + json.dumps({"action": "select_class", "class_name": selected_class}) + ) + response = await websocket.recv() + print(response) + + # Load game state + await websocket.send( + json.dumps({"action": "load_game_state", "player": {"name": "player"}}) + ) + response = await websocket.recv() + print(response) + + +if __name__ == "__main__": + asyncio.run(connect_to_server()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aed230f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + db: + image: postgres:latest + container_name: rpg_game_db + environment: + POSTGRES_DB: rpg_game + POSTGRES_USER: rpg_user + POSTGRES_PASSWORD: secure_password + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: diff --git a/main.py b/main.py index 2c2f945..f4a1011 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,8 @@ from src.display.boss.boss_view import BossView import time from src.models.base_types import EffectResult +from src.services.session_management import SessionManagementService +from src.services.game_state import GameStateService # Configure logging logging.basicConfig( @@ -62,26 +64,47 @@ def main(): base_view = BaseView() boss_view = BossView() boss_service = BossService() + session_service = SessionManagementService() + game_state_service = GameStateService() + + # Player authentication + session_service.show_login_screen() + player = session_service.authenticate_player() + if not player: + MessageView.show_error("Authentication failed. Exiting game.") + return - # Character creation - character_view.show_character_creation() - player_name = input().strip() - - # Use character creation service - try: - player = CharacterCreationService.create_character(player_name) - if not player: - MessageView.show_error("Character creation failed: Invalid name provided") + # Load game state + game_state = game_state_service.load_game_state(player) + if game_state: + player = game_state["player"] + shop = game_state["shop"] + else: + # Character creation + character_view.show_character_creation() + player_name = input().strip() + + # Use character creation service + try: + player = CharacterCreationService.create_character(player_name) + if not player: + MessageView.show_error( + "Character creation failed: Invalid name provided" + ) + return + except ValueError as e: + MessageView.show_error(f"Character creation failed: {str(e)}") return - except ValueError as e: - MessageView.show_error(f"Character creation failed: {str(e)}") - return - except Exception as e: - MessageView.show_error("An unexpected error occurred during character creation") - return + except Exception as e: + MessageView.show_error( + "An unexpected error occurred during character creation" + ) + return + + # Initialize shop + shop = Shop() - # Initialize shop - shop = Shop() + base_view.clear_screen() base_view.clear_screen() @@ -222,6 +245,9 @@ def main(): MessageView.show_info("\nThank you for playing! See you next time...") break + # Save game state after each turn + game_state_service.save_game_state(player, shop) + if player.health <= 0: game_view.show_game_over(player) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..f9dbb75 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,76 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/initial_migration.py b/migrations/versions/initial_migration.py new file mode 100644 index 0000000..831577f --- /dev/null +++ b/migrations/versions/initial_migration.py @@ -0,0 +1,76 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "initial_migration" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create the players table + op.create_table( + "players", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String, nullable=False), + sa.Column("description", sa.String), + sa.Column("health", sa.Integer, nullable=False), + sa.Column("max_health", sa.Integer, nullable=False), + sa.Column("attack", sa.Integer, nullable=False), + sa.Column("defense", sa.Integer, nullable=False), + sa.Column("level", sa.Integer, nullable=False), + sa.Column("mana", sa.Integer, nullable=False), + sa.Column("max_mana", sa.Integer, nullable=False), + sa.Column("exp", sa.Integer, nullable=False), + sa.Column("exp_to_level", sa.Integer, nullable=False), + sa.Column("inventory", sa.JSON, nullable=False), + sa.Column("equipment", sa.JSON, nullable=False), + sa.Column("skills", sa.JSON, nullable=False), + sa.Column("session_id", sa.String), + ) + + # Create the enemies table + op.create_table( + "enemies", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String, nullable=False), + sa.Column("description", sa.String), + sa.Column("health", sa.Integer, nullable=False), + sa.Column("max_health", sa.Integer, nullable=False), + sa.Column("attack", sa.Integer, nullable=False), + sa.Column("defense", sa.Integer, nullable=False), + sa.Column("level", sa.Integer, nullable=False), + sa.Column("exp_reward", sa.Integer, nullable=False), + sa.Column("art", sa.String), + ) + + # Create the game_states table + op.create_table( + "game_states", + sa.Column("player_id", sa.String, primary_key=True), + sa.Column("game_state", sa.JSON, nullable=False), + ) + + # Create the sessions table + op.create_table( + "sessions", + sa.Column("session_id", sa.String, primary_key=True), + sa.Column("player_data", sa.JSON, nullable=False), + sa.Column("expiration", sa.Integer, nullable=False), + ) + + # Create the shop_states table + op.create_table( + "shop_states", + sa.Column("player_id", sa.String, primary_key=True), + sa.Column("shop_state", sa.JSON, nullable=False), + ) + + +def downgrade(): + op.drop_table("shop_states") + op.drop_table("sessions") + op.drop_table("game_states") + op.drop_table("enemies") + op.drop_table("players") diff --git a/requirements.txt b/requirements.txt index 9ff5075..4f0f390 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ pytest-cov>=4.1.0 black>=23.7.0 flake8>=6.1.0 mypy>=1.5.1 -bandit>=1.7.5 \ No newline at end of file +bandit>=1.7.5 diff --git a/src/config/settings.py b/src/config/settings.py index fb3096e..d52cadb 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -157,3 +157,22 @@ "EPIC_MIN_LEVEL": 15, }, } + +# Database configuration settings for PostgreSQL +DATABASE_SETTINGS = { + "DB_NAME": "rpg_game", + "DB_USER": "rpg_user", + "DB_PASSWORD": "secure_password", + "DB_HOST": "localhost", + "DB_PORT": 5432, +} + +# Authentication and session management settings +AUTH_SETTINGS = { + "SESSION_TIMEOUT": 3600, # Session timeout in seconds + "JWT_SECRET_KEY": "your_secret_key", + "JWT_ALGORITHM": "HS256", + "REDIS_HOST": "localhost", + "REDIS_PORT": 6379, + "REDIS_DB": 0, +} diff --git a/src/display/combat/combat_view.py b/src/display/combat/combat_view.py index fa7ee82..d55d101 100644 --- a/src/display/combat/combat_view.py +++ b/src/display/combat/combat_view.py @@ -15,31 +15,34 @@ class CombatView(BaseView): """Handles all combat-related display logic""" @staticmethod - def show_combat_status(player: Player, enemy: Enemy, combat_log: List[str]): + def show_combat_status( + players: List[Player], enemies: List[Enemy], combat_log: List[str] + ): """Display combat status with improved visual flow""" print(f"\n{dec['TITLE']['PREFIX']}Combat{dec['TITLE']['SUFFIX']}") print(f"{dec['SEPARATOR']}") # Enemy section - print(f"\n{dec['SECTION']['START']}Enemy{dec['SECTION']['END']}") - print(f" {sym['SKULL']} {enemy.name}\n") - - # Show enemy art if available - if hasattr(enemy, "art") and enemy.art: - print(enemy.art) - else: - # Display default art or placeholder - print(" ╔════════╗") - print(" ║ (??) ║") - print(" ║ (||) ║") - print(" ╚════════╝") - - # Enemy health bar - health_percent = enemy.health / enemy.max_health - health_bar = "█" * int(health_percent * 20) - health_bar = health_bar.ljust(20, "░") - print(f"\n {sym['HEALTH']} Health: {enemy.health}/{enemy.max_health}") - print(f" [{health_bar}]") + print(f"\n{dec['SECTION']['START']}Enemies{dec['SECTION']['END']}") + for enemy in enemies: + print(f" {sym['SKULL']} {enemy.name}\n") + + # Show enemy art if available + if hasattr(enemy, "art") and enemy.art: + print(enemy.art) + else: + # Display default art or placeholder + print(" ╔════════╗") + print(" ║ (??) ║") + print(" ║ (||) ║") + print(" ╚════════╝") + + # Enemy health bar + health_percent = enemy.health / enemy.max_health + health_bar = "█" * int(health_percent * 20) + health_bar = health_bar.ljust(20, "░") + print(f"\n {sym['HEALTH']} Health: {enemy.health}/{enemy.max_health}") + print(f" [{health_bar}]") # Combat log section if combat_log: @@ -48,21 +51,21 @@ def show_combat_status(player: Player, enemy: Enemy, combat_log: List[str]): print(f" {message}") # Player status - print(f"\n{dec['SECTION']['START']}Status{dec['SECTION']['END']}") - - # Player health bar - player_health_percent = player.health / player.max_health - player_health_bar = "█" * int(player_health_percent * 20) - player_health_bar = player_health_bar.ljust(20, "░") - print(f" {sym['HEALTH']} Health: {player.health}/{player.max_health}") - print(f" [{player_health_bar}]") - - # Player mana bar - mana_percent = player.mana / player.max_mana - mana_bar = "█" * int(mana_percent * 20) - mana_bar = mana_bar.ljust(20, "░") - print(f" {sym['MANA']} Mana: {player.mana}/{player.max_mana}") - print(f" [{mana_bar}]") + print(f"\n{dec['SECTION']['START']}Players{dec['SECTION']['END']}") + for player in players: + # Player health bar + player_health_percent = player.health / player.max_health + player_health_bar = "█" * int(player_health_percent * 20) + player_health_bar = player_health_bar.ljust(20, "░") + print(f" {sym['HEALTH']} Health: {player.health}/{player.max_health}") + print(f" [{player_health_bar}]") + + # Player mana bar + mana_percent = player.mana / player.max_mana + mana_bar = "█" * int(mana_percent * 20) + mana_bar = mana_bar.ljust(20, "░") + print(f" {sym['MANA']} Mana: {player.mana}/{player.max_mana}") + print(f" [{mana_bar}]") # Actions print(f"\n{dec['SECTION']['START']}Actions{dec['SECTION']['END']}") diff --git a/src/models/character.py b/src/models/character.py index 7547a6a..5405b01 100644 --- a/src/models/character.py +++ b/src/models/character.py @@ -1,6 +1,7 @@ import random from typing import Dict, List, Optional, Any +from .skills import Skill from ..display.common.message_view import MessageView from .base_types import GameEntity, EffectTrigger from .effects.base import BaseEffect @@ -192,6 +193,9 @@ def __init__(self, name: str, char_class: CharacterClass): # Initialize equipment slots self.equipment = {"weapon": None, "armor": None, "accessory": None} + # Session management attributes + self.session_id = None + def equip_item(self, item: "Equipment") -> bool: """ Equip an item to the appropriate slot @@ -289,6 +293,60 @@ def rest(self) -> int: return heal_amount + def serialize(self) -> Dict[str, Any]: + """Serialize player data to a dictionary""" + return { + "name": self.name, + "description": self.description, + "health": self.health, + "max_health": self.max_health, + "attack": self.attack, + "defense": self.defense, + "level": self.level, + "mana": self.mana, + "max_mana": self.max_mana, + "exp": self.exp, + "exp_to_level": self.exp_to_level, + "inventory": self.inventory, + "equipment": { + slot: item.serialize() if item else None + for slot, item in self.equipment.items() + }, + "skills": [skill.serialize() for skill in self.skills], + "session_id": self.session_id, + } + + @classmethod + def deserialize(cls, data: Dict[str, Any]) -> "Player": + """Deserialize player data from a dictionary""" + char_class = CharacterClass( + name=data["char_class"]["name"], + description=data["char_class"]["description"], + base_health=data["char_class"]["base_health"], + base_mana=data["char_class"]["base_mana"], + base_attack=data["char_class"]["base_attack"], + base_defense=data["char_class"]["base_defense"], + skills=[Skill.deserialize(skill) for skill in data["char_class"]["skills"]], + ) + player = cls(name=data["name"], char_class=char_class) + player.description = data["description"] + player.health = data["health"] + player.max_health = data["max_health"] + player.attack = data["attack"] + player.defense = data["defense"] + player.level = data["level"] + player.mana = data["mana"] + player.max_mana = data["max_mana"] + player.exp = data["exp"] + player.exp_to_level = data["exp_to_level"] + player.inventory = data["inventory"] + player.equipment = { + slot: Equipment.deserialize(item) if item else None + for slot, item in data["equipment"].items() + } + player.skills = [Skill.deserialize(skill) for skill in data["skills"]] + player.session_id = data["session_id"] + return player class Enemy(Character): def __init__( @@ -316,6 +374,34 @@ def get_exp_reward(self) -> int: """Return the experience reward for defeating this enemy""" return self.exp_reward + def serialize(self) -> Dict[str, Any]: + """Serialize enemy data to a dictionary""" + return { + "name": self.name, + "description": self.description, + "health": self.health, + "max_health": self.max_health, + "attack": self.attack, + "defense": self.defense, + "level": self.level, + "exp_reward": self.exp_reward, + "art": self.art, + } + + @classmethod + def deserialize(cls, data: Dict[str, Any]) -> "Enemy": + """Deserialize enemy data from a dictionary""" + return cls( + name=data["name"], + description=data["description"], + health=data["health"], + attack=data["attack"], + defense=data["defense"], + exp_reward=data["exp_reward"], + level=data["level"], + art=data.get("art"), + ) + def get_fallback_enemy(player_level: int = 1) -> Enemy: """Create a fallback enemy when generation fails""" diff --git a/src/services/character_creation.py b/src/services/character_creation.py index 96d1965..1c7aa84 100644 --- a/src/services/character_creation.py +++ b/src/services/character_creation.py @@ -11,6 +11,7 @@ from src.display.ai.ai_view import AIView from src.display.character.character_view import CharacterView from src.utils.ascii_art import ensure_entity_art, load_ascii_art +from src.services.session_management import SessionManagementService logger = logging.getLogger(__name__) @@ -145,3 +146,11 @@ def _ensure_class_art(char_class: CharacterClass) -> None: if art_file: art_content = load_ascii_art(art_file) setattr(char_class, "art", art_content) + + @staticmethod + def authenticate_player() -> Optional[Player]: + """Authenticate player during character creation""" + session_service = SessionManagementService() + session_service.show_login_screen() + player = session_service.authenticate_player() + return player diff --git a/src/services/combat.py b/src/services/combat.py index 07ac315..9a86653 100644 --- a/src/services/combat.py +++ b/src/services/combat.py @@ -16,13 +16,11 @@ from src.services.shop import Shop from src.services.boss import BossService - class CombatResult(Enum): VICTORY = auto() DEFEAT = auto() RETREAT = auto() - def calculate_damage( attacker: "Character", defender: "Character", base_damage: int ) -> int: @@ -82,147 +80,212 @@ def handle_combat_rewards( def combat( - player: Player, enemy: Enemy, combat_view: CombatView, shop: Shop + players: List[Player], enemies: List[Enemy], combat_view: CombatView, shop: Shop ) -> Optional[bool]: - """Handle turn-based combat sequence.""" + """Handle turn-based combat sequence for multiple players and enemies.""" combat_log = [] - boss_service = BossService() if isinstance(enemy, Boss) else None + boss_service = ( + BossService() if any(isinstance(enemy, Boss) for enemy in enemies) else None + ) - while enemy.health > 0 and player.health > 0: + # Initialize player and enemy queues + player_queue = players[:] + enemy_queue = enemies[:] + + while player_queue and enemy_queue: BaseView.clear_screen() - combat_view.show_combat_status(player, enemy, combat_log) - - choice = input("\nChoose your action: ").strip() - if choice == "1": # Attack - # Calculate damage with some randomization - player_damage = player.get_total_attack() + random.randint(-2, 2) - - # Apply damage to the enemy - enemy.health -= player_damage - combat_log.insert( - 0, f"{sym['ATTACK']} You strike for {player_damage} damage!" - ) - elif choice == "2": # Use skill - BaseView.clear_screen() - combat_view.show_skills(player) - - try: - skill_choice = int(input("\nChoose skill (0 to cancel): ")) - 1 - if skill_choice == -1: - continue - - if 0 <= skill_choice < len(player.skills): - skill = player.skills[skill_choice] - if player.mana >= skill.mana_cost: - # Calculate and apply skill damage - skill_damage = skill.damage + random.randint(-3, 3) - enemy.health -= skill_damage - player.mana -= skill.mana_cost - combat_log.insert( - 0, - f"{sym['SKILL']} You cast {skill.name} for {skill_damage} damage!", - ) - combat_log.insert( - 0, f"{sym['MANA']} Consumed {skill.mana_cost} mana" - ) + combat_view.show_combat_status(player_queue, enemy_queue, combat_log) + + # Player's turn + for player in player_queue: + if player.health <= 0: + continue + + choice = input(f"\n{player.name}, choose your action: ").strip() + if choice == "1": # Attack + print("\nChoose your target:") + for i, enemy in enumerate(enemy_queue): + print(f"{i + 1}. {enemy.name} (Health: {enemy.health})") + try: + target_choice = int(input("Enter the number of the target: ")) - 1 + if 0 <= target_choice < len(enemy_queue): + target = enemy_queue[target_choice] else: - combat_log.insert(0, f"{sym['MANA']} Not enough mana!") - else: - combat_log.insert(0, "Invalid skill selection!") - except ValueError as e: + print("Invalid choice! Targeting a random enemy.") + target = random.choice(enemy_queue) + except ValueError: + print("Invalid input! Targeting a random enemy.") + target = random.choice(enemy_queue) + + player_damage = calculate_damage(player, target, random.randint(-2, 2)) + target.health -= player_damage combat_log.insert( - 0, f"Invalid input! Please enter a number. Error: {str(e)}" + 0, + f"{sym['ATTACK']} {player.name} strikes {target.name} for {player_damage} damage!", ) - - elif choice == "3": # Use item - BaseView.clear_screen() - combat_view.show_combat_items(player) - - try: - item_choice = int(input("\nChoose item: ")) - 1 - if item_choice == -1: # User chose to return - continue - usable_items = [ - item - for item in player.inventory["items"] - if isinstance(item, Consumable) - ] - if 0 <= item_choice < len(usable_items): - item = usable_items[item_choice] - if item.use(player): - # Remove the used item - player.inventory["items"].remove(item) - combat_log.insert(0, f"Used {item.name}") - - # Show healing/mana restore effects - if item.name == "Health Potion": + elif choice == "2": # Use skill + BaseView.clear_screen() + combat_view.show_skills(player) + + try: + skill_choice = int(input("\nChoose skill (0 to cancel): ")) - 1 + if skill_choice == -1: + continue + + if 0 <= skill_choice < len(player.skills): + skill = player.skills[skill_choice] + if player.mana >= skill.mana_cost: + print("\nChoose your target:") + for i, enemy in enumerate(enemy_queue): + print(f"{i + 1}. {enemy.name} (Health: {enemy.health})") + try: + target_choice = ( + int(input("Enter the number of the target: ")) - 1 + ) + if 0 <= target_choice < len(enemy_queue): + target = enemy_queue[target_choice] + else: + print("Invalid choice! Targeting a random enemy.") + target = random.choice(enemy_queue) + except ValueError: + print("Invalid input! Targeting a random enemy.") + target = random.choice(enemy_queue) + + skill_damage = calculate_damage( + player, target, skill.damage + random.randint(-3, 3) + ) + target.health -= skill_damage + player.mana -= skill.mana_cost + combat_log.insert( + 0, + f"{sym['SKILL']} {player.name} casts {skill.name} on {target.name} for {skill_damage} damage!", + ) combat_log.insert( - 0, f"{sym['HEALTH']} Restored {item.value} health!" + 0, + f"{sym['MANA']} {player.name} consumed {skill.mana_cost} mana", ) - if item.name == "Mana Potion": + else: combat_log.insert( - 0, f"{sym['MANA']} Restored {item.value} mana!" + 0, + f"{sym['MANA']} {player.name} does not have enough mana!", ) else: - combat_log.insert(0, "Couldn't use that item right now!") - else: - combat_log.insert(0, "Invalid item selection!") - except ValueError: - combat_log.insert(0, "Invalid input!") - continue # Ensure the loop continues after using an item - - elif choice == "4": # Retreat - escape_chance = 0.7 - (enemy.level * 0.05) - if random.random() < escape_chance: - combat_view.show_retreat_attempt(success=True) - return False # Successful retreat - else: - enemy_damage = enemy.attack + random.randint(1, 3) - player.health -= enemy_damage - combat_view.show_retreat_attempt( - success=False, damage_taken=enemy_damage, enemy_name=enemy.name - ) - combat_log.insert( - 0, - f"Failed to escape! {enemy.name} hits you for {enemy_damage} damage!", + combat_log.insert(0, "Invalid skill selection!") + except ValueError as e: + combat_log.insert( + 0, f"Invalid input! Please enter a number. Error: {str(e)}" + ) + + elif choice == "3": # Use item + BaseView.clear_screen() + combat_view.show_combat_items(player) + + try: + item_choice = int(input("\nChoose item: ")) - 1 + if item_choice == -1: # User chose to return + continue + usable_items = [ + item + for item in player.inventory["items"] + if isinstance(item, Consumable) + ] + if 0 <= item_choice < len(usable_items): + item = usable_items[item_choice] + if item.use(player): + player.inventory["items"].remove(item) + combat_log.insert(0, f"{player.name} used {item.name}") + + if item.name == "Health Potion": + combat_log.insert( + 0, + f"{sym['HEALTH']} {player.name} restored {item.value} health!", + ) + if item.name == "Mana Potion": + combat_log.insert( + 0, + f"{sym['MANA']} {player.name} restored {item.value} mana!", + ) + else: + combat_log.insert( + 0, f"{player.name} couldn't use that item right now!" + ) + else: + combat_log.insert(0, "Invalid item selection!") + except ValueError: + combat_log.insert(0, "Invalid input!") + continue + + elif choice == "4": # Retreat + escape_chance = 0.7 - ( + sum(enemy.level for enemy in enemy_queue) / len(enemy_queue) * 0.05 ) - - # Check for player death - if player.health <= 0: + if random.random() < escape_chance: + combat_view.show_retreat_attempt(success=True) + return False + else: + enemy_damage = random.choice(enemy_queue).attack + random.randint( + 1, 3 + ) + player.health -= enemy_damage + combat_view.show_retreat_attempt( + success=False, + damage_taken=enemy_damage, + enemy_name=random.choice(enemy_queue).name, + ) + combat_log.insert( + 0, + f"Failed to escape! {random.choice(enemy_queue).name} hits {player.name} for {enemy_damage} damage!", + ) + + if player.health <= 0: + player_queue.remove(player) + + # Check for player defeat + if not player_queue: return None BaseView.clear_screen() - combat_view.show_combat_status(player, enemy, combat_log) + combat_view.show_combat_status(player_queue, enemy_queue, combat_log) time.sleep(2) # Enemy's turn - if enemy.health > 0: + for enemy in enemy_queue: + if enemy.health <= 0: + continue + if isinstance(enemy, Boss): - boss_result = boss_service.handle_boss_turn(enemy, player) + boss_result = boss_service.handle_boss_turn( + enemy, random.choice(player_queue) + ) combat_log.insert( 0, f"{sym['SKILL']} {enemy.name} uses {boss_result.skill_used} for {boss_result.damage} damage!", ) - player.health -= boss_result.damage + target = random.choice(player_queue) + target.health -= boss_result.damage for effect in boss_result.status_effects: - effect.apply(player) + effect.apply(target) combat_log.insert(0, f"{sym['EFFECT']} {effect.description}") enemy.update_cooldowns() else: - enemy_damage = enemy.attack + random.randint(-1, 1) - player.health -= enemy_damage + target = random.choice(player_queue) + enemy_damage = calculate_damage(enemy, target, random.randint(-1, 1)) + target.health -= enemy_damage combat_log.insert( 0, - f"{sym['ATTACK']} {enemy.name} attacks for {enemy_damage} damage!", + f"{sym['ATTACK']} {enemy.name} attacks {target.name} for {enemy_damage} damage!", ) - # Update skill cooldowns at end of turn - for skill in player.skills: - skill.update_cooldown() + if enemy.health <= 0: + enemy_queue.remove(enemy) - return enemy.health <= 0 # True for victory, False shouldn't happen here + # Update skill cooldowns at end of turn + for player in player_queue: + for skill in player.skills: + skill.update_cooldown() + return not enemy_queue # True for victory, False for defeat def handle_level_up(player: Player): """Handle level up logic and display""" diff --git a/src/services/game_state.py b/src/services/game_state.py new file mode 100644 index 0000000..603b5d8 --- /dev/null +++ b/src/services/game_state.py @@ -0,0 +1,77 @@ +import json +from typing import Dict, Any +from src.models.character import Player, Enemy +from src.config.settings import DATABASE_SETTINGS +import psycopg2 + + +class GameStateService: + def __init__(self): + self.connection = psycopg2.connect( + dbname=DATABASE_SETTINGS["DB_NAME"], + user=DATABASE_SETTINGS["DB_USER"], + password=DATABASE_SETTINGS["DB_PASSWORD"], + host=DATABASE_SETTINGS["DB_HOST"], + port=DATABASE_SETTINGS["DB_PORT"], + ) + self.cursor = self.connection.cursor() + + def save_game_state(self, player: Player, shop: Any) -> None: + """Save the current game state to the database""" + game_state = { + "player": player.serialize(), + "shop": shop.serialize(), + } + game_state_json = json.dumps(game_state) + self.cursor.execute( + """ + INSERT INTO game_states (player_id, game_state) + VALUES (%s, %s) + ON CONFLICT (player_id) DO UPDATE + SET game_state = EXCLUDED.game_state + """, + (player.name, game_state_json), + ) + self.connection.commit() + + def load_game_state(self, player: Player) -> Dict[str, Any]: + """Load the game state from the database""" + self.cursor.execute( + """ + SELECT game_state FROM game_states + WHERE player_id = %s + """, + (player.name,), + ) + result = self.cursor.fetchone() + if result: + game_state_json = result[0] + game_state = json.loads(game_state_json) + return { + "player": Player.deserialize(game_state["player"]), + "shop": Shop.deserialize(game_state["shop"]), + } + return {} + + def serialize_game_object(self, obj: Any) -> str: + """Serialize a game object to JSON""" + return json.dumps(obj.serialize()) + + def deserialize_game_object(self, json_str: str, obj_type: str) -> Any: + """Deserialize a game object from JSON""" + data = json.loads(json_str) + if obj_type == "Player": + return Player.deserialize(data) + elif obj_type == "Enemy": + return Enemy.deserialize(data) + # Add more object types as needed + return None + + def update_game_state(self, player: Player, shop: Any) -> None: + """Update the game state in the database after each significant event""" + self.save_game_state(player, shop) + + def close(self): + """Close the database connection""" + self.cursor.close() + self.connection.close() diff --git a/src/services/server.py b/src/services/server.py new file mode 100644 index 0000000..07e8aaa --- /dev/null +++ b/src/services/server.py @@ -0,0 +1,98 @@ +import asyncio +import websockets +import json +from .session_management import SessionManagementService +from .game_state import GameStateService +from ..models.character import Player +from ..services.character_creation import CharacterCreationService + +connected_clients = set() + + +async def handle_client(websocket, path): + connected_clients.add(websocket) + session_service = SessionManagementService() + game_state_service = GameStateService() + + try: + async for message in websocket: + data = json.loads(message) + action = data.get("action") + + if action == "authenticate": + player = session_service.authenticate_player( + data["username"], data["password"] + ) + if player: + if not player.char_class: + classes = CharacterCreationService._get_character_classes() + await websocket.send( + json.dumps( + { + "status": "success", + "message": "Authenticated", + "classes": [cls.name for cls in classes], + } + ) + ) + else: + await websocket.send( + json.dumps( + { + "status": "success", + "message": "Authenticated", + "player": player.serialize(), + } + ) + ) + else: + await websocket.send( + json.dumps( + {"status": "error", "message": "Authentication failed"} + ) + ) + + elif action == "select_class": + class_name = data.get("class_name") + classes = CharacterCreationService._get_character_classes() + chosen_class = next( + (cls for cls in classes if cls.name == class_name), None + ) + if chosen_class: + player.char_class = chosen_class + session_id = session_service.create_session(player) + player.session_id = session_id + await websocket.send( + json.dumps( + { + "status": "success", + "message": "Class selected", + "session_id": session_id, + } + ) + ) + else: + await websocket.send( + json.dumps( + {"status": "error", "message": "Invalid class selection"} + ) + ) + + elif action == "load_game_state": + player = Player.deserialize(data["player"]) + game_state = game_state_service.load_game_state(player) + await websocket.send( + json.dumps({"status": "success", "game_state": game_state}) + ) + + finally: + connected_clients.remove(websocket) + + +async def main(): + async with websockets.serve(handle_client, "localhost", 8765): + await asyncio.Future() # Run forever + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/services/session_management.py b/src/services/session_management.py new file mode 100644 index 0000000..fc4e465 --- /dev/null +++ b/src/services/session_management.py @@ -0,0 +1,96 @@ +import psycopg2 +import jwt +import time +from typing import Optional + +from ..models.character_classes import CharacterClass +from ..config.settings import AUTH_SETTINGS, DATABASE_SETTINGS +from ..models.character import Player +from ..display.common.message_view import MessageView + + +class SessionManagementService: + def __init__(self): + self.conn = psycopg2.connect( + dbname=DATABASE_SETTINGS["DB_NAME"], + user=DATABASE_SETTINGS["DB_USER"], + password=DATABASE_SETTINGS["DB_PASSWORD"], + host=DATABASE_SETTINGS["DB_HOST"], + port=DATABASE_SETTINGS["DB_PORT"], + ) + self.jwt_secret_key = AUTH_SETTINGS["JWT_SECRET_KEY"] + self.jwt_algorithm = AUTH_SETTINGS["JWT_ALGORITHM"] + self.session_timeout = AUTH_SETTINGS["SESSION_TIMEOUT"] + + def create_session(self, player: Player) -> str: + session_id = self._generate_session_id(player) + cursor = self.conn.cursor() + cursor.execute( + "INSERT INTO sessions (session_id, player_data, expiration) VALUES (%s, %s, %s)", + (session_id, player.serialize(), time.time() + self.session_timeout), + ) + self.conn.commit() + cursor.close() + return session_id + + def get_session(self, session_id: str) -> Optional[Player]: + cursor = self.conn.cursor() + cursor.execute( + "SELECT player_data FROM sessions WHERE session_id = %s", (session_id,) + ) + session_data = cursor.fetchone() + cursor.close() + if session_data: + player_data = jwt.decode( + session_data[0], self.jwt_secret_key, algorithms=[self.jwt_algorithm] + ) + return Player.deserialize(player_data) + return None + + def _generate_session_id(self, player: Player) -> str: + payload = { + "player_id": player.name, + "exp": time.time() + self.session_timeout, + } + return jwt.encode(payload, self.jwt_secret_key, algorithm=self.jwt_algorithm) + + def authenticate_player(self, username: str, password: str) -> Optional[Player]: + # Placeholder authentication logic + if username == "player" and password == "password": + # Check if player already exists in the database + cursor = self.conn.cursor() + cursor.execute( + "SELECT player_data FROM players WHERE username = %s", (username,) + ) + player_data = cursor.fetchone() + cursor.close() + + if player_data: + # Deserialize existing player data + player = Player.deserialize(player_data[0]) + return player + else: + # Redirect to class creation + char_class = self.create_character_class() + player = Player(name=username, char_class=char_class) + return player + else: + MessageView.show_error("Invalid username or password") + return None + + def create_character_class(self) -> CharacterClass: + # Logic to create a new character class + # This could involve user input or default class creation + char_class = CharacterClass( + name="Warrior", + description="A brave warrior with unmatched strength.", + base_health=100, + base_mana=50, + base_attack=15, + base_defense=10, + skills=[], # Add appropriate skills here + ) + return char_class + + def show_login_screen(self): + print("Welcome to the game! Please log in.") diff --git a/src/services/set_bonus.py b/src/services/set_bonus.py index 467a66a..e2e4504 100644 --- a/src/services/set_bonus.py +++ b/src/services/set_bonus.py @@ -9,7 +9,7 @@ class SetBonusService: @staticmethod def check_set_bonuses(character: "Character") -> Dict[str, List["SetBonus"]]: """Check and return active set bonuses for a character""" - from ..models.sets.item_sets import ITEM_SETS + from ..models.sets.base import ITEM_SETS active_sets: Dict[str, List["SetBonus"]] = {} equipped_set_pieces: Dict[str, int] = {} diff --git a/src/services/shop.py b/src/services/shop.py index 0cedc1d..05aa1ab 100644 --- a/src/services/shop.py +++ b/src/services/shop.py @@ -1,3 +1,4 @@ +import json from typing import Optional from enum import Enum from src.models.items.consumable import Consumable @@ -9,7 +10,8 @@ import random from dataclasses import dataclass from typing import List - +import psycopg2 +from src.config.settings import DATABASE_SETTINGS class ShopType(Enum): GENERAL = "general" @@ -85,6 +87,14 @@ def __init__(self): self.current_event: Optional[ShopEvent] = None self.inventory: List[ShopItem] = self.generate_shop_inventory() self.sell_multiplier = SHOP_SETTINGS["SELL_MULTIPLIER"] + self.connection = psycopg2.connect( + dbname=DATABASE_SETTINGS["DB_NAME"], + user=DATABASE_SETTINGS["DB_USER"], + password=DATABASE_SETTINGS["DB_PASSWORD"], + host=DATABASE_SETTINGS["DB_HOST"], + port=DATABASE_SETTINGS["DB_PORT"], + ) + self.cursor = self.connection.cursor() def _random_shop_type(self) -> ShopType: roll = random.random() @@ -311,3 +321,57 @@ def buy_item(self, player: Player, item_index: int) -> bool: except Exception as e: MessageView.show_error(f"Purchase failed: {str(e)}") return False + + def save_shop_state(self, player_id: str) -> None: + """Save the current shop state to the database""" + shop_state = { + "shop_type": self.shop_type.value, + "current_event": self.current_event.name if self.current_event else None, + "inventory": [ + {"item": item.item.serialize(), "quantity": item.quantity} + for item in self.inventory + ], + } + shop_state_json = json.dumps(shop_state) + self.cursor.execute( + """ + INSERT INTO shop_states (player_id, shop_state) + VALUES (%s, %s) + ON CONFLICT (player_id) DO UPDATE + SET shop_state = EXCLUDED.shop_state + """, + (player_id, shop_state_json), + ) + self.connection.commit() + + def load_shop_state(self, player_id: str) -> None: + """Load the shop state from the database""" + self.cursor.execute( + """ + SELECT shop_state FROM shop_states + WHERE player_id = %s + """, + (player_id,), + ) + result = self.cursor.fetchone() + if result: + shop_state_json = result[0] + shop_state = json.loads(shop_state_json) + self.shop_type = ShopType(shop_state["shop_type"]) + self.current_event = ( + ShopEvent(shop_state["current_event"], 1.0) + if shop_state["current_event"] + else None + ) + self.inventory = [ + ShopItem( + item=Item.deserialize(item_data["item"]), + quantity=item_data["quantity"], + ) + for item_data in shop_state["inventory"] + ] + + def close(self): + """Close the database connection""" + self.cursor.close() + self.connection.close() diff --git a/terraform/server/main.tf b/terraform/server/main.tf new file mode 100644 index 0000000..9949483 --- /dev/null +++ b/terraform/server/main.tf @@ -0,0 +1,58 @@ +locals { + name = "terminal-quest" + environment = "dev" + region = "eu-west-1" + + instance_type = "t3.nano" + + vpc_cidr = "10.0.0.0/16" + azs = slice(data.aws_availability_zones.available1.names, 0, 3) + + + } + +module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "~> 5.0" + + name = "${local.name}-${local.environment}-${local.region}-vpc" + cidr = local.vpc_cidr + + azs = local.azs + private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] + public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] + +} + +module "security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 4.0" + + name = "${local.name}-${local.environment}-${local.region}-linux-sg" + description = "Security group for ${local.name}-${local.environment} EC2 instance" + vpc_id = module.vpc.vpc_id + + ingress_cidr_blocks = ["0.0.0.0/0"] + ingress_rules = ["http-80-tcp", "all-icmp", "ssh-tcp"] + egress_rules = ["all-all"] + +} + +module "ec2_instance_root_1" { + source = "terraform-aws-modules/ec2-instance/aws" # Update the path to your EC2 module + + name = "${local.name}-${local.environment}-${local.region}-linux-1" + + ami = data.aws_ami.amazon_linux1.id + instance_type = local.instance_type # Choose your instance type + availability_zone = element(module.vpc.azs, 0) + subnet_id = element(module.vpc.public_subnets, 0) + vpc_security_group_ids = [module.security_group.security_group_id] + associate_public_ip_address = true + #user_data_base64 = base64encode(local.user_data) + #user_data_replace_on_change = true + + cpu_options = { + core_count = 2 + threads_per_core = 2 + } diff --git a/terraform/server/providers.tf b/terraform/server/providers.tf new file mode 100644 index 0000000..c291cce --- /dev/null +++ b/terraform/server/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = local.region +} diff --git a/terraform/server/versions.tf b/terraform/server/versions.tf new file mode 100644 index 0000000..fd4d116 --- /dev/null +++ b/terraform/server/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.66" + } + } +}