feat: Initialize frontend and backend structure with essential configurations
Some checks failed
Backend CI / lint-and-test (push) Failing after 2m15s
Frontend CI / lint-and-build (push) Successful in 1m1s

- Added TypeScript build info for frontend.
- Created Vite configuration for React application.
- Implemented pre-commit hook to run checks before commits.
- Set up PostgreSQL Dockerfile with PostGIS support and initialization scripts.
- Added database creation script for PostgreSQL with necessary extensions.
- Established Python project configuration with dependencies and development tools.
- Developed pre-commit script to enforce code quality checks for backend and frontend.
- Created PowerShell script to set up Git hooks path.
This commit is contained in:
2025-10-11 15:25:32 +02:00
commit fc1e874309
74 changed files with 9477 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Copy this file to .env and adjust values per environment.
# Application settings
PROJECT_NAME=Rail Game API
API_PREFIX=/api
VERSION=0.1.0
# Security
JWT_SECRET_KEY=change-me
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
# Database configuration
DATABASE_URL=postgresql+psycopg://railgame:railgame@localhost:5432/railgame_dev
TEST_DATABASE_URL=postgresql+psycopg://railgame:railgame@localhost:5432/railgame_test
ALEMBIC_DATABASE_URL=${DATABASE_URL}
DATABASE_ECHO=false

47
.github/workflows/backend-ci.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Backend CI
on:
push:
paths:
- "backend/**"
- "scripts/**"
- "pyproject.toml"
- "TODO.md"
- ".github/workflows/backend-ci.yml"
pull_request:
paths:
- "backend/**"
- "scripts/**"
- "pyproject.toml"
- "TODO.md"
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
- name: Run formatters (check mode)
run: |
source .venv/bin/activate
python -m black --check backend
python -m isort --check-only backend
- name: Run backend tests
run: |
source .venv/bin/activate
pytest

37
.github/workflows/frontend-ci.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Frontend CI
on:
push:
paths:
- "frontend/**"
- "scripts/**"
- ".github/workflows/frontend-ci.yml"
pull_request:
paths:
- "frontend/**"
- "scripts/**"
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
working-directory: frontend
run: npm install
- name: Run linting
working-directory: frontend
run: npm run lint
- name: Run build
working-directory: frontend
run: npm run build

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# General
.DS_Store
Thumbs.db
*.log
*.tmp
# Python
.venv/
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
*.egg-info/
*.egg
.build/
.mypy_cache/
.pytest_cache/
.coverage
htmlcov/
# Node / Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Environment & local config
.env
.env.*
!.env.example
# Database & tooling artifacts
alembic.ini.timestamp
*.sqlite
# VS Code / IDEs
.vscode/
.idea/
*.iml
# Project specific
rail_game_backend.egg-info/
.github/instructions/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Georg Sinn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

146
README.md Normal file
View File

@@ -0,0 +1,146 @@
# Rail Game
A browser-based railway simulation game using real world railway maps from OpenStreetMap.
## Features
- Real world railway maps
- Interactive Leaflet map preview of the demo network snapshot
- Build and manage your own railway network
- Dynamic train schedules
## Architecture
The project is built using the following technologies:
- Frontend: HTML5, CSS3, JavaScript, React
- Backend: Python, FastAPI, Flask, SQLAlchemy
- Database: PostgreSQL with PostGIS extension
- Mapping: Leaflet, OpenStreetMap
## Project Structure
Planned structure for code and assets (folders created as needed):
```text
rail-game/
|-- backend/
| |-- app/
| | |-- api/ # FastAPI/Flask route handlers
| | |-- core/ # Config, startup, shared utilities
| | |-- models/ # SQLAlchemy models and schemas
| | |-- services/ # Domain logic and service layer
| | `-- websocket/ # Real-time communication handlers
| |-- tests/ # Backend unit and integration tests
| `-- requirements/ # Backend dependency lockfiles
|-- frontend/
| |-- public/ # Static assets served as-is
| |-- src/
| | |-- components/ # Reusable React components
| | |-- hooks/ # Custom React hooks
| | |-- pages/ # Top-level routed views
| | |-- state/ # Redux/Context stores and slices
| | |-- styles/ # Global and modular stylesheets
| | `-- utils/ # Frontend helpers and formatters
| `-- tests/ # Frontend unit and integration tests
|-- docs/ # Architecture docs, ADRs, guides
|-- infra/ # Deployment, IaC, Docker, CI workflows
|-- scripts/ # Tooling for setup, linting, migrations
|-- data/ # Seed data, fixtures, import/export tooling
`-- tests/ # End-to-end and cross-cutting tests
```
Use `infra/` to capture deployment assets (Dockerfiles, compose files, Terraform) and `.github/` for automation. Shared code that crosses layers should live in the respective service directories or dedicated packages under `backend/`.
## Installation
1. Clone the repository:
```bash
git clone https://github.com/zwitschi/rail-game.git
cd rail-game
```
2. Set up the backend (from the project root):
```bash
python -m venv .venv
.\.venv\Scripts\activate
python -m pip install -e .[dev]
```
3. Set up the frontend:
```bash
cd frontend
npm install
cd ..
```
4. Copy the sample environment file and adjust the database URLs per environment:
```bash
copy .env.example .env # PowerShell: Copy-Item .env.example .env
```
`DATABASE_URL`, `TEST_DATABASE_URL`, and `ALEMBIC_DATABASE_URL` control the runtime, test, and migration connections respectively.
5. (Optional) Point Git to the bundled hooks: `pwsh scripts/setup_hooks.ps1`.
6. Run database migrations to set up the schema:
```bash
cd backend
alembic upgrade head
cd ..
```
7. Start the development servers from separate terminals:
- Backend: `uvicorn backend.app.main:app --reload --port 8000`
- Frontend: `cd frontend && npm run dev`
8. Open your browser: frontend runs at `http://localhost:5173`, backend API at `http://localhost:8000`.
9. Run quality checks:
- Backend unit tests: `pytest`
- Backend formatters: `black backend/` and `isort backend/`
- Frontend lint: `cd frontend && npm run lint`
- Frontend type/build check: `cd frontend && npm run build`
10. Build for production:
- Frontend bundle: `cd frontend && npm run build`
- Backend container: `docker build -t rail-game-backend backend/`
11. Run containers:
- Backend: `docker run -p 8000:8000 rail-game-backend`
- Frontend: Serve `frontend/dist` with any static file host.
## Database Migrations
- Alembic configuration lives in `backend/alembic.ini` with scripts under `backend/migrations/`.
- Generate new revisions with `alembic revision --autogenerate -m "short description"` (ensure models are imported before running autogenerate).
- Apply migrations via `alembic upgrade head`; rollback with `alembic downgrade -1` during development.
## PostgreSQL Configuration
- **Database URLs**: The backend reads connection strings from the `.env` file. Set `DATABASE_URL` (development), `TEST_DATABASE_URL` (pytest/CI), and `ALEMBIC_DATABASE_URL` (migration runner). URLs use the SQLAlchemy format, e.g. `postgresql+psycopg://user:password@host:port/database`.
- **Required Extensions**: Migrations enable `postgis` for spatial types and `pgcrypto` for UUID generation. Ensure your Postgres instance has these extensions available.
- **Recommended Databases**: create `railgame_dev` and `railgame_test` (or variants) owned by a dedicated `railgame` role with privileges to create extensions.
- **Connection Debugging**: Toggle `DATABASE_ECHO=true` in `.env` to log SQL statements during development.
## API Preview
- `GET /api/health` Lightweight readiness probe.
- `POST /api/auth/register` Creates an in-memory demo account and returns a JWT access token.
- `POST /api/auth/login` Exchanges credentials for a JWT access token (demo user: `demo` / `railgame123`).
- `GET /api/auth/me` Returns the current authenticated user profile.
- `GET /api/network` Returns a sample snapshot of stations, tracks, and trains (camelCase fields) generated from shared domain models; requires a valid bearer token.
## Developer Tooling
- Install backend tooling in editable mode: `python -m pip install -e .[dev]`.
- Configure git hooks (Git for Windows works with these scripts): `pwsh scripts/setup_hooks.ps1`.
- Pre-commit hooks run `black`, `isort`, `pytest backend/tests`, and `npm run lint` if frontend dependencies are installed.
- Run the checks manually any time with `python scripts/precommit.py`.
- Frontend lint/format commands live in `frontend/package.json` (`npm run lint`, `npm run format`).
- Continuous integration runs via workflows in `.github/workflows/` covering backend lint/tests and frontend lint/build.

47
TODO.md Normal file
View File

@@ -0,0 +1,47 @@
# Development TODO Plan
## Phase 1 Project Foundations
- [x] Initialize Git hooks, linting, and formatting tooling (ESLint, Prettier, isort, black).
- [x] Configure `pyproject.toml` or equivalent for backend dependency management.
- [x] Scaffold FastAPI application entrypoint with health-check endpoint.
- [x] Bootstrap React app with Vite/CRA, including routing skeleton and global state provider.
- [x] Define shared TypeScript/Python models for core domain entities (tracks, stations, trains).
- [x] Set up CI workflow for linting and test automation (GitHub Actions).
## Phase 2 Core Features
- [x] Implement authentication flow (backend JWT, frontend login/register forms).
- [x] Build map visualization integrating Leaflet with OSM tiles.
- [ ] Create railway builder tools (track placement, station creation) with backend persistence APIs.
- [ ] Establish train scheduling service with validation rules and API endpoints.
- [ ] Develop frontend dashboards for resources, schedules, and achievements.
- [ ] Add real-time simulation updates (WebSocket layer, frontend subscription hooks).
## Phase 3 Data & Persistence
- [x] Design PostgreSQL/PostGIS schema and migrations (Alembic or similar).
- [ ] Implement data access layer with SQLAlchemy and repository abstractions.
- [ ] Seed initial data fixtures for development/demo purposes.
- [ ] Integrate caching strategy (Redis) for map tiles and frequent queries.
## Phase 4 Testing & Quality
- [ ] Write unit tests for backend services and models.
- [ ] Add frontend component and hook tests (Jest + React Testing Library).
- [ ] Implement end-to-end test suite (Playwright/Cypress) covering critical flows.
- [ ] Set up load/performance testing harness for scheduling and simulation.
## Phase 5 Deployment & Ops
- [ ] Create Dockerfiles for frontend and backend, plus docker-compose for local dev.
- [ ] Provision infrastructure scripts (Terraform/Ansible) targeting initial cloud environment.
- [ ] Configure observability stack (logging, metrics, tracing).
- [ ] Document deployment pipeline and release process.
## Phase 6 Polish & Expansion
- [ ] Add leaderboards and achievements logic with UI integration.
- [ ] Implement accessibility audit fixes (WCAG compliance).
- [ ] Optimize asset loading and introduce lazy loading strategies.
- [ ] Evaluate multiplayer/coop roadmap and spike POCs where feasible.

