Compare commits
2 commits
9b92485232
...
3f1b735506
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f1b735506 | |||
| c483e5f555 |
15 changed files with 2157 additions and 1 deletions
27
.env.example
Normal file
27
.env.example
Normal file
|
|
@ -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
|
||||||
203
.gitignore
vendored
Normal file
203
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
93
alembic.ini
Normal file
93
alembic.ini
Normal file
|
|
@ -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
|
||||||
107
alembic/env.py
Normal file
107
alembic/env.py
Normal file
|
|
@ -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()
|
||||||
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
|
|
@ -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"}
|
||||||
BIN
carbon_copy.db
Normal file
BIN
carbon_copy.db
Normal file
Binary file not shown.
43
init_db.py
Normal file
43
init_db.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# 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()
|
||||||
|
|
@ -20,7 +20,22 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
]
|
]
|
||||||
dependencies = []
|
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]
|
[project.urls]
|
||||||
Documentation = "https://github.com/Micha Albert/carbon-copy#readme"
|
Documentation = "https://github.com/Micha Albert/carbon-copy#readme"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
|
|
|
||||||
46
src/carbon_copy/database.py
Normal file
46
src/carbon_copy/database.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# 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
|
||||||
483
src/carbon_copy/email_client.py
Normal file
483
src/carbon_copy/email_client.py
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# 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"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; }}
|
||||||
|
.header {{ background-color: #2c3e50; color: white; padding: 20px; }}
|
||||||
|
.content {{ padding: 20px; }}
|
||||||
|
.footer {{ background-color: #ecf0f1; padding: 10px; font-size: 12px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Carbon Copy</h1>
|
||||||
|
<p>Multiplayer Email Text Adventure</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hello, {player_name}!</h2>
|
||||||
|
{content}
|
||||||
|
<p><em>Reply to this email to send a message in the game.</em></p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message from Carbon Copy.
|
||||||
|
To unsubscribe, reply with "UNSUBSCRIBE".</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
249
src/carbon_copy/main.py
Normal file
249
src/carbon_copy/main.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
@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 <email@domain.com>" 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)
|
||||||
|
}
|
||||||
503
src/carbon_copy/models.py
Normal file
503
src/carbon_copy/models.py
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# 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]"}
|
||||||
|
)
|
||||||
327
src/carbon_copy/seed_data.py
Normal file
327
src/carbon_copy/seed_data.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# SPDX-FileCopyrightText: 2025-present Micha Albert <info@micha.zone>
|
||||||
|
#
|
||||||
|
# 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()
|
||||||
32
src/carbon_copy/templates/index.html
Normal file
32
src/carbon_copy/templates/index.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="An email-based multiplayer text adventure for the masses">
|
||||||
|
<title>Carbon Copy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome to Carbon Copy</h1>
|
||||||
|
<p>An email-based multiplayer text adventure for the masses</p>
|
||||||
|
|
||||||
|
<h2>How to Play</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Send an email to the game master to create your character</li>
|
||||||
|
<li>Use simple commands like "look", "go north", "take sword"</li>
|
||||||
|
<li>Receive game updates via email</li>
|
||||||
|
<li>Adventure with other players in a persistent world</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Game Status</h2>
|
||||||
|
<p>The Carbon Copy text adventure is running and ready for players!</p>
|
||||||
|
|
||||||
|
<h3>Quick Commands</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Movement:</strong> go north, go south, go east, go west</li>
|
||||||
|
<li><strong>Interaction:</strong> look, examine [item], take [item], drop [item]</li>
|
||||||
|
<li><strong>Communication:</strong> say [message], tell [player] [message]</li>
|
||||||
|
<li><strong>Character:</strong> inventory, stats, help</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue