From c483e5f55509506c33d2d8bf711b221f85d4e49e Mon Sep 17 00:00:00 2001 From: "Micha R. Albert" Date: Wed, 23 Jul 2025 11:18:10 -0400 Subject: [PATCH 1/2] Add simple home page --- .gitignore | 203 +++++++++++++++++++++++++++ pyproject.toml | 6 +- src/carbon_copy/__init__.py | 4 + src/carbon_copy/main.py | 24 ++++ src/carbon_copy/templates/index.html | 13 ++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 src/carbon_copy/main.py create mode 100644 src/carbon_copy/templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb0f8dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,203 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/pyproject.toml b/pyproject.toml index 9766a71..e9cc741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,11 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = [] +dependencies = [ + "fastapi~=0.116.1", + "uvicorn[standard]~=0.35.0", + "jinja2~=3.1.6", +] [project.urls] Documentation = "https://github.com/Micha Albert/carbon-copy#readme" diff --git a/src/carbon_copy/__init__.py b/src/carbon_copy/__init__.py index 166a19d..9550e0e 100644 --- a/src/carbon_copy/__init__.py +++ b/src/carbon_copy/__init__.py @@ -1,3 +1,7 @@ # SPDX-FileCopyrightText: 2025-present Micha Albert # # SPDX-License-Identifier: MIT + +from .main import app + +__all__ = ["app"] diff --git a/src/carbon_copy/main.py b/src/carbon_copy/main.py new file mode 100644 index 0000000..9b3a7ba --- /dev/null +++ b/src/carbon_copy/main.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +from pathlib import Path +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates + +# Get the directory where this file is located +BASE_DIR = Path(__file__).resolve().parent + +app = FastAPI( + title="Carbon Copy", + description="An email-based multiplayer text adventure for the masses", + version="0.0.1" +) + +templates = Jinja2Templates(directory=BASE_DIR / "templates") + +# Serve the home page +@app.get("/") +async def root(request: Request): + """Return the home page""" + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/src/carbon_copy/templates/index.html b/src/carbon_copy/templates/index.html new file mode 100644 index 0000000..6bcc55e --- /dev/null +++ b/src/carbon_copy/templates/index.html @@ -0,0 +1,13 @@ + + + + + + + Carbon Copy + + +

Hello World

+

Welcome to Carbon Copy!

