commit 25ba7968d53da8d1559d26fe4e566bc7649d6df3 Author: Micha R. Albert Date: Wed May 28 10:26:04 2025 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b23420e --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$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 + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# 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 +.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/ + +# 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 enitre vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +*.db \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cda48e5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "random-access" +version = "0.0.1" +description = "A minimal FastAPI app with Click and Hatch" +authors = [{ name = "Micha R. Albert", email = "info@micha.zone" }] +dependencies = [ + "fastapi~=0.115.12", + "uvicorn[standard]~=0.34.2", + "click~=8.2.1", + "sqlmodel~=0.0.24", + "argon2-cffi~=23.1.0" +] +requires-python = ">=3.12" + +[project.scripts] +random-access-server = "random_access.cli:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.sdist] +include = [ + "src/random_access" +] diff --git a/src/random_access/__init__.py b/src/random_access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/random_access/cli.py b/src/random_access/cli.py new file mode 100644 index 0000000..422582c --- /dev/null +++ b/src/random_access/cli.py @@ -0,0 +1,15 @@ +import click +import uvicorn +from random_access.main import app + +@click.group() +def cli(): + """Random Access Server CLI.""" + pass + +@cli.command() +@click.option("--host", default="127.0.0.1", help="Host to bind to.") +@click.option("--port", default=8000, help="Port to bind to.") +def run(host, port): + """Run the FastAPI app.""" + uvicorn.run(app, host=host, port=port) diff --git a/src/random_access/db.py b/src/random_access/db.py new file mode 100644 index 0000000..51a91ea --- /dev/null +++ b/src/random_access/db.py @@ -0,0 +1,15 @@ +from sqlmodel import SQLModel, create_engine, Session + +DATABASE_URL = "sqlite:////home/micha/Documents/random-access/database.db" # sync SQLite URL + +# connect_args needed for SQLite to allow multithreaded access +connect_args = {"check_same_thread": False} + +engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args) + +def init_db(): + SQLModel.metadata.create_all(engine) + +def get_session(): + with Session(engine) as session: + yield session diff --git a/src/random_access/main.py b/src/random_access/main.py new file mode 100644 index 0000000..0596689 --- /dev/null +++ b/src/random_access/main.py @@ -0,0 +1,60 @@ +from contextlib import asynccontextmanager +from typing import Annotated +from fastapi import FastAPI, Depends, HTTPException, Header +from sqlmodel import select, Session +from .db import get_session, init_db +from .models import Player, World +from secrets import token_urlsafe +from argon2 import PasswordHasher + +hasher = PasswordHasher() + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.post("/players/") +def create_player(player: Player, session: Session = Depends(get_session)) -> Player: + session.add(player) + session.commit() + session.refresh(player) + return player + + +@app.get("/players/location") +def read_player_location( + api_token: Annotated[str | None, Header()], session: Session = Depends(get_session) +): + if not api_token: + raise HTTPException(status_code=401, detail="No API token provided") + hashed_token = hasher.hash(api_token) + results = session.exec(select(Player).where(Player.api_token == hashed_token)) + if len(results.all()) != 1: + raise HTTPException(status_code=500, detail="Critical error, please report this!") + + + +@app.get("/players/") +def read_players(session: Session = Depends(get_session)) -> list[dict[str, int | str]]: + players = [ + { + "id": player.id, + "username": player.in_game_name, + "current_world_id": player.current_world_id, + "level": player.level, + } + for player in list(session.exec(select(Player)).all()) + ] + return players + + +@app.get("/worlds") +def read_worlds(session: Session = Depends(get_session)): + worlds = list(session.exec(select(World)).all()) + return worlds diff --git a/src/random_access/models.py b/src/random_access/models.py new file mode 100644 index 0000000..5ccc574 --- /dev/null +++ b/src/random_access/models.py @@ -0,0 +1,73 @@ +import datetime +from decimal import Decimal +from enum import Enum +from typing import Optional, List +from sqlmodel import Field, Relationship, SQLModel +from sqlalchemy import Column, Integer + + +class ItemCategory(str, Enum): + ranged_weapon = "ranged_weapon" + melee_weapon = "melee_weapon" + armor_head = "armor_head" + armor_torso = "armor_torso" + armor_legs = "armor_legs" + armor_shoes = "armor_shoes" + potion = "potion" + throwable_potion = "throwable_potion" + special = "special" + shield = "shield" + resource = "resource" + + +class World(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + + creator_id: Optional[int] = Field(default=None, foreign_key="player.id") + creator: Optional["Player"] = Relationship( + back_populates="personal_world", sa_relationship_kwargs={"foreign_keys": "[World.creator_id]"} + ) + + inhabitants: List["Player"] = Relationship( + back_populates="current_world", sa_relationship_kwargs={"foreign_keys": "[Player.current_world_id]"} + ) + + +class Player(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + in_game_name: Optional[str] + slack_id: str = Field(index=True) + api_token: str = Field(index=True) + token_last_updated: datetime.datetime + + pos_x: int = Field(default=0) + pos_y: int = Field(default=0) + + inventory: List["Item"] = Relationship(back_populates="player") + + level: int = Field(default=1, le=100) + xp: int = Field(default=0, lt=1000) + + personal_world: Optional["World"] = Relationship( + back_populates="creator", sa_relationship_kwargs={"foreign_keys": "[World.creator_id]"} + ) + + current_world_id: Optional[int] = Field(default=None, foreign_key="world.id") + current_world: Optional["World"] = Relationship( + back_populates="inhabitants", sa_relationship_kwargs={"foreign_keys": "[Player.current_world_id]"} + ) + + +class Item(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + category: ItemCategory + cost: Optional[Decimal] = None + can_be_sold: bool = Field(default=False) + is_breakable: bool = Field(default=False) + hitpoints_remaining: Optional[Decimal] = None + + player_id: Optional[int] = Field(default=None, foreign_key="player.id") + player: Optional[Player] = Relationship(back_populates="inventory")