Compare commits
No commits in common. "3f1b73550680c3a5cee9248fdb15d53341de6aef" and "9b92485232836e9f421d4fd44b4f976014d34034" have entirely different histories.
3f1b735506
...
9b92485232
15 changed files with 1 additions and 2157 deletions
27
.env.example
27
.env.example
|
|
@ -1,27 +0,0 @@
|
||||||
# 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
203
.gitignore
vendored
|
|
@ -1,203 +0,0 @@
|
||||||
# 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
93
alembic.ini
|
|
@ -1,93 +0,0 @@
|
||||||
[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
107
alembic/env.py
|
|
@ -1,107 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"""${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
BIN
carbon_copy.db
Binary file not shown.
43
init_db.py
43
init_db.py
|
|
@ -1,43 +0,0 @@
|
||||||
#!/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,22 +20,7 @@ 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,7 +1,3 @@
|
||||||
# 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"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,483 +0,0 @@
|
||||||
# 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()
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
# 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,503 +0,0 @@
|
||||||
# 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]"}
|
|
||||||
)
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
# 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()
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<!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