+ + From 3f1b73550680c3a5cee9248fdb15d53341de6aef Mon Sep 17 00:00:00 2001 From: "Micha R. Albert" Date: Thu, 24 Jul 2025 16:30:38 -0400 Subject: [PATCH 2/2] very basic orm structure and email support --- .env.example | 27 ++ alembic.ini | 93 +++++ alembic/env.py | 107 ++++++ alembic/script.py.mako | 24 ++ carbon_copy.db | Bin 0 -> 90112 bytes init_db.py | 43 +++ pyproject.toml | 11 + src/carbon_copy/database.py | 46 +++ src/carbon_copy/email_client.py | 483 +++++++++++++++++++++++++ src/carbon_copy/main.py | 231 +++++++++++- src/carbon_copy/models.py | 503 +++++++++++++++++++++++++++ src/carbon_copy/seed_data.py | 327 +++++++++++++++++ src/carbon_copy/templates/index.html | 23 +- 13 files changed, 1913 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 carbon_copy.db create mode 100644 init_db.py create mode 100644 src/carbon_copy/database.py create mode 100644 src/carbon_copy/email_client.py create mode 100644 src/carbon_copy/models.py create mode 100644 src/carbon_copy/seed_data.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdc8118 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Database Configuration +DATABASE_URL=sqlite:///./carbon_copy.db +# For PostgreSQL, use: postgresql://username:password@localhost/carbon_copy + +SMTP_HOST= +SMTP_PORT= +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_USE_TLS=true + +IMAP_HOST= +IMAP_PORT= +IMAP_USERNAME= +IMAP_PASSWORD= +IMAP_USE_SSL=true + +# Security +SECRET_KEY=your_secret_key_here_change_this_in_production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Game Configuration (REQUIRED) +GAME_MASTER_EMAIL= +MAX_PLAYERS_PER_ROOM=10 +STARTING_ROOM_ID=1 +DEFAULT_HEALTH=100 +DEFAULT_ENERGY=100 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..3c23abd --- /dev/null +++ b/alembic.ini @@ -0,0 +1,93 @@ +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version number format for new migration files +# version_num_format = YYYYMMDD_HHMMSS + +# version name template to use +# version_path_separator = os.pathsep + +sqlalchemy.url = sqlite:///./carbon_copy.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..5fe1d21 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,107 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +import os +import sys +from pathlib import Path + +# Add the project root to the Python path +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root / "src")) + +# Import your models +from carbon_copy.models import SQLModel + +# 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 +target_metadata = SQLModel.metadata + +# 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 get_database_url(): + """Get database URL from environment or config.""" + from dotenv import load_dotenv + load_dotenv() + + db_url = os.getenv("DATABASE_URL") + if db_url: + return db_url + + config_url = config.get_main_option("sqlalchemy.url") + if config_url: + return config_url + + # Default fallback + return "sqlite:///./carbon_copy.db" + + +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 = get_database_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. + + """ + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = get_database_url() + + connectable = engine_from_config( + configuration, + 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/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/carbon_copy.db b/carbon_copy.db new file mode 100644 index 0000000000000000000000000000000000000000..ebda665a0e9f4001566a32376b37ff1a05b568c7 GIT binary patch literal 90112 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 literal 0 HcmV?d00001 diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..5bf1f0d --- /dev/null +++ b/init_db.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +""" +Carbon Copy Database Initialization Script + +This script initializes the database and loads seed data for the Carbon Copy +email-based multiplayer text adventure. +""" + +import sys +from pathlib import Path + +# Add the src directory to Python path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root / "src")) + +from carbon_copy.database import create_db_and_tables +from carbon_copy.seed_data import initialize_game_world + + +def main(): + """Initialize the database and load seed data.""" + print("Carbon Copy - Database Initialization") + print("=" * 40) + + print("Creating database tables...") + create_db_and_tables() + print("✓ Database tables created successfully") + + print("\nLoading seed data...") + initialize_game_world() + print("✓ Seed data loaded successfully") + + print("\nDatabase initialization complete!") + print("You can now start the FastAPI server with:") + print("uvicorn carbon_copy.main:app --reload") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e9cc741..ee1984e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,17 @@ dependencies = [ "fastapi~=0.116.1", "uvicorn[standard]~=0.35.0", "jinja2~=3.1.6", + "sqlmodel~=0.0.24", + "alembic~=1.16.4", + "asyncpg~=0.30.0", + "aiosqlite~=0.21.0", + "python-dotenv~=1.1.1", + "aiosmtplib~=4.0.1", + "aioimaplib~=2.0.1", + "email-validator~=2.2.0", + "passlib[bcrypt]~=1.7.4", + "python-jose[cryptography]~=3.5.0", + "python-multipart~=0.0.20", ] [project.urls] diff --git a/src/carbon_copy/database.py b/src/carbon_copy/database.py new file mode 100644 index 0000000..c31fb77 --- /dev/null +++ b/src/carbon_copy/database.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +import os +from typing import AsyncGenerator +from sqlmodel import SQLModel, create_engine, Session +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./carbon_copy.db") + +# Convert SQLite URL to async version if needed +if DATABASE_URL.startswith("sqlite:///"): + ASYNC_DATABASE_URL = DATABASE_URL.replace("sqlite:///", "sqlite+aiosqlite:///") +elif DATABASE_URL.startswith("postgresql://"): + ASYNC_DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://") +else: + ASYNC_DATABASE_URL = DATABASE_URL + +# Create engines +if "sqlite" in DATABASE_URL: + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) + async_engine = create_async_engine(ASYNC_DATABASE_URL, connect_args={"check_same_thread": False}) +else: + engine = create_engine(DATABASE_URL) + async_engine = create_async_engine(ASYNC_DATABASE_URL) + + +def create_db_and_tables(): + """Create database tables.""" + SQLModel.metadata.create_all(engine) + + +def get_session(): + """Get database session.""" + with Session(engine) as session: + yield session + + +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + """Get async database session.""" + async with AsyncSession(async_engine) as session: + yield session diff --git a/src/carbon_copy/email_client.py b/src/carbon_copy/email_client.py new file mode 100644 index 0000000..83aa818 --- /dev/null +++ b/src/carbon_copy/email_client.py @@ -0,0 +1,483 @@ +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +import os +import asyncio +import email as email_module +from typing import List, Optional, Dict, Any +from datetime import datetime, timezone +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import aiosmtplib +import aioimaplib +from dotenv import load_dotenv + +load_dotenv() + + +class EmailConfig: + """Email configuration from environment variables.""" + + def __init__(self): + # SMTP Configuration - all required + self.smtp_host = os.getenv("SMTP_HOST") + smtp_port_str = os.getenv("SMTP_PORT") + self.smtp_username = os.getenv("SMTP_USERNAME") + self.smtp_password = os.getenv("SMTP_PASSWORD") + self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + + # IMAP Configuration - all required + self.imap_host = os.getenv("IMAP_HOST") + imap_port_str = os.getenv("IMAP_PORT") + self.imap_username = os.getenv("IMAP_USERNAME") + self.imap_password = os.getenv("IMAP_PASSWORD") + self.imap_use_ssl = os.getenv("IMAP_USE_SSL", "true").lower() == "true" + + # Game Configuration - required + self.game_master_email = os.getenv("GAME_MASTER_EMAIL") + + # Validate required fields + required_fields = { + "SMTP_HOST": self.smtp_host, + "SMTP_PORT": smtp_port_str, + "SMTP_USERNAME": self.smtp_username, + "SMTP_PASSWORD": self.smtp_password, + "IMAP_HOST": self.imap_host, + "IMAP_PORT": imap_port_str, + "IMAP_USERNAME": self.imap_username, + "IMAP_PASSWORD": self.imap_password, + "GAME_MASTER_EMAIL": self.game_master_email, + } + + missing_fields = [field for field, value in required_fields.items() if not value] + if missing_fields: + raise ValueError(f"Missing required environment variables: {', '.join(missing_fields)}") + + # Convert port strings to integers after validation + try: + self.smtp_port = int(smtp_port_str) + except ValueError: + raise ValueError(f"SMTP_PORT must be a valid integer, got: {smtp_port_str}") + + try: + self.imap_port = int(imap_port_str) + except ValueError: + raise ValueError(f"IMAP_PORT must be a valid integer, got: {imap_port_str}") + + +class EmailMessage: + """Represents an email message.""" + + def __init__( + self, + subject: str, + content: str, + from_email: str, + to_email: str, + message_id: Optional[str] = None, + in_reply_to: Optional[str] = None, + thread_id: Optional[str] = None, + received_at: Optional[datetime] = None + ): + self.subject = subject + self.content = content + self.from_email = from_email + self.to_email = to_email + self.message_id = message_id + self.in_reply_to = in_reply_to + self.thread_id = thread_id + self.received_at = received_at or datetime.now(timezone.utc) + + +class SMTPClient: + """Async SMTP client for sending emails.""" + + def __init__(self, config: EmailConfig): + self.config = config + + async def send_email( + self, + to_email: str, + subject: str, + content: str, + from_email: Optional[str] = None, + reply_to: Optional[str] = None, + message_id: Optional[str] = None, + is_html: bool = False + ) -> bool: + """Send an email via SMTP.""" + try: + from_email = from_email or self.config.smtp_username + + # Create message + if is_html: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(content, "html")) + else: + msg = MIMEText(content, "plain") + + msg["Subject"] = subject + msg["From"] = from_email + msg["To"] = to_email + + if reply_to: + msg["Reply-To"] = reply_to + msg["In-Reply-To"] = reply_to + msg["References"] = reply_to + + if message_id: + msg["Message-ID"] = message_id + + # Send email + async with aiosmtplib.SMTP( + hostname=self.config.smtp_host, + port=self.config.smtp_port, + use_tls=self.config.smtp_use_tls + ) as smtp: + await smtp.login(self.config.smtp_username, self.config.smtp_password) + await smtp.send_message(msg) + + return True + + except Exception as e: + print(f"Failed to send email: {e}") + return False + + async def send_game_notification( + self, + to_email: str, + player_name: str, + notification_type: str, + content: str, + room_name: Optional[str] = None + ) -> bool: + """Send a game-specific notification email.""" + subject_map = { + "welcome": f"Welcome to Carbon Copy, {player_name}!", + "room_update": f"Room Update: {room_name}", + "message": f"New Message in Carbon Copy", + "combat": f"Combat Update for {player_name}", + "item": f"Item Update for {player_name}", + "system": f"System Notification - Carbon Copy" + } + + subject = subject_map.get(notification_type, "Carbon Copy Notification") + + # Create HTML email content + html_content = f""" + + + + + +
+

Carbon Copy

+

Multiplayer Email Text Adventure

+
+
+

Hello, {player_name}!

+ {content} +

Reply to this email to send a message in the game.