0
backend/__init__.py Normal file
View File

35
backend/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = migrations
sqlalchemy.url = postgresql+psycopg://railgame:railgame@localhost:5432/railgame
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from backend.app.api.auth import router as auth_router
from backend.app.api.health import router as health_router
from backend.app.api.network import router as network_router
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(auth_router)
router.include_router(network_router)

41
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from backend.app.api.deps import get_current_user
from backend.app.models import AuthResponse, LoginRequest, RegisterRequest, UserPublic
from backend.app.services.auth import (
authenticate_user,
issue_token_for_user,
register_user,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=AuthResponse)
async def login(credentials: LoginRequest) -> AuthResponse:
user = authenticate_user(credentials.username, credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return issue_token_for_user(user)
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest) -> AuthResponse:
try:
user = register_user(payload.username, payload.password, payload.full_name)
except ValueError as exc:
message = str(exc)
status_code = status.HTTP_409_CONFLICT if "exists" in message else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=status_code, detail=message) from exc
return issue_token_for_user(user)
@router.get("/me", response_model=UserPublic)
async def read_current_user(current_user: UserPublic = Depends(get_current_user)) -> UserPublic:
return current_user

40
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import ValidationError
from sqlalchemy.orm import Session
from backend.app.core.security import decode_access_token
from backend.app.db.session import get_db_session
from backend.app.models import TokenPayload, UserPublic
from backend.app.services.auth import get_user, to_public_user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserPublic:
try:
payload = TokenPayload(**decode_access_token(token))
except (ValueError, ValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from exc
user = get_user(payload.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return to_public_user(user)
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health", summary="Service health status")
async def health_check() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.app.api.deps import get_current_user, get_db
from backend.app.models import UserPublic
from backend.app.services.network import get_network_snapshot
router = APIRouter(prefix="/network", tags=["network"])
@router.get("", summary="Fetch a snapshot of the railway network")
def read_network_snapshot(
_: UserPublic = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, list[dict[str, object]]]:
return get_network_snapshot(db)

View File

View File

@@ -0,0 +1,38 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
project_name: str = "Rail Game API"
version: str = "0.1.0"
api_prefix: str = "/api"
jwt_secret_key: str = "insecure-change-me"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60
database_url: str = "postgresql+psycopg://railgame:railgame@localhost:5432/railgame"
database_echo: bool = False
test_database_url: Optional[str] = None
alembic_database_url: Optional[str] = None
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def sqlalchemy_database_url(self) -> str:
return self.database_url
@property
def sqlalchemy_test_url(self) -> Optional[str]:
return self.test_database_url
@property
def sqlalchemy_alembic_url(self) -> str:
return self.alembic_database_url or self.database_url
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from jose import JWTError, jwt
from passlib.context import CryptContext
from backend.app.core.config import get_settings
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
to_encode: Dict[str, Any] = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> Dict[str, Any]:
settings = get_settings()
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc: # pragma: no cover - specific error mapping handled by caller
raise ValueError("Invalid token") from exc

View File

@@ -0,0 +1,6 @@
"""Database package exposing SQLAlchemy base metadata and session utilities."""
from backend.app.db.models import Base
from backend.app.db.session import SessionLocal, engine, get_db_session
__all__ = ["Base", "SessionLocal", "engine", "get_db_session"]

90
backend/app/db/models.py Normal file
View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import uuid
from geoalchemy2 import Geometry
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func
class Base(DeclarativeBase):
"""Base class for all SQLAlchemy models."""
class TimestampMixin:
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
full_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
role: Mapped[str] = mapped_column(String(32), nullable=False, default="player")
preferences: Mapped[str | None] = mapped_column(Text, nullable=True)
class Station(Base, TimestampMixin):
__tablename__ = "stations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
osm_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
code: Mapped[str | None] = mapped_column(String(16), nullable=True)
location: Mapped[str] = mapped_column(Geometry(geometry_type="POINT", srid=4326), nullable=False)
elevation_m: Mapped[float | None] = mapped_column(Float, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
class Track(Base, TimestampMixin):
__tablename__ = "tracks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
start_station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="RESTRICT"), nullable=False)
end_station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="RESTRICT"), nullable=False)
length_meters: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
max_speed_kph: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_bidirectional: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="planned")
track_geometry: Mapped[str] = mapped_column(Geometry(geometry_type="LINESTRING", srid=4326), nullable=False)
__table_args__ = (
UniqueConstraint("start_station_id", "end_station_id", name="uq_tracks_station_pair"),
)
class Train(Base, TimestampMixin):
__tablename__ = "trains"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
designation: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
operator_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
home_station_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="SET NULL"))
capacity: Mapped[int] = mapped_column(Integer, nullable=False)
max_speed_kph: Mapped[int] = mapped_column(Integer, nullable=False)
consist: Mapped[str | None] = mapped_column(Text, nullable=True)
class TrainSchedule(Base, TimestampMixin):
__tablename__ = "train_schedules"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
train_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("trains.id", ondelete="CASCADE"), nullable=False)
sequence_index: Mapped[int] = mapped_column(Integer, nullable=False)
station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="CASCADE"), nullable=False)
scheduled_arrival: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
scheduled_departure: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dwell_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
UniqueConstraint("train_id", "sequence_index", name="uq_train_schedule_sequence"),
)

24
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from backend.app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.sqlalchemy_database_url, echo=settings.database_echo, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
__all__ = ["engine", "SessionLocal", "get_db_session"]

14
backend/app/main.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import FastAPI
from backend.app.api import router as api_router
from backend.app.core.config import get_settings
def create_app() -> FastAPI:
settings = get_settings()
application = FastAPI(title=settings.project_name, version=settings.version)
application.include_router(api_router, prefix=settings.api_prefix)
return application
app = create_app()

View File

@@ -0,0 +1,35 @@
from .auth import (
AuthResponse,
LoginRequest,
RegisterRequest,
TokenPayload,
TokenResponse,
UserInDB,
UserPublic,
)
from .base import (
StationCreate,
StationModel,
TrackCreate,
TrackModel,
TrainCreate,
TrainModel,
to_camel,
)
__all__ = [
"LoginRequest",
"RegisterRequest",
"AuthResponse",
"TokenPayload",
"TokenResponse",
"UserInDB",
"UserPublic",
"StationCreate",
"StationModel",
"TrackCreate",
"TrackModel",
"TrainCreate",
"TrainModel",
"to_camel",
]

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from backend.app.models.base import to_camel
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
full_name: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class TokenPayload(BaseModel):
sub: str
exp: int
class UserPublic(BaseModel):
username: str
full_name: str | None = None
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class UserInDB(UserPublic):
hashed_password: str
class AuthResponse(TokenResponse):
user: UserPublic
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime
from typing import Generic, Sequence, TypeVar
from pydantic import BaseModel, ConfigDict
def to_camel(string: str) -> str:
head, *tail = string.split("_")
return head + "".join(part.capitalize() for part in tail)
IdT = TypeVar("IdT", bound=str)
class CamelModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
alias_generator=to_camel,
)
class TimestampedModel(CamelModel):
created_at: datetime
updated_at: datetime
model_config = ConfigDict(
frozen=True,
from_attributes=True,
populate_by_name=True,
alias_generator=to_camel,
)
class IdentifiedModel(TimestampedModel, Generic[IdT]):
id: IdT
class StationModel(IdentifiedModel[str]):
name: str
latitude: float
longitude: float
class TrackModel(IdentifiedModel[str]):
start_station_id: str
end_station_id: str
length_meters: float
max_speed_kph: float
class TrainModel(IdentifiedModel[str]):
designation: str
capacity: int
max_speed_kph: float
operating_track_ids: list[str]
class StationCreate(CamelModel):
name: str
latitude: float
longitude: float
osm_id: str | None = None
code: str | None = None
elevation_m: float | None = None
is_active: bool = True
class TrackCreate(CamelModel):
start_station_id: str
end_station_id: str
coordinates: Sequence[tuple[float, float]]
name: str | None = None
length_meters: float | None = None
max_speed_kph: int | None = None
is_bidirectional: bool = True
status: str = "planned"
class TrainCreate(CamelModel):
designation: str
capacity: int
max_speed_kph: int
operator_id: str | None = None
home_station_id: str | None = None
consist: str | None = None

View File

@@ -0,0 +1,11 @@
"""Repository abstractions for database access."""
from backend.app.repositories.stations import StationRepository
from backend.app.repositories.tracks import TrackRepository
from backend.app.repositories.trains import TrainRepository
__all__ = [
"StationRepository",
"TrackRepository",
"TrainRepository",
]

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import Generic, Iterable, Optional, Sequence, Type, TypeVar
from sqlalchemy.orm import Session
from backend.app.db.models import Base
ModelT = TypeVar("ModelT", bound=Base)
class BaseRepository(Generic[ModelT]):
"""Provide common CRUD helpers for SQLAlchemy models."""
model: Type[ModelT]
def __init__(self, session: Session) -> None:
self.session = session
def get(self, identifier: object) -> Optional[ModelT]:
return self.session.get(self.model, identifier)
def list(self) -> Sequence[ModelT]:
statement = sa.select(self.model)
return list(self.session.scalars(statement))
def add(self, instance: ModelT) -> ModelT:
self.session.add(instance)
return instance
def add_all(self, instances: Iterable[ModelT]) -> None:
self.session.add_all(instances)
def delete(self, instance: ModelT) -> None:
self.session.delete(instance)
def flush(self) -> None:
self.session.flush()

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.orm import Session
from geoalchemy2.elements import WKTElement
from backend.app.db.models import Station
from backend.app.repositories.base import BaseRepository
from backend.app.models import StationCreate
class StationRepository(BaseRepository[Station]):
model = Station
def __init__(self, session: Session) -> None:
super().__init__(session)
def list_active(self) -> list[Station]:
statement = sa.select(self.model).where(self.model.is_active.is_(True))
return list(self.session.scalars(statement))
@staticmethod
def _point(latitude: float, longitude: float) -> WKTElement:
return WKTElement(f"POINT({longitude} {latitude})", srid=4326)
def create(self, data: StationCreate) -> Station:
station = Station(
name=data.name,
osm_id=data.osm_id,
code=data.code,
location=self._point(data.latitude, data.longitude),
elevation_m=data.elevation_m,
is_active=data.is_active,
)
self.session.add(station)
return station

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import sqlalchemy as sa
from geoalchemy2.elements import WKTElement
from uuid import UUID
from sqlalchemy.orm import Session
from backend.app.db.models import Track
from backend.app.repositories.base import BaseRepository
from backend.app.models import TrackCreate
class TrackRepository(BaseRepository[Track]):
model = Track
def __init__(self, session: Session) -> None:
super().__init__(session)
def list_all(self) -> list[Track]:
statement = sa.select(self.model)
return list(self.session.scalars(statement))
@staticmethod
def _ensure_uuid(value: UUID | str) -> UUID:
if isinstance(value, UUID):
return value
return UUID(str(value))
@staticmethod
def _line_string(coordinates: list[tuple[float, float]]) -> WKTElement:
if len(coordinates) < 2:
raise ValueError("Track geometry requires at least two coordinate pairs")
parts = [f"{lon} {lat}" for lat, lon in coordinates]
return WKTElement(f"LINESTRING({', '.join(parts)})", srid=4326)
def create(self, data: TrackCreate) -> Track:
coordinates = list(data.coordinates)
geometry = self._line_string(coordinates)
track = Track(
name=data.name,
start_station_id=self._ensure_uuid(data.start_station_id),
end_station_id=self._ensure_uuid(data.end_station_id),
length_meters=data.length_meters,
max_speed_kph=data.max_speed_kph,
is_bidirectional=data.is_bidirectional,
status=data.status,
track_geometry=geometry,
)
self.session.add(track)
return track

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import sqlalchemy as sa
from uuid import UUID
from sqlalchemy.orm import Session
from backend.app.db.models import Train
from backend.app.repositories.base import BaseRepository
from backend.app.models import TrainCreate
class TrainRepository(BaseRepository[Train]):
model = Train
def __init__(self, session: Session) -> None:
super().__init__(session)
def list_all(self) -> list[Train]:
statement = sa.select(self.model)
return list(self.session.scalars(statement))
@staticmethod
def _optional_uuid(value: UUID | str | None) -> UUID | None:
if value is None:
return None
if isinstance(value, UUID):
return value
return UUID(str(value))
def create(self, data: TrainCreate) -> Train:
train = Train(
designation=data.designation,
operator_id=self._optional_uuid(data.operator_id),
home_station_id=self._optional_uuid(data.home_station_id),
capacity=data.capacity,
max_speed_kph=data.max_speed_kph,
consist=data.consist,
)
self.session.add(train)
return train