+
+ + + + """ + + return await self.send_email( + to_email=to_email, + subject=subject, + content=html_content, + is_html=True + ) + + +class IMAPClient: + """Async IMAP client for receiving emails.""" + + def __init__(self, config: EmailConfig): + self.config = config + self.client: Optional[aioimaplib.IMAP4_SSL] = None + + async def connect(self) -> bool: + """Connect to IMAP server.""" + try: + # Validate configuration + if not self.config.imap_host: + raise ValueError("IMAP host is not configured") + if not self.config.imap_username: + raise ValueError("IMAP username is not configured") + if not self.config.imap_password: + raise ValueError("IMAP password is not configured") + + print(f"Connecting to IMAP server {self.config.imap_host}:{self.config.imap_port}") + self.client = aioimaplib.IMAP4_SSL( + host=self.config.imap_host, + port=self.config.imap_port + ) + print("Waiting for server hello...") + await self.client.wait_hello_from_server() + print(f"Logging in as {self.config.imap_username}...") + await self.client.login(self.config.imap_username, self.config.imap_password) + print("Selecting INBOX...") + await self.client.select("INBOX") + print("Successfully connected to IMAP server") + return True + except Exception as e: + print(f"Failed to connect to IMAP: {e}") + print(f"IMAP Host: {self.config.imap_host}") + print(f"IMAP Port: {self.config.imap_port}") + print(f"IMAP Username: {self.config.imap_username}") + return False + + async def disconnect(self): + """Disconnect from IMAP server.""" + if self.client: + await self.client.logout() + self.client = None + + async def test_connection(self) -> bool: + """Test IMAP connection and return detailed status.""" + try: + print("Testing IMAP connection...") + success = await self.connect() + if success: + print("✓ IMAP connection test successful") + await self.disconnect() + return True + else: + print("✗ IMAP connection test failed") + return False + except Exception as e: + print(f"✗ IMAP connection test failed with exception: {e}") + return False + + async def fetch_new_messages(self, mark_as_read: bool = False) -> List[EmailMessage]: + """Fetch new (unread) messages from inbox.""" + if not self.client: + print("No IMAP client connection, attempting to connect...") + if not await self.connect(): + raise ConnectionError("Failed to connect to IMAP server") + + if not self.client: # Type guard + raise ConnectionError("IMAP client is None after connection attempt") + + try: + # Search for unseen messages + search_criteria = "UNSEEN" + print(f"Searching for messages with criteria: {search_criteria}") + response = await self.client.search(search_criteria) + + if response.result != "OK": + raise RuntimeError(f"IMAP search failed with result: {response.result}") + + # Handle message IDs - they come as bytes and need to be decoded + raw_message_ids = response.lines[0].split() if response.lines[0] else [] + message_ids = [] + for raw_id in raw_message_ids: + if isinstance(raw_id, bytes): + message_ids.append(raw_id.decode('utf-8')) + else: + message_ids.append(str(raw_id)) + + print(f"Found {len(message_ids)} unread messages") + messages = [] + + for msg_id in message_ids: + # Fetch message + print(f"Fetching message ID: {msg_id}") + fetch_response = await self.client.fetch(msg_id, "RFC822") + if fetch_response.result != "OK": + print(f"Failed to fetch message {msg_id}: {fetch_response.result}") + if hasattr(fetch_response, 'lines') and fetch_response.lines: + print(f"Server response: {fetch_response.lines}") + continue + + # Parse message + raw_email = fetch_response.lines[1] + email_msg = email_module.message_from_bytes(raw_email) + + # Extract message details + subject = email_msg.get("Subject", "") + from_email = email_msg.get("From", "") + to_email = email_msg.get("To", "") + message_id = email_msg.get("Message-ID", "") + in_reply_to = email_msg.get("In-Reply-To", "") + date_str = email_msg.get("Date", "") + + # Parse date + received_at = None + if date_str: + try: + import email.utils + date_tuple = email.utils.parsedate_tz(date_str) + if date_tuple: + timestamp = email.utils.mktime_tz(date_tuple) + received_at = datetime.fromtimestamp(timestamp, tz=timezone.utc) + except: + pass + + # Extract content + content = "" + if email_msg.is_multipart(): + for part in email_msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if isinstance(payload, bytes): + content = payload.decode("utf-8", errors="ignore") + break + elif part.get_content_type() == "text/html" and not content: + payload = part.get_payload(decode=True) + if isinstance(payload, bytes): + content = payload.decode("utf-8", errors="ignore") + else: + payload = email_msg.get_payload(decode=True) + if isinstance(payload, bytes): + content = payload.decode("utf-8", errors="ignore") + elif isinstance(payload, str): + content = payload + + # Create EmailMessage object + msg = EmailMessage( + subject=subject, + content=content, + from_email=from_email, + to_email=to_email, + message_id=message_id, + in_reply_to=in_reply_to, + received_at=received_at + ) + messages.append(msg) + + # Mark as read if requested + if mark_as_read: + await self.client.store(msg_id, "+FLAGS", "\\Seen") + + return messages + + except Exception as e: + print(f"Failed to fetch messages: {e}") + raise RuntimeError(f"Failed to fetch messages: {e}") from e + + async def mark_as_read(self, message_id: str) -> bool: + """Mark a specific message as read.""" + if not self.client: + print("No IMAP client connection, attempting to connect...") + if not await self.connect(): + raise ConnectionError("Failed to connect to IMAP server") + + if not self.client: # Type guard + raise ConnectionError("IMAP client is None after connection attempt") + + try: + # Search for the message by Message-ID + print(f"Searching for message with ID: {message_id}") + search_response = await self.client.search(f'HEADER Message-ID "{message_id}"') + if search_response.result != "OK": + raise RuntimeError(f"IMAP search failed with result: {search_response.result}") + + if not search_response.lines[0]: + print(f"No message found with Message-ID: {message_id}") + return False + + # Handle message IDs - they come as bytes and need to be decoded + raw_msg_ids = search_response.lines[0].split() + msg_ids = [] + for raw_id in raw_msg_ids: + if isinstance(raw_id, bytes): + msg_ids.append(raw_id.decode('utf-8')) + else: + msg_ids.append(str(raw_id)) + + print(f"Found {len(msg_ids)} messages to mark as read") + for msg_id in msg_ids: + await self.client.store(msg_id, "+FLAGS", "\\Seen") + + return True + + except Exception as e: + print(f"Failed to mark message as read: {e}") + raise RuntimeError(f"Failed to mark message as read: {e}") from e + + +class EmailService: + """Main email service combining SMTP and IMAP functionality.""" + + def __init__(self): + self.config = EmailConfig() + self.smtp_client = SMTPClient(self.config) + self.imap_client = IMAPClient(self.config) + + async def send_email(self, *args, **kwargs) -> bool: + """Send email via SMTP client.""" + return await self.smtp_client.send_email(*args, **kwargs) + + async def send_game_notification(self, *args, **kwargs) -> bool: + """Send game notification via SMTP client.""" + return await self.smtp_client.send_game_notification(*args, **kwargs) + + async def fetch_new_messages(self, *args, **kwargs) -> List[EmailMessage]: + """Fetch new messages via IMAP client.""" + return await self.imap_client.fetch_new_messages(*args, **kwargs) + + async def mark_as_read(self, *args, **kwargs) -> bool: + """Mark message as read via IMAP client.""" + return await self.imap_client.mark_as_read(*args, **kwargs) + + async def process_incoming_emails(self) -> List[Dict[str, Any]]: + """Process incoming emails and convert them to game actions.""" + messages = await self.fetch_new_messages(mark_as_read=True) + processed_messages = [] + + for msg in messages: + # Parse game commands from email content + processed_msg = { + "from_email": msg.from_email, + "subject": msg.subject, + "content": msg.content, + "message_id": msg.message_id, + "received_at": msg.received_at, + "commands": self._extract_game_commands(msg.content) + } + processed_messages.append(processed_msg) + + return processed_messages + + def _extract_game_commands(self, content: str) -> List[str]: + """Extract game commands from email content.""" + # Simple command extraction - can be enhanced + commands = [] + lines = content.split('\n') + + for line in lines: + line = line.strip().lower() + # Look for common game commands + if line.startswith(('go ', 'move ', 'walk ', 'run ')): + commands.append(line) + elif line.startswith(('say ', 'tell ', 'whisper ')): + commands.append(line) + elif line.startswith(('look', 'examine ', 'inspect ')): + commands.append(line) + elif line.startswith(('take ', 'get ', 'pick up ')): + commands.append(line) + elif line.startswith(('drop ', 'put down ')): + commands.append(line) + elif line.startswith(('use ', 'cast ', 'drink ', 'eat ')): + commands.append(line) + elif line in ['help', 'inventory', 'stats', 'status', 'quit', 'save']: + commands.append(line) + + return commands + + async def cleanup(self): + """Cleanup email connections.""" + await self.imap_client.disconnect() diff --git a/src/carbon_copy/main.py b/src/carbon_copy/main.py index 9b3a7ba..94ca164 100644 --- a/src/carbon_copy/main.py +++ b/src/carbon_copy/main.py @@ -2,23 +2,248 @@ # # SPDX-License-Identifier: MIT +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone from pathlib import Path -from fastapi import FastAPI, Request + +from fastapi import Depends, FastAPI, Request from fastapi.templating import Jinja2Templates +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.sql import func + +from .database import create_db_and_tables, get_async_session +from .email_client import EmailService +from .models import * # Import all models # Get the directory where this file is located BASE_DIR = Path(__file__).resolve().parent +# Initialize email service +email_service = EmailService() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager.""" + # Startup + create_db_and_tables() + print("Database tables created") + print("Carbon Copy started - Email text adventure ready!") + + yield + + # Shutdown + await email_service.cleanup() + print("Carbon Copy shutting down") + + app = FastAPI( title="Carbon Copy", description="An email-based multiplayer text adventure for the masses", - version="0.0.1" + version="0.0.1", + lifespan=lifespan ) templates = Jinja2Templates(directory=BASE_DIR / "templates") -# Serve the home page + @app.get("/") async def root(request: Request): """Return the home page""" return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "carbon-copy"} + + +@app.get("/api/stats") +async def get_stats(session: AsyncSession = Depends(get_async_session)): + """Get basic game statistics.""" + from sqlmodel import func, select + + # Count users + user_result = await session.execute(select(func.count()).select_from(User)) + user_count = user_result.scalar_one() + + # Count rooms + room_result = await session.execute(select(func.count()).select_from(Room)) + room_count = room_result.scalar_one() + + # Count active users (online in last hour) + one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1) + try: + active_result = await session.execute( + select(func.count()).select_from(User).where( + User.last_activity.is_not(None) and User.last_activity > one_hour_ago # type: ignore + ) + ) + active_count = active_result.scalar_one() + except Exception: + # Fallback if the query fails + active_count = 0 + + return { + "total_users": user_count, + "total_rooms": room_count, + "active_users": active_count, + "game_status": "running" + } + + +@app.post("/api/process-emails") +async def process_emails(session: AsyncSession = Depends(get_async_session)): + """Manually trigger email processing and save to database.""" + try: + # Fetch and process emails + messages = await email_service.process_incoming_emails() + + saved_messages = [] + new_users_created = 0 + + for email_data in messages: + # Extract email address from the from_email field + from_email = email_data["from_email"] + + # Parse email address (handle "Name " format) + if "<" in from_email and ">" in from_email: + email_address = from_email.split("<")[1].split(">")[0].strip() + else: + email_address = from_email.strip() + + # Find or create user + user_result = await session.execute( + select(User).where(User.email == email_address) + ) + user = user_result.scalar_one_or_none() + + if not user: + # Create new user + username = email_address.split("@")[0] # Use part before @ as username + # Make sure username is unique + existing_username = await session.execute( + select(User).where(User.username == username) + ) + if existing_username.scalar_one_or_none(): + # Add timestamp to make it unique + import time + username = f"{username}_{int(time.time())}" + + user = User( + username=username, + email=email_address, + display_name=username, + hashed_password="email_user_no_password", # Placeholder for email-only users + role=UserRole.PLAYER, + status=UserStatus.ACTIVE + ) + session.add(user) + await session.commit() # Commit to get the user ID + await session.refresh(user) + new_users_created += 1 + print(f"Created new user: {username} ({email_address})") + + # Create message record + message = Message( + subject=email_data["subject"], + content=email_data["content"], + message_type=MessageType.CHAT, + status=MessageStatus.DELIVERED, + sender_id=user.id, + email_message_id=email_data["message_id"], + contains_commands=len(email_data["commands"]) > 0, + created_at=email_data["received_at"] or datetime.now(timezone.utc), + delivered_at=datetime.now(timezone.utc) + ) + + session.add(message) + + # Update user's last activity + user.last_activity = datetime.now(timezone.utc) + user.last_email_check = datetime.now(timezone.utc) + + saved_messages.append({ + "id": "pending", # Will be set after commit + "from_email": email_address, + "subject": email_data["subject"], + "content": email_data["content"][:100] + "..." if len(email_data["content"]) > 100 else email_data["content"], + "commands": email_data["commands"], + "user_id": user.id, + "username": user.username + }) + + # Commit all changes + await session.commit() + + return { + "status": "success", + "messages_processed": len(messages), + "messages_saved": len(saved_messages), + "new_users_created": new_users_created, + "saved_messages": saved_messages + } + + except Exception as e: + await session.rollback() + print(f"Error processing emails: {e}") + return { + "status": "error", + "message": str(e) + } + + +@app.get("/api/messages") +async def get_messages( + limit: int = 20, + offset: int = 0, + session: AsyncSession = Depends(get_async_session) +): + """Get recent messages from the database.""" + try: + # Get recent email messages (all messages in this system are from emails) + messages_result = await session.execute( + select(Message) + .limit(limit) + .offset(offset) + ) + messages_data = messages_result.scalars().all() + + # Get all users for lookup + users_result = await session.execute(select(User)) + users = {user.id: user for user in users_result.scalars().all()} + + messages = [] + for message in messages_data: + sender = users.get(message.sender_id) if message.sender_id else None + + messages.append({ + "id": message.id, + "subject": message.subject, + "content": message.content, + "email_message_id": message.email_message_id, + "contains_commands": message.contains_commands, + "created_at": message.created_at.isoformat() if message.created_at else None, + "sender": { + "id": sender.id if sender else None, + "username": sender.username if sender else None, + "email": sender.email if sender else None, + "display_name": sender.display_name if sender else None + } if sender else None + }) + + return { + "status": "success", + "messages": messages, + "limit": limit, + "offset": offset, + "count": len(messages) + } + + except Exception as e: + return { + "status": "error", + "message": str(e) + } diff --git a/src/carbon_copy/models.py b/src/carbon_copy/models.py new file mode 100644 index 0000000..786c8bd --- /dev/null +++ b/src/carbon_copy/models.py @@ -0,0 +1,503 @@ +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +from datetime import datetime, timezone +from typing import Optional, List +from enum import Enum +from sqlmodel import SQLModel, Field, Relationship + + +class UserStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + BANNED = "banned" + SUSPENDED = "suspended" + + +class UserRole(str, Enum): + PLAYER = "player" + MODERATOR = "moderator" + ADMIN = "admin" + GAME_MASTER = "game_master" + + +class MessageType(str, Enum): + CHAT = "chat" + ACTION = "action" + SYSTEM = "system" + WHISPER = "whisper" + ROOM_ANNOUNCEMENT = "room_announcement" + GLOBAL_ANNOUNCEMENT = "global_announcement" + + +class MessageStatus(str, Enum): + SENT = "sent" + DELIVERED = "delivered" + READ = "read" + FAILED = "failed" + + +class ItemType(str, Enum): + WEAPON = "weapon" + ARMOR = "armor" + CONSUMABLE = "consumable" + TOOL = "tool" + TREASURE = "treasure" + KEY = "key" + BOOK = "book" + CONTAINER = "container" + QUEST_ITEM = "quest_item" + + +class ItemRarity(str, Enum): + COMMON = "common" + UNCOMMON = "uncommon" + RARE = "rare" + EPIC = "epic" + LEGENDARY = "legendary" + + +class RoomType(str, Enum): + INDOOR = "indoor" + OUTDOOR = "outdoor" + DUNGEON = "dungeon" + WILDERNESS = "wilderness" + CITY = "city" + SHOP = "shop" + TAVERN = "tavern" + TEMPLE = "temple" + + +class NPCType(str, Enum): + MERCHANT = "merchant" + GUARD = "guard" + QUEST_GIVER = "quest_giver" + ENEMY = "enemy" + FRIENDLY = "friendly" + NEUTRAL = "neutral" + BOSS = "boss" + + +class VehicleType(str, Enum): + HORSE = "horse" + CART = "cart" + SHIP = "ship" + CARRIAGE = "carriage" + FLYING_MOUNT = "flying_mount" + MAGICAL_TRANSPORT = "magical_transport" + + +class VehicleStatus(str, Enum): + AVAILABLE = "available" + IN_USE = "in_use" + BROKEN = "broken" + MAINTENANCE = "maintenance" + + +# Association tables for many-to-many relationships +class UserItemLink(SQLModel, table=True): + user_id: Optional[int] = Field(default=None, foreign_key="user.id", primary_key=True) + item_id: Optional[int] = Field(default=None, foreign_key="item.id", primary_key=True) + quantity: int = Field(default=1) + equipped: bool = Field(default=False) + obtained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class RoomItemLink(SQLModel, table=True): + room_id: Optional[int] = Field(default=None, foreign_key="room.id", primary_key=True) + item_id: Optional[int] = Field(default=None, foreign_key="item.id", primary_key=True) + quantity: int = Field(default=1) + position_x: Optional[float] = Field(default=None) + position_y: Optional[float] = Field(default=None) + hidden: bool = Field(default=False) + + +class UserRoomHistory(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + room_id: int = Field(foreign_key="room.id") + entered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + left_at: Optional[datetime] = Field(default=None) + duration_minutes: Optional[int] = Field(default=None) + + # Relationships + user: Optional["User"] = Relationship(back_populates="room_history") + + +# Main models +class User(SQLModel, table=True): + + id: Optional[int] = Field(default=None, primary_key=True) + email: str = Field(unique=True, index=True) + username: str = Field(unique=True, index=True) + display_name: str + hashed_password: str + + # Game stats + level: int = Field(default=1) + experience: int = Field(default=0) + health: int = Field(default=100) + max_health: int = Field(default=100) + energy: int = Field(default=100) + max_energy: int = Field(default=100) + strength: int = Field(default=10) + dexterity: int = Field(default=10) + intelligence: int = Field(default=10) + charisma: int = Field(default=10) + + # Currency and resources + gold: int = Field(default=0) + silver: int = Field(default=0) + copper: int = Field(default=100) + + # Status and role + status: UserStatus = Field(default=UserStatus.ACTIVE) + role: UserRole = Field(default=UserRole.PLAYER) + + # Location and movement + current_room_id: Optional[int] = Field(default=1, foreign_key="room.id") + last_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + position_x: Optional[float] = Field(default=0.0) + position_y: Optional[float] = Field(default=0.0) + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + last_login: Optional[datetime] = Field(default=None) + last_activity: Optional[datetime] = Field(default=None) + last_email_check: Optional[datetime] = Field(default=None) + + # Game state + is_online: bool = Field(default=False) + is_in_combat: bool = Field(default=False) + is_resting: bool = Field(default=False) + respawn_time: Optional[datetime] = Field(default=None) + + # Preferences + email_notifications: bool = Field(default=True) + auto_save_interval: int = Field(default=300) # seconds + preferred_language: str = Field(default="en") + timezone: str = Field(default="UTC") + + # Relationships + current_room: Optional["Room"] = Relationship( + back_populates="current_users", + sa_relationship_kwargs={"foreign_keys": "[User.current_room_id]"} + ) + sent_messages: List["Message"] = Relationship( + back_populates="sender", + sa_relationship_kwargs={"foreign_keys": "[Message.sender_id]"} + ) + received_messages: List["Message"] = Relationship( + back_populates="recipient", + sa_relationship_kwargs={"foreign_keys": "[Message.recipient_id]"} + ) + items: List["Item"] = Relationship(back_populates="users", link_model=UserItemLink) + room_history: List["UserRoomHistory"] = Relationship(back_populates="user") + owned_vehicles: List["Vehicle"] = Relationship(back_populates="owner") + + +class Room(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + short_description: str + long_description: str + + # Room properties + room_type: RoomType = Field(default=RoomType.INDOOR) + is_safe: bool = Field(default=True) + is_pvp_enabled: bool = Field(default=False) + is_accessible: bool = Field(default=True) + is_hidden: bool = Field(default=False) + requires_light: bool = Field(default=False) + + # Environmental factors + temperature: Optional[int] = Field(default=20) # Celsius + humidity: Optional[int] = Field(default=50) # Percentage + light_level: int = Field(default=100) # 0-100 + noise_level: int = Field(default=0) # 0-100 + + # Size and capacity + max_occupancy: int = Field(default=10) + area_square_meters: Optional[float] = Field(default=None) + ceiling_height: Optional[float] = Field(default=None) + + # Coordinates for mapping + zone_id: Optional[int] = Field(default=None) + world_x: Optional[float] = Field(default=None) + world_y: Optional[float] = Field(default=None) + world_z: Optional[float] = Field(default=None) + + # Connected rooms (simplified - could be expanded to a separate table) + north_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + south_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + east_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + west_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + up_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + down_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + last_cleaned: Optional[datetime] = Field(default=None) + + # Relationships + current_users: List["User"] = Relationship( + back_populates="current_room", + sa_relationship_kwargs={"foreign_keys": "[User.current_room_id]"} + ) + messages: List["Message"] = Relationship(back_populates="room") + items: List["Item"] = Relationship(back_populates="rooms", link_model=RoomItemLink) + npcs: List["NPC"] = Relationship( + back_populates="current_room", + sa_relationship_kwargs={"foreign_keys": "[NPC.current_room_id]"} + ) + vehicles: List["Vehicle"] = Relationship( + back_populates="current_room", + sa_relationship_kwargs={"foreign_keys": "[Vehicle.current_room_id]"} + ) + + +class Message(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + subject: Optional[str] = Field(default=None) + content: str + + # Message metadata + message_type: MessageType = Field(default=MessageType.CHAT) + status: MessageStatus = Field(default=MessageStatus.SENT) + priority: int = Field(default=1) # 1-5, 5 being highest + + # Sender and recipient + sender_id: Optional[int] = Field(default=None, foreign_key="user.id") + recipient_id: Optional[int] = Field(default=None, foreign_key="user.id") + room_id: Optional[int] = Field(default=None, foreign_key="room.id") + + # Email integration + email_message_id: Optional[str] = Field(default=None, unique=True) + email_thread_id: Optional[str] = Field(default=None) + reply_to_message_id: Optional[int] = Field(default=None, foreign_key="message.id") + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + sent_at: Optional[datetime] = Field(default=None) + delivered_at: Optional[datetime] = Field(default=None) + read_at: Optional[datetime] = Field(default=None) + expires_at: Optional[datetime] = Field(default=None) + + # Content flags + is_encrypted: bool = Field(default=False) + contains_commands: bool = Field(default=False) + is_system_generated: bool = Field(default=False) + is_broadcast: bool = Field(default=False) + + # Relationships + sender: Optional["User"] = Relationship( + back_populates="sent_messages", + sa_relationship_kwargs={"foreign_keys": "[Message.sender_id]"} + ) + recipient: Optional["User"] = Relationship( + back_populates="received_messages", + sa_relationship_kwargs={"foreign_keys": "[Message.recipient_id]"} + ) + room: Optional["Room"] = Relationship(back_populates="messages") + replies: List["Message"] = Relationship( + back_populates="parent_message", + sa_relationship_kwargs={"foreign_keys": "[Message.reply_to_message_id]"} + ) + parent_message: Optional["Message"] = Relationship( + back_populates="replies", + sa_relationship_kwargs={"remote_side": "[Message.id]", "foreign_keys": "[Message.reply_to_message_id]"} + ) + + +class Item(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + description: str + short_description: str + + # Item properties + item_type: ItemType = Field(default=ItemType.TREASURE) + rarity: ItemRarity = Field(default=ItemRarity.COMMON) + + # Value and weight + base_value: int = Field(default=0) # in copper + weight: float = Field(default=0.0) # in kg + volume: float = Field(default=0.0) # in liters + + # Durability and condition + max_durability: int = Field(default=100) + current_durability: int = Field(default=100) + repair_cost: int = Field(default=0) + + # Combat stats (for weapons/armor) + damage: int = Field(default=0) + armor_value: int = Field(default=0) + accuracy_bonus: int = Field(default=0) + critical_chance: float = Field(default=0.0) + + # Stat bonuses + strength_bonus: int = Field(default=0) + dexterity_bonus: int = Field(default=0) + intelligence_bonus: int = Field(default=0) + charisma_bonus: int = Field(default=0) + health_bonus: int = Field(default=0) + energy_bonus: int = Field(default=0) + + # Usage properties + is_consumable: bool = Field(default=False) + is_stackable: bool = Field(default=False) + max_stack_size: int = Field(default=1) + uses_remaining: Optional[int] = Field(default=None) + cooldown_seconds: int = Field(default=0) + + # Requirements + required_level: int = Field(default=1) + required_strength: int = Field(default=0) + required_dexterity: int = Field(default=0) + required_intelligence: int = Field(default=0) + + # Flags + is_unique: bool = Field(default=False) + is_cursed: bool = Field(default=False) + is_blessed: bool = Field(default=False) + is_magical: bool = Field(default=False) + is_quest_item: bool = Field(default=False) + can_be_dropped: bool = Field(default=True) + can_be_traded: bool = Field(default=True) + can_be_sold: bool = Field(default=True) + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + # Relationships + users: List["User"] = Relationship(back_populates="items", link_model=UserItemLink) + rooms: List["Room"] = Relationship(back_populates="items", link_model=RoomItemLink) + + +class NPC(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + description: str + short_description: str + + # NPC properties + npc_type: NPCType = Field(default=NPCType.FRIENDLY) + level: int = Field(default=1) + + # Stats + health: int = Field(default=100) + max_health: int = Field(default=100) + energy: int = Field(default=100) + max_energy: int = Field(default=100) + strength: int = Field(default=10) + dexterity: int = Field(default=10) + intelligence: int = Field(default=10) + charisma: int = Field(default=10) + + # Combat properties + damage: int = Field(default=10) + armor: int = Field(default=0) + accuracy: int = Field(default=75) + evasion: int = Field(default=5) + + # Behavior + is_aggressive: bool = Field(default=False) + is_mobile: bool = Field(default=True) + respawn_time_minutes: int = Field(default=15) + patrol_range: int = Field(default=3) # rooms + + # Location + current_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + spawn_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + position_x: Optional[float] = Field(default=0.0) + position_y: Optional[float] = Field(default=0.0) + + # Loot and rewards + gold_drop_min: int = Field(default=0) + gold_drop_max: int = Field(default=0) + experience_reward: int = Field(default=10) + + # Dialogue and quests + greeting_message: Optional[str] = Field(default=None) + farewell_message: Optional[str] = Field(default=None) + shop_inventory: Optional[str] = Field(default=None) # JSON string of item IDs + + # State + is_alive: bool = Field(default=True) + last_death: Optional[datetime] = Field(default=None) + last_respawn: Optional[datetime] = Field(default=None) + last_movement: Optional[datetime] = Field(default=None) + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + # Relationships + current_room: Optional["Room"] = Relationship( + back_populates="npcs", + sa_relationship_kwargs={"foreign_keys": "[NPC.current_room_id]"} + ) + + +class Vehicle(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + description: str + + # Vehicle properties + vehicle_type: VehicleType = Field(default=VehicleType.HORSE) + status: VehicleStatus = Field(default=VehicleStatus.AVAILABLE) + + # Capacity and size + max_passengers: int = Field(default=1) + max_cargo_weight: float = Field(default=100.0) # kg + max_cargo_volume: float = Field(default=50.0) # liters + + # Performance + speed: float = Field(default=1.0) # multiplier for travel time + fuel_capacity: Optional[float] = Field(default=None) + fuel_current: Optional[float] = Field(default=None) + fuel_consumption: Optional[float] = Field(default=None) # per distance unit + + # Durability + max_durability: int = Field(default=100) + current_durability: int = Field(default=100) + maintenance_cost: int = Field(default=0) + last_maintenance: Optional[datetime] = Field(default=None) + + # Location and ownership + owner_id: Optional[int] = Field(default=None, foreign_key="user.id") + current_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + parked_room_id: Optional[int] = Field(default=None, foreign_key="room.id") + + # Requirements + required_skill_level: int = Field(default=0) + license_required: bool = Field(default=False) + + # Value + purchase_cost: int = Field(default=1000) + current_value: int = Field(default=1000) + insurance_cost: int = Field(default=0) + + # Features + has_storage: bool = Field(default=False) + is_magical: bool = Field(default=False) + can_fly: bool = Field(default=False) + can_sail: bool = Field(default=False) + is_amphibious: bool = Field(default=False) + + # Timestamps + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + last_used: Optional[datetime] = Field(default=None) + + # Relationships + owner: Optional["User"] = Relationship(back_populates="owned_vehicles") + current_room: Optional["Room"] = Relationship( + back_populates="vehicles", + sa_relationship_kwargs={"foreign_keys": "[Vehicle.current_room_id]"} + ) diff --git a/src/carbon_copy/seed_data.py b/src/carbon_copy/seed_data.py new file mode 100644 index 0000000..a259af7 --- /dev/null +++ b/src/carbon_copy/seed_data.py @@ -0,0 +1,327 @@ +# SPDX-FileCopyrightText: 2025-present Micha Albert +# +# SPDX-License-Identifier: MIT + +from datetime import datetime, timezone +from sqlmodel import Session, select +from .database import engine +from .models import * + + +def create_initial_rooms(): + """Create initial game rooms.""" + rooms_data = [ + { + "id": 1, + "name": "Town Square", + "short_description": "The bustling heart of the town", + "long_description": "You stand in the center of a busy town square. Cobblestone paths lead in all directions, and a beautiful fountain sits in the middle. Merchants hawk their wares while children play around the fountain's edge.", + "room_type": RoomType.OUTDOOR, + "is_safe": True, + "max_occupancy": 20, + "north_room_id": 2, + "east_room_id": 3, + "west_room_id": 4, + "south_room_id": 5 + }, + { + "id": 2, + "name": "North Gate", + "short_description": "The northern entrance to town", + "long_description": "A massive stone archway marks the northern entrance to the town. Guards in shining armor stand watch, checking travelers as they come and go. Beyond the gate, you can see a winding road leading into dark woods.", + "room_type": RoomType.OUTDOOR, + "is_safe": True, + "south_room_id": 1, + "north_room_id": 6 + }, + { + "id": 3, + "name": "The Prancing Pony Tavern", + "short_description": "A cozy tavern filled with warm light", + "long_description": "The Prancing Pony is a welcoming tavern with low wooden beams, flickering candles, and the smell of roasted meat in the air. Patrons sit around heavy oak tables, sharing stories and ale. A large fireplace crackles merrily in the corner.", + "room_type": RoomType.TAVERN, + "is_safe": True, + "west_room_id": 1, + "max_occupancy": 15 + }, + { + "id": 4, + "name": "General Store", + "short_description": "A well-stocked merchant's shop", + "long_description": "Shelves line the walls from floor to ceiling, packed with all manner of goods. Weapons, armor, potions, and supplies fill every available space. The shopkeeper, a portly man with a friendly smile, stands behind a wooden counter.", + "room_type": RoomType.SHOP, + "is_safe": True, + "east_room_id": 1, + "max_occupancy": 8 + }, + { + "id": 5, + "name": "South Road", + "short_description": "A dusty road leading south", + "long_description": "The road stretches south from the town, disappearing over rolling hills. Cart tracks mark the packed earth, and wildflowers grow along the roadside. A wooden signpost points toward distant cities.", + "room_type": RoomType.OUTDOOR, + "is_safe": True, + "north_room_id": 1, + "south_room_id": 7 + }, + { + "id": 6, + "name": "Dark Woods", + "short_description": "A mysterious forest path", + "long_description": "Ancient trees tower overhead, their branches forming a canopy that blocks most of the sunlight. The air is cool and damp, filled with the sounds of rustling leaves and distant bird calls. A narrow path winds deeper into the woods.", + "room_type": RoomType.WILDERNESS, + "is_safe": False, + "south_room_id": 2, + "light_level": 30, + "requires_light": True + }, + { + "id": 7, + "name": "Hillside Farm", + "short_description": "A peaceful farming settlement", + "long_description": "Rolling green fields stretch as far as the eye can see, dotted with grazing sheep and golden wheat. A small farmhouse sits atop a nearby hill, smoke rising from its chimney. The air smells of fresh earth and growing things.", + "room_type": RoomType.OUTDOOR, + "is_safe": True, + "north_room_id": 5, + "max_occupancy": 6 + } + ] + + with Session(engine) as session: + for room_data in rooms_data: + # Check if room already exists + existing_room = session.exec(select(Room).where(Room.id == room_data["id"])).first() + if not existing_room: + room = Room(**room_data) + session.add(room) + + session.commit() + + +def create_initial_items(): + """Create initial game items.""" + items_data = [ + { + "name": "Rusty Sword", + "description": "An old iron sword with spots of rust along the blade. Despite its age, it still holds a sharp edge.", + "short_description": "A rusty but functional sword", + "item_type": ItemType.WEAPON, + "rarity": ItemRarity.COMMON, + "base_value": 50, + "weight": 2.5, + "damage": 8, + "required_level": 1, + "can_be_traded": True + }, + { + "name": "Leather Armor", + "description": "Well-crafted leather armor that provides basic protection while allowing good mobility.", + "short_description": "Simple but effective leather armor", + "item_type": ItemType.ARMOR, + "rarity": ItemRarity.COMMON, + "base_value": 75, + "weight": 5.0, + "armor_value": 5, + "required_level": 1, + "can_be_traded": True + }, + { + "name": "Health Potion", + "description": "A small glass vial filled with a red liquid that glows with magical energy. Drinking it will restore some health.", + "short_description": "A magical healing potion", + "item_type": ItemType.CONSUMABLE, + "rarity": ItemRarity.COMMON, + "base_value": 25, + "weight": 0.2, + "health_bonus": 25, + "is_consumable": True, + "is_stackable": True, + "max_stack_size": 10, + "uses_remaining": 1 + }, + { + "name": "Traveler's Backpack", + "description": "A sturdy leather backpack with multiple compartments and reinforced straps. Perfect for long journeys.", + "short_description": "A reliable traveler's backpack", + "item_type": ItemType.CONTAINER, + "rarity": ItemRarity.COMMON, + "base_value": 40, + "weight": 1.0, + "can_be_traded": True + }, + { + "name": "Magic Crystal", + "description": "A beautiful crystal that pulses with inner light. It feels warm to the touch and seems to contain powerful magic.", + "short_description": "A glowing magical crystal", + "item_type": ItemType.TREASURE, + "rarity": ItemRarity.RARE, + "base_value": 500, + "weight": 0.5, + "is_magical": True, + "intelligence_bonus": 2, + "energy_bonus": 10 + } + ] + + with Session(engine) as session: + for item_data in items_data: + # Check if item already exists + existing_item = session.exec(select(Item).where(Item.name == item_data["name"])).first() + if not existing_item: + item = Item(**item_data) + session.add(item) + + session.commit() + + +def create_initial_npcs(): + """Create initial NPCs.""" + npcs_data = [ + { + "name": "Marcus the Shopkeeper", + "description": "A portly, middle-aged man with a friendly disposition and keen eye for business.", + "short_description": "The friendly general store owner", + "npc_type": NPCType.MERCHANT, + "level": 5, + "current_room_id": 4, + "spawn_room_id": 4, + "is_aggressive": False, + "is_mobile": False, + "greeting_message": "Welcome to my shop! I have the finest goods in town.", + "farewell_message": "Thank you for your business! Come back anytime.", + "charisma": 15 + }, + { + "name": "Captain Roderick", + "description": "A stern-looking guard captain with graying hair and battle scars. His armor is well-maintained and his sword always ready.", + "short_description": "The town guard captain", + "npc_type": NPCType.GUARD, + "level": 8, + "current_room_id": 2, + "spawn_room_id": 2, + "strength": 16, + "dexterity": 12, + "health": 150, + "max_health": 150, + "damage": 15, + "armor": 8, + "is_aggressive": False, + "greeting_message": "Halt! State your business in our fair town.", + "farewell_message": "Move along, citizen. Keep the peace." + }, + { + "name": "Old Tom the Storyteller", + "description": "An elderly man with twinkling eyes and a white beard. He sits by the tavern's fireplace, always ready with a tale.", + "short_description": "An old storyteller with many tales", + "npc_type": NPCType.QUEST_GIVER, + "level": 3, + "current_room_id": 3, + "spawn_room_id": 3, + "is_aggressive": False, + "is_mobile": False, + "intelligence": 18, + "charisma": 16, + "greeting_message": "Come, sit by the fire! I have stories that will amaze you.", + "farewell_message": "Safe travels, young one. May your adventures be legendary!" + }, + { + "name": "Forest Wolf", + "description": "A large, gray wolf with yellow eyes that gleam with wild intelligence. Its hackles are raised and it bares its fangs.", + "short_description": "A dangerous forest predator", + "npc_type": NPCType.ENEMY, + "level": 4, + "current_room_id": 6, + "spawn_room_id": 6, + "strength": 14, + "dexterity": 16, + "health": 80, + "max_health": 80, + "damage": 12, + "armor": 2, + "accuracy": 80, + "evasion": 15, + "is_aggressive": True, + "respawn_time_minutes": 30, + "experience_reward": 50, + "gold_drop_min": 5, + "gold_drop_max": 15 + } + ] + + with Session(engine) as session: + for npc_data in npcs_data: + # Check if NPC already exists + existing_npc = session.exec(select(NPC).where(NPC.name == npc_data["name"])).first() + if not existing_npc: + npc = NPC(**npc_data) + session.add(npc) + + session.commit() + + +def create_initial_vehicles(): + """Create initial vehicles.""" + vehicles_data = [ + { + "name": "Brown Mare", + "description": "A sturdy brown horse with a gentle temperament. She's well-trained for riding and responds well to commands.", + "vehicle_type": VehicleType.HORSE, + "status": VehicleStatus.AVAILABLE, + "max_passengers": 1, + "max_cargo_weight": 50.0, + "speed": 2.0, + "current_room_id": 1, + "parked_room_id": 1, + "purchase_cost": 300, + "current_value": 300, + "max_durability": 100, + "current_durability": 100 + }, + { + "name": "Merchant's Cart", + "description": "A wooden cart with iron-reinforced wheels. Perfect for transporting goods between towns.", + "vehicle_type": VehicleType.CART, + "status": VehicleStatus.AVAILABLE, + "max_passengers": 2, + "max_cargo_weight": 200.0, + "max_cargo_volume": 150.0, + "speed": 1.5, + "current_room_id": 4, + "parked_room_id": 4, + "purchase_cost": 150, + "current_value": 150, + "has_storage": True, + "required_skill_level": 1 + } + ] + + with Session(engine) as session: + for vehicle_data in vehicles_data: + # Check if vehicle already exists + existing_vehicle = session.exec(select(Vehicle).where(Vehicle.name == vehicle_data["name"])).first() + if not existing_vehicle: + vehicle = Vehicle(**vehicle_data) + session.add(vehicle) + + session.commit() + + +def initialize_game_world(): + """Initialize the complete game world with all seed data.""" + print("Creating initial rooms...") + create_initial_rooms() + + print("Creating initial items...") + create_initial_items() + + print("Creating initial NPCs...") + create_initial_npcs() + + print("Creating initial vehicles...") + create_initial_vehicles() + + print("Game world initialization complete!") + + +if __name__ == "__main__": + initialize_game_world() diff --git a/src/carbon_copy/templates/index.html b/src/carbon_copy/templates/index.html index 6bcc55e..5481f4b 100644 --- a/src/carbon_copy/templates/index.html +++ b/src/carbon_copy/templates/index.html @@ -7,7 +7,26 @@ Carbon Copy -

Hello World

-

Welcome to Carbon Copy!

+

Welcome to Carbon Copy

+

An email-based multiplayer text adventure for the masses

+ +

How to Play

+
    +
  • Send an email to the game master to create your character
  • +
  • Use simple commands like "look", "go north", "take sword"
  • +
  • Receive game updates via email
  • +
  • Adventure with other players in a persistent world
  • +
+ +

Game Status

+

The Carbon Copy text adventure is running and ready for players!

+ +

Quick Commands

+
    +
  • Movement: go north, go south, go east, go west
  • +
  • Interaction: look, examine [item], take [item], drop [item]
  • +
  • Communication: say [message], tell [player] [message]
  • +
  • Character: inventory, stats, help
  • +