View File

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Dict, Optional
from backend.app.core.security import (
create_access_token,
get_password_hash,
verify_password,
)
from backend.app.models import AuthResponse, UserInDB, UserPublic
_DEMO_USER = UserInDB(
username="demo",
full_name="Demo Engineer",
hashed_password=get_password_hash("railgame123"),
)
_FAKE_USERS: Dict[str, UserInDB] = {_DEMO_USER.username: _DEMO_USER}
def get_user(username: str) -> Optional[UserInDB]:
return _FAKE_USERS.get(username)
def authenticate_user(username: str, password: str) -> Optional[UserInDB]:
user = get_user(username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def issue_token_for_user(user: UserInDB) -> AuthResponse:
return AuthResponse(
access_token=create_access_token(subject=user.username),
token_type="bearer",
user=to_public_user(user),
)
def to_public_user(user: UserInDB) -> UserPublic:
return UserPublic(username=user.username, full_name=user.full_name)
def register_user(username: str, password: str, full_name: Optional[str] = None) -> UserInDB:
normalized_username = username.strip()
if not normalized_username:
raise ValueError("Username must not be empty")
if normalized_username in _FAKE_USERS:
raise ValueError("Username already exists")
user = UserInDB(
username=normalized_username,
full_name=full_name.strip() if full_name else None,
hashed_password=get_password_hash(password),
)
_FAKE_USERS[normalized_username] = user
return user

View File

@@ -0,0 +1,157 @@
"""Domain services for railway network aggregation."""
from datetime import datetime, timezone
from decimal import Decimal
from typing import Iterable, cast
from geoalchemy2.elements import WKBElement, WKTElement
from geoalchemy2.shape import to_shape
try: # pragma: no cover - optional dependency guard
from shapely.geometry import Point # type: ignore
except ImportError: # pragma: no cover - allow running without shapely at import time
Point = None # type: ignore[assignment]
from sqlalchemy.orm import Session
from backend.app.models import StationModel, TrackModel, TrainModel
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
def _timestamp() -> datetime:
return datetime.now(timezone.utc)
def _fallback_snapshot() -> dict[str, list[dict[str, object]]]:
now = _timestamp()
stations = [
StationModel(
id="station-1",
name="Central",
latitude=52.520008,
longitude=13.404954,
created_at=now,
updated_at=now,
),
StationModel(
id="station-2",
name="Harbor",
latitude=53.551086,
longitude=9.993682,
created_at=now,
updated_at=now,
),
]
tracks = [
TrackModel(
id="track-1",
start_station_id="station-1",
end_station_id="station-2",
length_meters=289000.0,
max_speed_kph=230.0,
created_at=now,
updated_at=now,
)
]
trains = [
TrainModel(
id="train-1",
designation="ICE 123",
capacity=400,
max_speed_kph=300.0,
operating_track_ids=[track.id for track in tracks],
created_at=now,
updated_at=now,
)
]
return _serialize_snapshot(stations, tracks, trains)
def _serialize_snapshot(
stations: Iterable[StationModel],
tracks: Iterable[TrackModel],
trains: Iterable[TrainModel],
) -> dict[str, list[dict[str, object]]]:
return {
"stations": [station.model_dump(by_alias=True) for station in stations],
"tracks": [track.model_dump(by_alias=True) for track in tracks],
"trains": [train.model_dump(by_alias=True) for train in trains],
}
def _to_float(value: Decimal | float | int | None, default: float = 0.0) -> float:
if value is None:
return default
if isinstance(value, Decimal):
return float(value)
return float(value)
def get_network_snapshot(session: Session) -> dict[str, list[dict[str, object]]]:
station_repo = StationRepository(session)
track_repo = TrackRepository(session)
train_repo = TrainRepository(session)
stations_entities = station_repo.list_active()
tracks_entities = track_repo.list_all()
trains_entities = train_repo.list_all()
if not stations_entities and not tracks_entities and not trains_entities:
return _fallback_snapshot()
station_models: list[StationModel] = []
for station in stations_entities:
location = station.location
geom = (
to_shape(cast(WKBElement | WKTElement, location))
if location is not None and Point is not None
else None
)
if Point is not None and geom is not None and isinstance(geom, Point):
latitude = float(geom.y)
longitude = float(geom.x)
else:
latitude = 0.0
longitude = 0.0
station_models.append(
StationModel(
id=str(station.id),
name=station.name,
latitude=latitude,
longitude=longitude,
created_at=cast(datetime, station.created_at),
updated_at=cast(datetime, station.updated_at),
)
)
track_models: list[TrackModel] = []
for track in tracks_entities:
track_models.append(
TrackModel(
id=str(track.id),
start_station_id=str(track.start_station_id),
end_station_id=str(track.end_station_id),
length_meters=_to_float(track.length_meters),
max_speed_kph=_to_float(track.max_speed_kph),
created_at=cast(datetime, track.created_at),
updated_at=cast(datetime, track.updated_at),
)
)
train_models: list[TrainModel] = []
for train in trains_entities:
train_models.append(
TrainModel(
id=str(train.id),
designation=train.designation,
capacity=train.capacity,
max_speed_kph=_to_float(train.max_speed_kph),
operating_track_ids=[],
created_at=cast(datetime, train.created_at),
updated_at=cast(datetime, train.updated_at),
)
)
return _serialize_snapshot(station_models, track_models, train_models)

View File

69
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from logging.config import fileConfig
from typing import Any, Dict
from alembic import context
from sqlalchemy import engine_from_config, pool
from backend.app.core.config import get_settings
from backend.app.db.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
# target_metadata = None
target_metadata = Base.metadata
def get_run_options() -> Dict[str, Any]:
settings = get_settings()
return {"url": settings.database_url}
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
kwargs = get_run_options()
context.configure(
url=kwargs["url"],
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
kwargs = get_run_options()
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
url=kwargs["url"],
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,19 @@
"""Template for new Alembic migration scripts."""
from __future__ import annotations
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,108 @@
"""Initial PostgreSQL/PostGIS schema"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from geoalchemy2.types import Geometry
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "20251011_01"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("username", sa.String(length=64), nullable=False, unique=True),
sa.Column("email", sa.String(length=255), nullable=True, unique=True),
sa.Column("full_name", sa.String(length=128), nullable=True),
sa.Column("password_hash", sa.String(length=256), nullable=False),
sa.Column("role", sa.String(length=32), nullable=False, server_default="player"),
sa.Column("preferences", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
)
op.create_table(
"stations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("osm_id", sa.String(length=32), nullable=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("code", sa.String(length=16), nullable=True),
sa.Column("location", Geometry(geometry_type="POINT", srid=4326), nullable=False),
sa.Column("elevation_m", sa.Float(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
)
op.create_index("ix_stations_location", "stations", ["location"], postgresql_using="gist")
op.create_table(
"tracks",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(length=128), nullable=True),
sa.Column("start_station_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("end_station_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("length_meters", sa.Numeric(10, 2), nullable=True),
sa.Column("max_speed_kph", sa.Integer(), nullable=True),
sa.Column("is_bidirectional", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("status", sa.String(length=32), nullable=False, server_default="planned"),
sa.Column("track_geometry", Geometry(geometry_type="LINESTRING", srid=4326), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.ForeignKeyConstraint(["start_station_id"], ["stations.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["end_station_id"], ["stations.id"], ondelete="RESTRICT"),
sa.UniqueConstraint("start_station_id", "end_station_id", name="uq_tracks_station_pair"),
)
op.create_index("ix_tracks_geometry", "tracks", ["track_geometry"], postgresql_using="gist")
op.create_table(
"trains",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("designation", sa.String(length=64), nullable=False, unique=True),
sa.Column("operator_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("home_station_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("capacity", sa.Integer(), nullable=False),
sa.Column("max_speed_kph", sa.Integer(), nullable=False),
sa.Column("consist", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.ForeignKeyConstraint(["operator_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["home_station_id"], ["stations.id"], ondelete="SET NULL"),
)
op.create_table(
"train_schedules",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("train_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("sequence_index", sa.Integer(), nullable=False),
sa.Column("station_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("scheduled_arrival", sa.DateTime(timezone=True), nullable=True),
sa.Column("scheduled_departure", sa.DateTime(timezone=True), nullable=True),
sa.Column("dwell_seconds", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
sa.ForeignKeyConstraint(["train_id"], ["trains.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["station_id"], ["stations.id"], ondelete="CASCADE"),
sa.UniqueConstraint("train_id", "sequence_index", name="uq_train_schedule_sequence"),
)
def downgrade() -> None:
op.drop_table("train_schedules")
op.drop_table("trains")
op.drop_index("ix_tracks_geometry", table_name="tracks")
op.drop_table("tracks")
op.drop_index("ix_stations_location", table_name="stations")
op.drop_table("stations")
op.drop_table("users")
op.execute("DROP EXTENSION IF EXISTS pgcrypto")
op.execute("DROP EXTENSION IF EXISTS postgis")

View File

@@ -0,0 +1,10 @@
fastapi>=0.111.0,<1.0.0
uvicorn[standard]>=0.24.0,<1.0.0
pydantic-settings>=2.0.0,<3.0.0
python-jose[cryptography]>=3.3.0,<4.0.0
passlib[bcrypt]>=1.7.4,<2.0.0
SQLAlchemy>=2.0.31,<3.0.0
alembic>=1.13.1,<2.0.0
geoalchemy2>=0.15.2,<0.16.0
psycopg[binary]>=3.1.19,<4.0.0
shapely>=2.0.4,<3.0.0

View File

@@ -0,0 +1,4 @@
-r base.txt
httpx>=0.27.0,<0.28.0
pytest>=8.0.0,<9.0.0

View File

View File

@@ -0,0 +1,81 @@
from uuid import uuid4
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def test_login_returns_token_and_user() -> None:
response = client.post(
"/api/auth/login",
json={"username": "demo", "password": "railgame123"},
)
assert response.status_code == 200
payload = response.json()
assert "accessToken" in payload
assert payload["tokenType"] == "bearer"
assert payload["user"]["username"] == "demo"
def test_login_with_invalid_credentials_fails() -> None:
response = client.post(
"/api/auth/login",
json={"username": "demo", "password": "wrong"},
)
assert response.status_code == 401
def test_me_endpoint_returns_current_user() -> None:
login = client.post(
"/api/auth/login",
json={"username": "demo", "password": "railgame123"},
)
token = login.json()["accessToken"]
response = client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json()["username"] == "demo"
def test_register_creates_user_and_returns_token() -> None:
username = f"player_{uuid4().hex[:8]}"
response = client.post(
"/api/auth/register",
json={
"username": username,
"password": "testpass123",
"fullName": "Test Player",
},
)
assert response.status_code == 201
payload = response.json()
assert payload["user"]["username"] == username
assert payload["tokenType"] == "bearer"
me = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {payload['accessToken']}"},
)
assert me.status_code == 200
assert me.json()["username"] == username
def test_register_duplicate_username_returns_conflict() -> None:
username = f"dupe_{uuid4().hex[:8]}"
first = client.post(
"/api/auth/register",
json={"username": username, "password": "firstpass"},
)
assert first.status_code == 201
duplicate = client.post(
"/api/auth/register",
json={"username": username, "password": "secondpass"},
)
assert duplicate.status_code == 409
assert duplicate.json()["detail"] == "Username already exists"

View File

@@ -0,0 +1,11 @@
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def test_health_check() -> None:
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@@ -0,0 +1,51 @@
from datetime import datetime, timezone
from backend.app.models import StationModel, TrackModel, TrainModel
def _now() -> datetime:
return datetime.now(timezone.utc)
def test_station_model_round_trip() -> None:
timestamp = _now()
station = StationModel(
id="station-1",
name="Central",
latitude=52.52,
longitude=13.405,
created_at=timestamp,
updated_at=timestamp,
)
assert station.name == "Central"
assert station.model_dump()["id"] == "station-1"
def test_track_model_properties() -> None:
timestamp = _now()
track = TrackModel(
id="track-1",
start_station_id="station-1",
end_station_id="station-2",
length_meters=1500.0,
max_speed_kph=120.0,
created_at=timestamp,
updated_at=timestamp,
)
assert track.length_meters > 0
assert track.start_station_id != track.end_station_id
def test_train_model_operating_tracks() -> None:
timestamp = _now()
train = TrainModel(
id="train-1",
designation="Express",
capacity=350,
max_speed_kph=200.0,
operating_track_ids=["track-1", "track-2"],
created_at=timestamp,
updated_at=timestamp,
)
assert train.capacity >= 0
assert len(train.operating_track_ids) == 2

View File

@@ -0,0 +1,35 @@
from fastapi.testclient import TestClient
from backend.app.main import app
AUTH_CREDENTIALS = {"username": "demo", "password": "railgame123"}
client = TestClient(app)
def _authenticate() -> str:
response = client.post("/api/auth/login", json=AUTH_CREDENTIALS)
assert response.status_code == 200
return response.json()["accessToken"]
def test_network_snapshot_requires_authentication() -> None:
response = client.get("/api/network")
assert response.status_code == 401
def test_network_snapshot_endpoint_returns_collections() -> None:
token = _authenticate()
response = client.get(
"/api/network", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
payload = response.json()
assert set(payload.keys()) == {"stations", "tracks", "trains"}
assert all(isinstance(payload[key], list) for key in payload)
assert payload["stations"], "Expected sample station data"
assert payload["trains"], "Expected sample train data"
station = payload["stations"][0]
assert "name" in station and "createdAt" in station

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
from backend.app.services.network import get_network_snapshot
@pytest.fixture
def sample_entities() -> dict[str, SimpleNamespace]:
timestamp = datetime.now(timezone.utc)
station = SimpleNamespace(
id=uuid4(),
name="Test Station",
location=None,
created_at=timestamp,
updated_at=timestamp,
)
track = SimpleNamespace(
id=uuid4(),
start_station_id=station.id,
end_station_id=station.id,
length_meters=1234.5,
max_speed_kph=160,
created_at=timestamp,
updated_at=timestamp,
)
train = SimpleNamespace(
id=uuid4(),
designation="Test Express",
capacity=200,
max_speed_kph=220,
created_at=timestamp,
updated_at=timestamp,
)
return {"station": station, "track": track, "train": train}
def test_network_snapshot_prefers_repository_data(monkeypatch: pytest.MonkeyPatch, sample_entities: dict[str, SimpleNamespace]) -> None:
station = sample_entities["station"]
track = sample_entities["track"]
train = sample_entities["train"]
monkeypatch.setattr(StationRepository, "list_active", lambda self: [station])
monkeypatch.setattr(TrackRepository, "list_all", lambda self: [track])
monkeypatch.setattr(TrainRepository, "list_all", lambda self: [train])
snapshot = get_network_snapshot(session=None) # type: ignore[arg-type]
assert snapshot["stations"]
assert snapshot["stations"][0]["name"] == station.name
assert snapshot["tracks"][0]["lengthMeters"] == pytest.approx(track.length_meters)
assert snapshot["trains"][0]["designation"] == train.designation
assert snapshot["trains"][0]["operatingTrackIds"] == []
def test_network_snapshot_falls_back_when_repositories_empty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(StationRepository, "list_active", lambda self: [])
monkeypatch.setattr(TrackRepository, "list_all", lambda self: [])
monkeypatch.setattr(TrainRepository, "list_all", lambda self: [])
snapshot = get_network_snapshot(session=None) # type: ignore[arg-type]
assert snapshot["stations"]
assert snapshot["trains"]
assert any(station["name"] == "Central" for station in snapshot["stations"])

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, List
import pytest
from backend.app.models import StationCreate, TrackCreate, TrainCreate
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
@dataclass
class DummySession:
added: List[Any] = field(default_factory=list)
def add(self, instance: Any) -> None:
self.added.append(instance)
def add_all(self, instances: list[Any]) -> None:
self.added.extend(instances)
def scalars(self, _statement: Any) -> list[Any]: # pragma: no cover - not used here
return []
def flush(self, _objects: list[Any] | None = None) -> None: # pragma: no cover - optional
return None
def test_station_repository_create_generates_geometry() -> None:
session = DummySession()
repo = StationRepository(session) # type: ignore[arg-type]
station = repo.create(
StationCreate(
name="Central",
latitude=52.52,
longitude=13.405,
osm_id="123",
code="BER",
elevation_m=34.5,
)
)
assert station.name == "Central"
assert session.added and session.added[0] is station
assert getattr(station.location, "srid", None) == 4326
assert "POINT" in str(station.location)
def test_track_repository_requires_geometry() -> None:
session = DummySession()
repo = TrackRepository(session) # type: ignore[arg-type]
with pytest.raises(ValueError):
repo.create(
TrackCreate(
start_station_id="00000000-0000-0000-0000-000000000001",
end_station_id="00000000-0000-0000-0000-000000000002",
coordinates=[(52.0, 13.0)],
)
)
def test_track_repository_create_builds_linestring() -> None:
session = DummySession()
repo = TrackRepository(session) # type: ignore[arg-type]
track = repo.create(
TrackCreate(
name="Main Line",
start_station_id="00000000-0000-0000-0000-000000000001",
end_station_id="00000000-0000-0000-0000-000000000002",
coordinates=[(52.0, 13.0), (53.0, 14.0)],
length_meters=1000.5,
max_speed_kph=160,
is_bidirectional=False,
status="operational",
)
)
assert session.added and session.added[0] is track
assert track.status == "operational"
geom_repr = str(track.track_geometry)
assert "LINESTRING" in geom_repr
assert "13.0 52.0" in geom_repr
def test_train_repository_create_supports_optional_ids() -> None:
session = DummySession()
repo = TrainRepository(session) # type: ignore[arg-type]
train = repo.create(
TrainCreate(
designation="ICE 123",
capacity=400,
max_speed_kph=300,
operator_id=None,
home_station_id="00000000-0000-0000-0000-000000000001",
consist="locomotive+cars",
)
)
assert session.added and session.added[0] is train
assert train.designation == "ICE 123"
assert str(train.home_station_id).endswith("1")
assert train.operator_id is None

570
docs/architecture.md Normal file
View File

@@ -0,0 +1,570 @@
# Rail Game Architecture Documentation
This document follows the arc42 template for software architecture documentation.
## 1. Introduction and Goals
### 1.1 Requirements Overview
The Rail Game is a browser-based railway simulation game that allows users to build and manage their own railway networks using real-world railway maps from OpenStreetMap. Key features include:
- Visualization of real-world railway maps
- Interactive building and management of railway networks
- Dynamic train scheduling and simulation
- User authentication and profiles
- Leaderboards and achievements
- Potential multiplayer elements
### 1.2 Quality Goals
- **Usability**: Intuitive interfaces for map interaction and network management
- **Performance**: Smooth real-time updates and responsive UI
- **Reliability**: Robust error handling and data integrity
- **Security**: Secure user authentication and data protection
- **Scalability**: Support for growing user base and complex networks
### 1.3 Stakeholders
- **Users**: Players who build and manage railway networks
- **Developers**: Team maintaining and extending the application
- **Administrators**: Managing user accounts and system operations
## 2. Constraints
### 2.1 Technical Constraints
- **Browser Compatibility**: Must work on modern web browsers supporting HTML5, CSS3, and JavaScript
- **Backend Language**: Python-based using FastAPI or Flask frameworks
- **Database**: PostgreSQL with PostGIS for spatial data handling
- **Mapping**: Integration with Leaflet and OpenStreetMap data
### 2.2 Organizational Constraints
- Open-source project hosted on GitHub
- Follows standard web development practices and security guidelines
### 2.3 Conventions
- Use Git for version control with conventional commit messages
- Follow React best practices for frontend development
- Implement RESTful API design for backend services
## 3. Context and Scope
### 3.1 Business Context
The Rail Game provides an educational and entertaining simulation of railway network management. It leverages real-world geographical data to create an immersive experience for users interested in transportation systems and strategy games.
### 3.2 Technical Context
The system interacts with:
- **OpenStreetMap**: Source of real-world map and railway data
- **Web Browsers**: Primary interface for users
- **PostgreSQL/PostGIS**: Storage and spatial queries for railway data
- **External APIs**: Potential integrations for additional features (e.g., weather, real-time data)
### 3.3 System Scope
**In Scope:**
- User registration and authentication
- Railway network building and management
- Train scheduling and simulation
- Map visualization and interaction
- Leaderboards and user profiles
**Out of Scope:**
- Real-world train control systems
- Physical railway operations
- Multiplayer real-time collaboration (initial version)
## 4. Solution Strategy
### 4.1 Technology Choices
- **Frontend**: React-based single-page application for responsive UI
- **Backend**: Python FastAPI/Flask for RESTful APIs and real-time features
- **Database**: PostgreSQL with PostGIS for efficient spatial data handling
- **Mapping**: Leaflet library for interactive maps integrated with OpenStreetMap
### 4.2 Architectural Patterns
- **Client-Server Architecture**: Separation of frontend and backend concerns
- **RESTful API**: For communication between frontend and backend
- **Component-Based UI**: Using React for modular frontend development
- **ORM**: SQLAlchemy for database abstraction
### 4.3 Key Decisions
- Browser-native implementation for broad accessibility
- Spatial database for efficient geographical queries
- Modular architecture allowing for future extensions (e.g., multiplayer)
## 5. Building Block View
### 5.1 Whitebox Overall System
The Rail Game system is structured as a client-server architecture with the following top-level building blocks:
- **Frontend Application**: Browser-based React SPA handling user interface and interactions
- **Backend API**: Python-based RESTful API server managing game logic and data access
- **Database**: PostgreSQL with PostGIS for persistent storage and spatial queries
- **External Services**: OpenStreetMap and other third-party APIs for map data and additional features
```mermaid
graph TD
A[Frontend Application] -->|REST API| B[Backend API]
B -->|SQL Queries| C[Database]
B -->|API Calls| D[External Services]
A -->|Map Tiles| D
```
### 5.2 Level 1 Building Blocks
#### 5.2.1 Frontend Application
**Responsibility**: Provides the user interface for railway network building, management, and visualization.
**Interfaces**:
- User interactions via browser
- RESTful API calls to Backend API
- Integration with Leaflet for map rendering
**Key Components**:
- Map View: Displays railway networks and allows interaction
- Network Builder: Tools for creating and editing railway tracks and stations
- Dashboard: User profile, resources, and game statistics
- Authentication UI: Login, registration, and profile management
#### 5.2.2 Backend API
**Responsibility**: Handles game logic, data processing, and serves as the interface between frontend and database.
**Interfaces**:
- RESTful HTTP endpoints for frontend communication
- Database connections via SQLAlchemy ORM
- Potential WebSocket connections for real-time updates
**Key Components**:
- User Management: Authentication, profiles, and sessions
- Railway Engine: Logic for network building, route calculation, and scheduling
- Game Logic: Resource management, scoring, and achievements
- Data Access Layer: Abstraction for database operations
#### 5.2.3 Database
**Responsibility**: Persistent storage of user data, railway networks, and game state.
**Interfaces**:
- SQL connections from Backend API
- Spatial queries via PostGIS extensions
**Key Components**:
- User Schema: Accounts, profiles, and authentication data
- Railway Schema: Tracks, stations, trains, and schedules
- Game Schema: Resources, achievements, and leaderboards
#### 5.2.4 External Services
**Responsibility**: Provides external data sources and integrations.
**Interfaces**:
- API calls from Backend or Frontend
- Data feeds for map tiles, geographical information, and real-time data
**Key Components**:
- OpenStreetMap: Source of map tiles and railway data
- Authentication Providers: OAuth integrations (e.g., Google, GitHub)
- Analytics Services: User tracking and performance monitoring
### 5.3 Level 2 Building Blocks
#### 5.3.1 Frontend Components
```mermaid
graph TD
A[Map Component] -->|Leaflet| B[Toolbar Component]
A -->|Leaflet| C[Modal Components]
A -->|Redux| D[State Management]
```
- **Map Component**: React Leaflet-based map showing OpenStreetMap tiles with station markers and track polylines drawn from the shared network snapshot models
- **Toolbar Component**: Tools for building tracks, placing stations, and managing trains
- **Modal Components**: Dialogs for settings, confirmations, and detailed views
- **State Management**: Redux store for game state and UI state
#### 5.3.2 Backend Modules
```mermaid
graph TD
A[API Layer] -->|REST Endpoints| B[Health Router]
A -->|REST Endpoints| C[Network Router]
C -->|Domain Models| D[Network Service]
D -->|Shared Schemas| E[Frontend Data Contracts]
```
- **Health Module**: Lightweight readiness probes used by infrastructure checks.
- **Network Module**: Serves read-only snapshots of stations, tracks, and trains using shared domain models (camelCase aliases for client compatibility).
- **Authentication Module**: JWT-based user registration, authentication, and authorization. The current prototype supports on-the-fly account creation backed by an in-memory user store and issues short-lived access tokens to validate the client flow end-to-end.
- **Railway Calculation Module**: Algorithms for route optimization and scheduling (planned).
- **Resource Management Module**: Logic for game economy and progression (planned).
- **Real-time Module**: WebSocket handlers for live updates (if implemented).
#### 5.3.3 Database Tables
- **Users Table**: User accounts and profile information
- **Railways Table**: User-created railway networks (spatial data)
- **Trains Table**: Train configurations and schedules
- **Stations Table**: Station locations and properties (spatial data)
- **Achievements Table**: User progress and leaderboard data
### 5.4 Project Directory Structure
The repository will be organized to mirror the logical architecture and isolate concerns between frontend, backend, infrastructure, and shared assets.
```text
rail-game/
|-- backend/
| |-- app/
| | |-- api/ # FastAPI/Flask route handlers and request lifecycles
| | |-- core/ # Configuration, startup hooks, cross-cutting utilities
| | |-- models/ # SQLAlchemy models, Pydantic schemas, migrations helpers
| | |-- services/ # Domain services for scheduling, routing, resource logic
| | `-- websocket/ # Real-time transport adapters and event handlers
| |-- tests/ # Backend unit, integration, and contract tests
| `-- requirements/ # Dependency manifests and lockfiles per environment
|-- frontend/
| |-- public/ # Static assets served without processing
| |-- src/
| | |-- components/ # Reusable React UI components and widgets
| | |-- hooks/ # Custom hooks for map interaction, data fetching, state sync
| | |-- pages/ # Route-level views composing feature modules
| | |-- state/ # Redux/Context stores, slices, and middleware
| | |-- styles/ # Global stylesheets, design tokens, CSS modules
| | `-- utils/ # Frontend-only helpers for formatting and calculations
| `-- tests/ # Component, store, and integration tests (Jest/React Testing Library)
|-- docs/ # Architecture docs, ADRs, onboarding guides
|-- infra/ # Docker, Terraform, CI/CD workflows, deployment manifests
|-- scripts/ # Automation for setup, linting, database tasks, data imports
|-- data/ # Seed datasets, fixtures, export/import scripts (kept out of VCS if large)
`-- tests/ # Cross-cutting end-to-end suites and shared test utilities
```
Shared code that spans application layers should be surfaced through well-defined APIs within `backend/app/services` or exposed via frontend data contracts to keep coupling low. Infrastructure automation and CI/CD assets remain isolated under `infra/` to support multiple deployment targets.
## 6. Runtime View
### 6.1 Overview
The runtime view illustrates the dynamic behavior of the Rail Game system during typical user interactions. It shows how the building blocks interact to fulfill user requests and maintain system state.
### 6.2 Key Runtime Scenarios
#### 6.2.1 User Authentication
**Scenario**: A user signs up and logs into the game.
**Description**: From the authentication UI the user can either register a new profile or sign in with existing credentials. New registrations are persisted in the prototype's in-memory store. On login the backend verifies the credentials and issues a JWT token for subsequent requests.
```mermaid
sequenceDiagram
participant U as User
participant F as Frontend
participant B as Backend API
participant D as Database
U->>F: Submit signup/login form
alt Register new account
F->>B: POST /api/auth/register
B->>B: Persist user (in-memory prototype store)
end
F->>B: POST /api/auth/login
B->>D: Query user credentials
D-->>B: User data
B->>B: Validate password
B-->>F: JWT token
F-->>U: Redirect to dashboard
```
#### 6.2.2 Loading Map and Railway Data
**Scenario**: User opens the game and loads their railway network.
**Description**: The frontend requests map tiles from OpenStreetMap and user-specific railway data from the backend, which retrieves it from the database.
```mermaid
sequenceDiagram
participant U as User
participant F as Frontend
participant B as Backend API
participant D as Database
participant OSM as OpenStreetMap
U->>F: Open game
F->>OSM: Request map tiles
OSM-->>F: Map tiles
F->>B: GET /api/railways/{userId}
B->>D: Query user railways
D-->>B: Railway data (spatial)
B-->>F: Railway network JSON
F->>F: Render map with railways
```
#### 6.2.3 Fetching Network Snapshot (current implementation)
**Scenario**: The frontend loads a shared snapshot of stations, tracks, and trains using the domain models.
**Description**: After the React client authenticates and stores the issued access token, it calls the FastAPI `/api/network` endpoint with a bearer header. The backend constructs a `NetworkSnapshot` using immutable domain models and returns camelCase JSON for direct consumption by TypeScript interfaces. The frontend hydrates both summary lists and the React Leaflet map overlay with the resulting station and track geometry.
```mermaid
sequenceDiagram
participant F as Frontend (React)
participant H as Hook (useNetworkSnapshot)
participant A as API Router (/api/network)
participant S as Network Service
F->>H: Mount component
H->>A: GET /api/network (Bearer token)
A->>S: Build snapshot using domain models
S-->>A: Stations, tracks, trains (camelCase JSON)
A-->>H: 200 OK + payload
H-->>F: Update UI state (loading → success)
F->>F: Render Leaflet map and snapshot summaries
```
#### 6.2.4 Building Railway Network
**Scenario**: User adds a new track segment to their railway network.
**Description**: The user interacts with the map to place a new track. The frontend sends the new track data to the backend, which validates and stores it in the database.
```mermaid
sequenceDiagram
participant U as User
participant F as Frontend
participant B as Backend API
participant D as Database
U->>F: Draw new track on map
F->>F: Validate track placement
F->>B: POST /api/tracks
B->>B: Validate track logic
B->>D: Insert new track (spatial)
D-->>B: Confirmation
B-->>F: Success response
F->>F: Update map display
```
#### 6.2.5 Running Train Simulation
**Scenario**: User starts a train simulation on their network.
**Description**: The frontend requests simulation start, backend calculates train routes and schedules, updates database with simulation state, and sends real-time updates back to frontend.
```mermaid
sequenceDiagram
participant U as User
participant F as Frontend
participant B as Backend API
participant D as Database
U->>F: Click "Start Simulation"
F->>B: POST /api/simulation/start
B->>D: Query railway network
D-->>B: Network data
B->>B: Calculate routes & schedules
B->>D: Update train positions
D-->>B: Confirmation
B-->>F: Simulation started
loop Real-time updates
B->>B: Update train positions
B->>D: Save positions
B-->>F: WebSocket position updates
end
```
#### 6.2.6 Saving Game Progress
**Scenario**: User saves their current game state.
**Description**: The frontend periodically or on user request sends current game state to backend for persistence.
```mermaid
sequenceDiagram
participant F as Frontend
participant B as Backend API
participant D as Database
F->>B: POST /api/save
B->>D: Update user progress
D-->>B: Confirmation
B-->>F: Save successful
```
### 6.3 Performance and Scalability Considerations
- **Database Queries**: Spatial queries for railway data are optimized using PostGIS indexes
- **Caching**: Frequently accessed map tiles and user data may be cached
- **Real-time Updates**: WebSocket connections for simulation updates, with fallback to polling
- **Load Balancing**: Backend API can be scaled horizontally for multiple users
- **CDN**: Static assets and map tiles served via CDN for faster loading
## 7. Deployment View
To be detailed in subsequent sections.
## 8. Concepts
### 8.1 Domain Concepts
#### 8.1.1 Railway Network Model
The core domain concept is the railway network, consisting of:
- **Tracks**: Linear segments connecting geographical points, stored as spatial geometries
- **Stations**: Key points on the network where trains can stop, load/unload passengers or cargo
- **Trains**: Movable entities that follow routes along tracks according to schedules
- **Schedules**: Time-based plans for train movements and operations
Railway networks are user-created and managed, built upon real-world geographical data from OpenStreetMap.
#### 8.1.2 Game Economy
Resource management drives gameplay:
- **Currency**: Earned through network operations and achievements
- **Resources**: Required for building and upgrading railway components
- **Scoring**: Based on network efficiency, passenger satisfaction, and operational success
#### 8.1.3 Simulation Engine
Dynamic simulation of train operations:
- **Route Calculation**: Pathfinding algorithms to determine optimal train routes
- **Schedule Optimization**: Balancing train frequencies with network capacity
- **Real-time Updates**: Live position tracking and status reporting
#### 8.1.4 Network Snapshot Contract
- **Shared Models**: The backend uses immutable Pydantic models with camelCase aliases that mirror TypeScript interfaces in `frontend/src/types/domain.ts`.
- **Snapshot Service**: Until persistence exists, a service synthesises demo stations, tracks, and trains to keep the client workflow functional.
- **Client Hook**: `useNetworkSnapshot` orchestrates fetch status (idle/loading/success/error) and pushes data into the React view layer.
### 8.2 Architectural Concepts
#### 8.2.1 Client-Server Architecture
- **Frontend**: Browser-based React SPA handling user interactions and UI rendering
- **Backend**: RESTful API server processing business logic and data operations
- **Separation of Concerns**: Clear boundaries between presentation, business logic, and data layers
#### 8.2.2 Spatial Data Handling
- **PostGIS Integration**: Extension of PostgreSQL for geographical and spatial operations
- **Coordinate Systems**: Use of standard geographical projections (e.g., WGS84)
- **Spatial Queries**: Efficient querying of railway elements within geographical bounds
#### 8.2.3 Real-time Communication
- **WebSocket Protocol**: For live updates during train simulations
- **Fallback Mechanisms**: Polling as alternative when WebSockets unavailable
- **Event-Driven Updates**: Push notifications for game state changes
### 8.3 User Interface Concepts
#### 8.3.1 Component-Based Architecture
- **React Components**: Modular, reusable UI elements
- **State Management**: Centralized state using Redux or Context API
- **Responsive Design**: Adaptive layouts for various screen sizes and devices
#### 8.3.2 Map Interaction
- **Leaflet Integration**: Interactive mapping library for geographical visualization
- **Layer Management**: Overlaying user railways on base OpenStreetMap tiles
- **Gesture Handling**: Mouse, keyboard, and touch interactions for map navigation and editing
#### 8.3.3 Game Interface Patterns
- **Toolbar**: Contextual tools for building and editing railway elements
- **Modal Dialogs**: For configuration, confirmation, and detailed information display
- **Dashboard**: Overview of user progress, resources, and network statistics
### 8.4 Security Concepts
#### 8.4.1 Authentication and Authorization
- **JWT Tokens**: Stateless authentication for API requests
- **OAuth Integration**: Support for third-party authentication providers
- **Role-Based Access**: Differentiated permissions for users and administrators
#### 8.4.2 Data Protection
- **Input Validation**: Sanitization of all user inputs to prevent injection attacks
- **HTTPS Encryption**: Secure communication between client and server
- **Data Privacy**: Compliance with privacy regulations for user data handling
### 8.5 Persistence Concepts
#### 8.5.1 Database Design
- **Schema Overview**: Core tables include `users`, `stations`, `tracks`, `trains`, and `train_schedules`, each backed by UUID primary keys and timestamp metadata for auditing.
- **Users**: Holds account metadata (`username`, `email`, `role`, hashed password) with JSON-ready preference storage and soft defaults for player roles.
- **Stations**: Stores OpenStreetMap references, station codes, and a `POINT` geometry (`SRID 4326`) to support spatial queries; a GiST index accelerates proximity searches.
- **Tracks**: Models line segments between stations using `LINESTRING` geometry plus operational attributes (length, speed limits, status, bidirectionality) and a uniqueness constraint on station pairs.
- **Trains & Schedules**: Captures rolling stock capabilities and their ordered stop plans (`sequence_index`, arrival/departure timestamps, dwell times) with cascading foreign keys for clean deletions.
- **Spatial Extensions**: Alembic migrations provision `postgis` and `pgcrypto` extensions; geometry columns use GeoAlchemy2 bindings for seamless ORM interactions.
#### 8.5.2 Data Access Patterns
- **ORM Layer**: SQLAlchemy 2.0 declarative models (see `backend/app/db/models.py`) expose typed entities that will feed repository and service layers.
- **Session Management**: A centralized engine/session factory (`backend/app/db/session.py`) pulls the database URL from environment-managed settings and keeps pooling under application control.
- **Environment Separation**: `.env` configuration exposes `DATABASE_URL`, `TEST_DATABASE_URL`, and `ALEMBIC_DATABASE_URL`, allowing the runtime, tests, and migration tooling to target different Postgres instances.
- **Schema Evolution**: Alembic configuration (`backend/alembic.ini`, `backend/migrations/`) provides repeatable migrations—the initial revision creates the PostGIS-enabled schema and GiST indexes.
- **Transaction Management**: Service-layer dependencies will acquire short-lived sessions (`SessionLocal`) ensuring explicit commit/rollback boundaries around game operations.
### 8.6 Development and Deployment Concepts
#### 8.6.1 Testing Strategy
- **Unit Testing**: Individual component and function testing
- **Integration Testing**: API endpoint and database interaction validation
- **End-to-End Testing**: Complete user workflow verification across browsers
#### 8.6.2 Deployment Pipeline
- **Containerization**: Docker for consistent environments
- **CI/CD**: Automated testing and deployment workflows
- **Static Hosting**: CDN-based delivery of frontend assets
#### 8.6.3 Performance Optimization
- **Lazy Loading**: On-demand loading of components and data
- **Caching Layers**: Redis for frequently accessed data
- **Asset Optimization**: Minification and compression of static resources
## 9. Design Decisions
To be detailed in subsequent sections.
## 10. Quality Requirements
To be detailed in subsequent sections.
## 11. Risks and Technical Debt
To be detailed in subsequent sections.
## 12. Glossary
To be detailed in subsequent sections.

45
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,45 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: "latest",
sourceType: "module"
},
settings: {
react: {
version: "detect"
}
},
plugins: ["react", "@typescript-eslint", "react-hooks", "jsx-a11y", "import"],
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:jsx-a11y/recommended",
"plugin:import/typescript",
"prettier"
],
rules: {
"react/react-in-jsx-scope": "off",
"import/order": [
"warn",
{
"groups": [["builtin", "external"], "internal", ["parent", "sibling", "index"]],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
};

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 88
}

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rail Game</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5861
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "rail-game-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && tsc --project tsconfig.node.json --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
},
"dependencies": {
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1"
},
"devDependencies": {
"@types/node": "^20.16.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/leaflet": "^1.9.13",
"@typescript-eslint/eslint-plugin": "^8.6.0",
"@typescript-eslint/parser": "^8.6.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"prettier": "^3.3.3",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
}

88
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,88 @@
import './styles/global.css';
import { LoginForm } from './components/auth/LoginForm';
import { NetworkMap } from './components/map/NetworkMap';
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
import { useAuth } from './state/AuthContext';
function App(): JSX.Element {
const { token, user, status: authStatus, logout } = useAuth();
const isAuthenticated = authStatus === 'authenticated' && token !== null;
const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null);
return (
<div className="app-shell">
<header className="app-header">
<div>
<h1>Rail Game</h1>
<p>Build and manage a railway network using real-world map data.</p>
</div>
{isAuthenticated && (
<div className="auth-meta">
<span>Signed in as {user?.fullName ?? user?.username}</span>
<button type="button" onClick={() => logout()} className="ghost-button">
Sign out
</button>
</div>
)}
</header>
<main className="app-main">
{!isAuthenticated ? (
<section className="card">
<LoginForm />
</section>
) : (
<section className="card">
<h2>Network Snapshot</h2>
{status === 'loading' && <p>Loading network data</p>}
{status === 'error' && <p className="error-text">{error}</p>}
{status === 'success' && data && (
<div className="snapshot-layout">
<div className="map-wrapper">
<NetworkMap snapshot={data} />
</div>
<div className="grid">
<div>
<h3>Stations</h3>
<ul>
{data.stations.map((station) => (
<li key={station.id}>
{station.name} ({station.latitude.toFixed(3)},{' '}
{station.longitude.toFixed(3)})
</li>
))}
</ul>
</div>
<div>
<h3>Trains</h3>
<ul>
{data.trains.map((train) => (
<li key={train.id}>
{train.designation} · {train.capacity} capacity ·{' '}
{train.maxSpeedKph} km/h
</li>
))}
</ul>
</div>
<div>
<h3>Tracks</h3>
<ul>
{data.tracks.map((track) => (
<li key={track.id}>
{track.startStationId} {track.endStationId} ·{' '}
{(track.lengthMeters / 1000).toFixed(1)} km
</li>
))}
</ul>
</div>
</div>
</div>
)}
</section>
)}
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,98 @@
import { FormEvent, useState } from 'react';
import { useAuth } from '../../state/AuthContext';
export function LoginForm(): JSX.Element {
const { login, register, logout, status, error } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('demo');
const [password, setPassword] = useState('railgame123');
const [fullName, setFullName] = useState('');
const isSubmitting = status === 'authenticating';
const isRegisterMode = mode === 'register';
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isRegisterMode) {
await register(username, password, fullName || undefined);
} else {
await login(username, password);
}
}
function toggleMode() {
setMode((current) => (current === 'login' ? 'register' : 'login'));
setUsername('');
setPassword('');
setFullName('');
logout();
}
return (
<form className="auth-card" onSubmit={handleSubmit}>
<div className="auth-card__heading">
<h2>{isRegisterMode ? 'Create an account' : 'Sign in'}</h2>
<button
type="button"
className="link-button"
onClick={toggleMode}
disabled={isSubmitting}
>
{isRegisterMode ? 'Have an account? Sign in' : 'Need an account? Register'}
</button>
</div>
<p className="auth-helper">
{isRegisterMode
? 'Choose a username and password to create a new profile.'
: 'Use the demo credentials to explore the prototype.'}
</p>
{isRegisterMode && (
<>
<label htmlFor="fullName">Full name</label>
<input
id="fullName"
autoComplete="name"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
disabled={isSubmitting}
/>
</>
)}
<label htmlFor="username">Username</label>
<input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={isSubmitting}
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete={isRegisterMode ? 'new-password' : 'current-password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
required
/>
{error && <p className="error-text">{error}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting
? isRegisterMode
? 'Creating account…'
: 'Signing in…'
: isRegisterMode
? 'Register'
: 'Sign in'}
</button>
</form>
);
}

View File

@@ -0,0 +1,102 @@
import type { LatLngBoundsExpression, LatLngExpression } from 'leaflet';
import { useMemo } from 'react';
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip } from 'react-leaflet';
import type { NetworkSnapshot } from '../../services/api';
import 'leaflet/dist/leaflet.css';
interface NetworkMapProps {
readonly snapshot: NetworkSnapshot;
}
interface StationPosition {
readonly id: string;
readonly name: string;
readonly position: LatLngExpression;
}
const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09];
export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
const stationPositions = useMemo<StationPosition[]>(() => {
return snapshot.stations.map((station) => ({
id: station.id,
name: station.name,
position: [station.latitude, station.longitude] as LatLngExpression,
}));
}, [snapshot.stations]);
const stationLookup = useMemo(() => {
const lookup = new Map<string, LatLngExpression>();
for (const station of stationPositions) {
lookup.set(station.id, station.position);
}
return lookup;
}, [stationPositions]);
const trackSegments = useMemo(() => {
return snapshot.tracks
.map((track) => {
const start = stationLookup.get(track.startStationId);
const end = stationLookup.get(track.endStationId);
if (!start || !end) {
return null;
}
return [start, end] as LatLngExpression[];
})
.filter((segment): segment is LatLngExpression[] => segment !== null);
}, [snapshot.tracks, stationLookup]);
const bounds = useMemo(() => {
if (stationPositions.length === 0) {
return null;
}
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const { position } of stationPositions) {
const [lat, lng] = position as [number, number];
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
}
const padding = 0.02;
return [
[minLat - padding, minLng - padding],
[maxLat + padding, maxLng + padding],
] as LatLngBoundsExpression;
}, [stationPositions]);
return (
<MapContainer
className="network-map"
center={bounds ? undefined : DEFAULT_CENTER}
bounds={bounds ?? undefined}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{trackSegments.map((segment, index) => (
<Polyline key={`track-${index}`} positions={segment} pathOptions={{ color: '#38bdf8', weight: 4 }} />
))}
{stationPositions.map((station) => (
<CircleMarker
key={station.id}
center={station.position}
radius={6}
pathOptions={{ color: '#f97316', fillColor: '#fed7aa', fillOpacity: 0.9 }}
>
<Tooltip direction="top" offset={[0, -8]}>{station.name}</Tooltip>
</CircleMarker>
))}
</MapContainer>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import type { NetworkSnapshot } from '../services/api';
import { ApiError, fetchNetworkSnapshot } from '../services/api';
import { useAuth } from '../state/AuthContext';
interface NetworkSnapshotState {
readonly data: NetworkSnapshot | null;
readonly status: 'idle' | 'loading' | 'success' | 'error';
readonly error: string | null;
}
const INITIAL_STATE: NetworkSnapshotState = {
data: null,
status: 'idle',
error: null,
};
export function useNetworkSnapshot(token: string | null): NetworkSnapshotState {
const [state, setState] = useState<NetworkSnapshotState>(INITIAL_STATE);
const { logout } = useAuth();
useEffect(() => {
const abortController = new AbortController();
if (!token) {
setState(INITIAL_STATE);
return () => abortController.abort();
}
const authToken = token;
async function load() {
setState({ data: null, status: 'loading', error: null });
try {
const snapshot = await fetchNetworkSnapshot(authToken, abortController.signal);
setState({ data: snapshot, status: 'success', error: null });
} catch (error) {
if (abortController.signal.aborted) {
return;
}
if (error instanceof ApiError && error.status === 401) {
logout(error.message);
setState({ data: null, status: 'error', error: error.message });
return;
}
setState({ data: null, status: 'error', error: (error as Error).message });
}
}
load();
return () => abortController.abort();
}, [token, logout]);
return state;
}

20
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
import { AuthProvider } from './state/AuthContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Failed to find the root element');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,84 @@
import type { AuthResponse, LoginPayload, RegisterPayload } from '../types/auth';
import type { Station, Track, Train } from '../types/domain';
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
export interface NetworkSnapshot {
readonly stations: Station[];
readonly tracks: Track[];
readonly trains: Train[];
}
const JSON_HEADERS = {
accept: 'application/json',
'content-type': 'application/json',
};
export async function login(credentials: LoginPayload): Promise<AuthResponse> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new ApiError('Invalid username or password', response.status);
}
return (await response.json()) as AuthResponse;
}
export async function register(credentials: RegisterPayload): Promise<AuthResponse> {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(credentials),
});
if (!response.ok) {
const message =
response.status === 409
? 'Username already exists'
: 'Registration failed. Please try again.';
throw new ApiError(message, response.status);
}
return (await response.json()) as AuthResponse;
}
export async function fetchNetworkSnapshot(
token: string,
signal?: AbortSignal
): Promise<NetworkSnapshot> {
const response = await fetch('/api/network', {
method: 'GET',
headers: {
accept: 'application/json',
Authorization: `Bearer ${token}`,
},
signal,
});
if (!response.ok) {
const message =
response.status === 401
? 'Authorization required. Please sign in again.'
: `Failed to fetch network snapshot (status ${response.status})`;
throw new ApiError(message, response.status);
}
const data = (await response.json()) as NetworkSnapshot;
return {
stations: data.stations ?? [],
tracks: data.tracks ?? [],
trains: data.trains ?? [],
};
}

View File

@@ -0,0 +1,149 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { login as loginRequest, register as registerRequest } from '../services/api';
import type { AuthenticatedUser } from '../types/auth';
const STORAGE_TOKEN_KEY = 'rail-game/auth/token';
const STORAGE_USER_KEY = 'rail-game/auth/user';
type AuthStatus = 'unauthenticated' | 'authenticating' | 'authenticated';
interface AuthContextValue {
readonly token: string | null;
readonly user: AuthenticatedUser | null;
readonly status: AuthStatus;
readonly error: string | null;
login: (username: string, password: string) => Promise<boolean>;
register: (
username: string,
password: string,
fullName?: string | null
) => Promise<boolean>;
logout: (reason?: string) => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function readFromStorage<T>(key: string): T | null {
if (typeof window === 'undefined') {
return null;
}
const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function writeToStorage(key: string, value: unknown): void {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, JSON.stringify(value));
}
function removeFromStorage(key: string): void {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(key);
}
interface AuthProviderProps {
readonly children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps): JSX.Element {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthenticatedUser | null>(null);
const [status, setStatus] = useState<AuthStatus>('unauthenticated');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const storedToken = readFromStorage<string>(STORAGE_TOKEN_KEY);
const storedUser = readFromStorage<AuthenticatedUser>(STORAGE_USER_KEY);
if (storedToken && storedUser) {
setToken(storedToken);
setUser(storedUser);
setStatus('authenticated');
}
}, []);
const logout = useCallback((reason?: string) => {
setToken(null);
setUser(null);
setStatus('unauthenticated');
setError(reason ?? null);
removeFromStorage(STORAGE_TOKEN_KEY);
removeFromStorage(STORAGE_USER_KEY);
}, []);
const login = useCallback(async (username: string, password: string) => {
setStatus('authenticating');
setError(null);
try {
const response = await loginRequest({ username, password });
setToken(response.accessToken);
setUser(response.user);
setStatus('authenticated');
writeToStorage(STORAGE_TOKEN_KEY, response.accessToken);
writeToStorage(STORAGE_USER_KEY, response.user);
return true;
} catch (err) {
setStatus('unauthenticated');
const message = err instanceof Error ? err.message : 'Authentication failed';
setError(message);
return false;
}
}, []);
const register = useCallback(
async (username: string, password: string, fullName?: string | null) => {
setStatus('authenticating');
setError(null);
try {
const response = await registerRequest({ username, password, fullName });
setToken(response.accessToken);
setUser(response.user);
setStatus('authenticated');
writeToStorage(STORAGE_TOKEN_KEY, response.accessToken);
writeToStorage(STORAGE_USER_KEY, response.user);
return true;
} catch (err) {
setStatus('unauthenticated');
const message = err instanceof Error ? err.message : 'Registration failed';
setError(message);
return false;
}
},
[]
);
const value = useMemo<AuthContextValue>(
() => ({ token, user, status, error, login, register, logout }),
[token, user, status, error, login, register, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,235 @@
:root {
font-family:
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
line-height: 1.5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #1e293b, #0f172a 55%, #020617 100%);
}
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
align-items: center;
padding: 3rem 1.5rem;
gap: 2rem;
}
.app-header {
text-align: center;
max-width: 720px;
}
.app-header h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1rem;
}
.app-header p {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.72);
}
.app-main {
width: min(900px, 100%);
}
.card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 35px 60px -15px rgba(15, 23, 42, 0.65);
}
.card h2 {
font-size: 1.6rem;
margin-bottom: 0.75rem;
}
.card p {
color: rgba(226, 232, 240, 0.85);
font-size: 1rem;
}
.grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-top: 1.5rem;
}
.grid ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0;
}
.grid h3 {
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.snapshot-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.map-wrapper {
height: 360px;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.25);
}
.network-map {
height: 100%;
width: 100%;
}
@media (min-width: 768px) {
.snapshot-layout {
gap: 2rem;
}
.map-wrapper {
height: 420px;
}
}
.error-text {
color: #f87171;
font-weight: 600;
}
.app-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
text-align: center;
}
.app-header h1 {
margin-bottom: 0.5rem;
}
.auth-meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-size: 0.95rem;
color: rgba(226, 232, 240, 0.9);
}
@media (min-width: 640px) {
.app-header {
flex-direction: row;
justify-content: space-between;
text-align: left;
}
.auth-meta {
align-items: flex-end;
}
}
.auth-card {
display: grid;
gap: 1rem;
}
.auth-card__heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.auth-helper {
font-size: 0.95rem;
color: rgba(226, 232, 240, 0.75);
}
.auth-card input {
padding: 0.75rem 0.85rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.7);
color: rgba(226, 232, 240, 0.95);
}
.auth-card button,
.ghost-button {
cursor: pointer;
border: none;
border-radius: 999px;
padding: 0.75rem 1.5rem;
font-weight: 600;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.auth-card button {
background: linear-gradient(135deg, #38bdf8, #818cf8);
color: #0f172a;
}
.auth-card button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.auth-card button:not(:disabled):hover {
transform: translateY(-1px);
}
.ghost-button {
background: transparent;
color: rgba(226, 232, 240, 0.85);
border: 1px solid rgba(148, 163, 184, 0.35);
padding: 0.35rem 1.1rem;
}
.ghost-button:hover {
background: rgba(148, 163, 184, 0.15);
}
.link-button {
background: none;
border: none;
color: #93c5fd;
cursor: pointer;
font-weight: 600;
text-decoration: underline;
padding: 0;
}
.link-button:disabled {
color: rgba(148, 163, 184, 0.6);
cursor: not-allowed;
text-decoration: none;
}

View File

@@ -0,0 +1,19 @@
export interface LoginPayload {
readonly username: string;
readonly password: string;
}
export interface RegisterPayload extends LoginPayload {
readonly fullName?: string | null;
}
export interface AuthenticatedUser {
readonly username: string;
readonly fullName?: string | null;
}
export interface AuthResponse {
readonly accessToken: string;
readonly tokenType: string;
readonly user: AuthenticatedUser;
}

View File

@@ -0,0 +1,30 @@
export interface Timestamped {
readonly createdAt: string;
readonly updatedAt: string;
}
export interface Identified extends Timestamped {
readonly id: string;
}
export interface Station extends Identified {
readonly name: string;
readonly latitude: number;
readonly longitude: number;
}
export interface Track extends Identified {
readonly startStationId: string;
readonly endStationId: string;
readonly lengthMeters: number;
readonly maxSpeedKph: number;
}
export interface Train extends Identified {
readonly designation: string;
readonly capacity: number;
readonly maxSpeedKph: number;
readonly operatingTrackIds: readonly string[];
}
export type NetworkEntity = Station | Track | Train;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"lib": ["ESNext"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/hooks/usenetworksnapshot.ts","./src/services/api.ts","./src/types/domain.ts"],"errors":true,"version":"5.9.3"}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
open: true
}
});

5
githooks/pre-commit Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
python "${repo_root}/scripts/precommit.py"

15
infra/postgres/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
FROM postgis/postgis:16-3.4
ARG POSTGRES_USER=railgame
ARG POSTGRES_PASSWORD=railgame
ARG POSTGRES_DB=railgame_dev
ENV POSTGRES_USER=${POSTGRES_USER} \
POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \
POSTGRES_DB=${POSTGRES_DB}
# Bootstrap extensions and companion databases on first run.
COPY initdb/ /docker-entrypoint-initdb.d/
EXPOSE 5432

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
psql_cli=("${psql[@]}")
if [[ ${#psql_cli[@]} -eq 0 ]]; then
psql_cli=(psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --no-password)
fi
"${psql_cli[@]}" --dbname="$POSTGRES_DB" <<-EOSQL
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
EOSQL
existing_db=$("${psql_cli[@]}" --dbname=postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'railgame_test';")
if [[ "$existing_db" != "1" ]]; then
"${psql_cli[@]}" --dbname=postgres <<-EOSQL
CREATE DATABASE railgame_test OWNER ${POSTGRES_USER};
EOSQL
fi
"${psql_cli[@]}" --dbname=railgame_test <<-EOSQL
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
EOSQL

56
pyproject.toml Normal file
View File

@@ -0,0 +1,56 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "rail-game-backend"
version = "0.1.0"
description = "Backend service for the Rail Game project"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{name = "Rail Game Team"}]
dependencies = [
"fastapi>=0.111.0,<1.0.0",
"uvicorn[standard]>=0.24.0,<1.0.0",
"pydantic-settings>=2.0.0,<3.0.0",
"python-jose[cryptography]>=3.3.0,<4.0.0",
"passlib[bcrypt]>=1.7.4,<2.0.0",
"SQLAlchemy>=2.0.31,<3.0.0",
"alembic>=1.13.1,<2.0.0",
"geoalchemy2>=0.15.2,<0.16.0",
"psycopg[binary]>=3.1.19,<4.0.0",
"shapely>=2.0.4,<3.0.0"
]
[project.optional-dependencies]
dev = [
"httpx>=0.27.0,<0.28.0",
"pytest>=8.0.0,<9.0.0",
"black>=24.0.0,<25.0.0",
"isort>=5.13.0,<6.0.0"
]
[tool.black]
line-length = 88
target-version = ["py310"]
include = "backend/.+\\.py$"
[tool.isort]
profile = "black"
line_length = 88
src_paths = ["backend"]
known_first_party = ["backend"]
[tool.setuptools.packages.find]
where = ["."]
include = ["backend*"]
exclude = ["frontend*", "infra*", "data*", "docs*", "tests*"]
[tool.pytest.ini_options]
testpaths = [
"backend/tests"
]
pythonpath = [
"backend"
]

58
scripts/precommit.py Normal file
View File

@@ -0,0 +1,58 @@
"""Run project pre-commit checks for backend and frontend code."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIR = ROOT / "frontend"
BACKEND_DIR = ROOT / "backend"
def run_step(command: list[str], *, cwd: Path | None = None) -> None:
display_cmd = " ".join(command)
step_cwd = cwd or ROOT
print(f"\n>>> {display_cmd} (cwd={step_cwd})")
result = subprocess.run(command, cwd=step_cwd, check=False)
if result.returncode != 0:
raise RuntimeError(f"Command failed: {display_cmd}")
def main() -> int:
steps: list[tuple[list[str], Path | None]] = [
([sys.executable, "-m", "black", "--check", str(BACKEND_DIR)], None),
([sys.executable, "-m", "isort", "--check-only", str(BACKEND_DIR)], None),
([sys.executable, "-m", "pytest", str(BACKEND_DIR / "tests")], None),
]
if (FRONTEND_DIR / "node_modules").exists():
steps.append(
(
[
"npm",
"--prefix",
str(FRONTEND_DIR),
"run",
"lint",
],
None,
)
)
else:
print("\n>>> Skipping frontend lint (frontend/node_modules not installed)")
try:
for command, cwd in steps:
run_step(command, cwd=cwd)
except RuntimeError as exc:
print(f"\nPre-commit checks failed: {exc}")
return 1
print("\nAll pre-commit checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

8
scripts/setup_hooks.ps1 Normal file
View File

@@ -0,0 +1,8 @@
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path "$PSScriptRoot\..").Path
$hookPath = Join-Path $repoRoot "githooks"
Write-Host "Configuring git hooks path to: $hookPath"
git config core.hooksPath "$hookPath"
Write-Host "Git hooks configured. Hooks sourced from $hookPath"